Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 59 additions & 67 deletions src/server/routes/copy-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,8 @@ export default [
handler: async (request, h) => {
const { auth, params } = request
const { accessToken } = auth.credentials
const { form, referenceNumber, submissionToCopy } =
await getFormAndSubmission(params, accessToken)

const { translator } = await getTranslator(
request,
form,
submissionToCopy
)
const { form, referenceNumber, submissionToCopy, translator } =
await getCopyFormContext(request, params, accessToken)

const previewStatus = getPreviewStatus(submissionToCopy)
const backLinkHref = constructHomepageLink(form.slug, previewStatus)
Expand Down Expand Up @@ -153,18 +147,10 @@ export default [
const { accessToken } = auth.credentials
const { form, referenceNumber, submissionToCopy } =
await getFormAndSubmission(params, accessToken)
const { formDeclarations, formVersionHasChanged } =
await getFormComparisonState(submissionToCopy, form, referenceNumber)

const def = await getFormDefinitionForSubmission(
submissionToCopy,
form,
referenceNumber
)
const { formHasDeclaration, formVersionHasChanged } = getFormDetails(
def,
submissionToCopy
)

if (formHasDeclaration || formVersionHasChanged) {
if (formDeclarations.length > 0 || formVersionHasChanged) {
return h
.redirect(
`/confirm-copy-form/${form.id}/${referenceNumber}/form-changed`
Expand Down Expand Up @@ -201,14 +187,8 @@ export default [
handler: async (request, h) => {
const { auth, params } = request
const { accessToken } = auth.credentials
const { form, referenceNumber, submissionToCopy } =
await getFormAndSubmission(params, accessToken)

const { translator } = await getTranslator(
request,
form,
submissionToCopy
)
const { form, referenceNumber, submissionToCopy, translator } =
await getCopyFormContext(request, params, accessToken)

const model = copyFormViewModel(
'confirmCopyFormChanged.title',
Expand All @@ -233,14 +213,8 @@ export default [
handler: async (request, h) => {
const { auth, params } = request
const { accessToken } = auth.credentials
const { form, referenceNumber, submissionToCopy } =
await getFormAndSubmission(params, accessToken)

const { translator } = await getTranslator(
request,
form,
submissionToCopy
)
const { form, referenceNumber, submissionToCopy, translator } =
await getCopyFormContext(request, params, accessToken)

const model = copyFormViewModel(
'confirmCopyCheckAnswers.title',
Expand All @@ -267,16 +241,12 @@ export default [
const { accessToken } = auth.credentials
const { form, referenceNumber, submissionToCopy } =
await getFormAndSubmission(params, accessToken)

const def = await getFormDefinitionForSubmission(
submissionToCopy,
form,
referenceNumber
)
const { formVersionHasChanged } = getFormDetails(def, submissionToCopy)
const { formDeclarations, formVersionHasChanged } =
await getFormComparisonState(submissionToCopy, form, referenceNumber)
const ignoreKeys = formDeclarations.map((declaration) => declaration.name)

// Set state and dispatch user into the form
await setCacheState(request, submissionToCopy, form)
await setCacheState(request, submissionToCopy, form, ignoreKeys)

// If the form version has changed, redirect to the form start page and walkthrough,
// otherwise redirect to the summary page and allow the engine to handle the walkthrough
Expand All @@ -285,9 +255,10 @@ export default [
submissionToCopy.meta.isPreview,
submissionToCopy.meta.status
)
const nextHref = formVersionHasChanged
? formHref
: `${formHref}${ControllerPath.Summary}`
const nextHref =
formVersionHasChanged || formDeclarations.length > 0
? formHref
: `${formHref}${ControllerPath.Summary}`

return h.redirect(nextHref)
},
Expand All @@ -301,6 +272,36 @@ export default [
})
]

/**
* @param {Request<{ Params: CopyFormParams }>} request
* @param {CopyFormParams} params
* @param {string} accessToken
* @returns {Promise<{ form: FormMetadata, referenceNumber: string, submissionToCopy: FormSubmissionDocument, translator: Awaited<ReturnType<typeof getTranslator>>['translator'] }>}
*/
async function getCopyFormContext(request, params, accessToken) {
const { form, referenceNumber, submissionToCopy } =
await getFormAndSubmission(params, accessToken)
const { translator } = await getTranslator(request, form, submissionToCopy)

return { form, referenceNumber, submissionToCopy, translator }
}

/**
* @param {FormSubmissionDocument} submissionToCopy
* @param {FormMetadata} form
* @param {string} referenceNumber
* @returns {Promise<{ formDeclarations: DeclarationFieldComponent[], formVersionHasChanged: boolean }>}
*/
async function getFormComparisonState(submissionToCopy, form, referenceNumber) {
const def = await getFormDefinitionForSubmission(
submissionToCopy,
form,
referenceNumber
)

return getFormDetails(def, submissionToCopy)
}

/**
* @param {string} pageTitleKey - the page title key for translation
* @param {string} backLinkHref - the back link href
Expand Down Expand Up @@ -372,46 +373,37 @@ async function getFormDefinitionForSubmission(
* Get diff details about the form definition and submission
* @param {FormDefinition} def
* @param {FormSubmissionDocument} submission
* @returns {{ formHasDeclaration: boolean, formVersionHasChanged: boolean }}
* @returns {{ formDeclarations: DeclarationFieldComponent[], formVersionHasChanged: boolean }}
*/
function getFormDetails(def, submission) {
const formHasDeclaration = def.pages
const formDeclarations = def.pages
.flatMap((p) => (hasFormComponents(p) ? p.components : []))
.some((c) => c.type === ComponentType.DeclarationField)
.filter((c) => c.type === ComponentType.DeclarationField)

const latestVersionMetadata = /** @type {FormVersionMetadata | undefined} */ (
def.metadata?.$$__formVersion
)

const { referenceNumber, status, versionMetadata } = submission.meta

if (!latestVersionMetadata) {
throw Boom.badData(
`Cannot copy form '${referenceNumber}' because the latest ${status} version of the form definition doesn't contain versionMetadata`
)
}

if (!versionMetadata) {
throw Boom.badData(
`Cannot copy form '${referenceNumber}' because the submission doesn't contain versionMetadata`
)
}
const { versionMetadata } = submission.meta

const formVersionHasChanged =
latestVersionMetadata.versionNumber > versionMetadata.versionNumber
latestVersionMetadata && versionMetadata
? latestVersionMetadata.versionNumber > versionMetadata.versionNumber
: true // If either version metadata is missing, assume the form has changed

return { formHasDeclaration, formVersionHasChanged }
return { formDeclarations, formVersionHasChanged }
}

/**
* Set the cache state with the copied submission
* @param {Request<{ Params: CopyFormParams }>} request
* @param {FormSubmissionDocument} submissionToCopy
* @param {FormMetadata} form
* @param {string[]} [ignoreKeys] - keys to ignore when copying state (e.g. declaration fields)
*/
async function setCacheState(request, submissionToCopy, form) {
async function setCacheState(request, submissionToCopy, form, ignoreKeys = []) {
const cacheService = getCacheService(request.server)
const copiedState = mapValueToState(submissionToCopy)
const copiedState = mapValueToState(submissionToCopy, ignoreKeys)
const state = getPreviewStatus(submissionToCopy)
const req = { yar: request.yar, params: { slug: form.slug, state } }

Expand Down Expand Up @@ -456,7 +448,7 @@ async function setCacheState(request, submissionToCopy, form) {
/**
* @import { ServerRoute, Request, RequestQuery } from '@hapi/hapi'
* @import { Yar } from '@hapi/yar'
* @import { FormVersionMetadata, FormMetadata, FormDefinition, FormStatus } from '@defra/forms-model'
* @import { FormVersionMetadata, FormMetadata, FormDefinition, FormStatus, DeclarationFieldComponent } from '@defra/forms-model'
* @import { Translator } from '@defra/forms-engine-plugin/engine/i18n/types.js'
* @import { FormSubmissionDocument } from '~/src/server/services/submissionService.js'
*/
42 changes: 38 additions & 4 deletions src/server/routes/copy-form.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ describe('Copy form routes', () => {
expect(response.statusCode).toBe(StatusCodes.NOT_FOUND)
})

test('responds with unprocessable entity when the form version metadata is missing', async () => {
test('redirects to the changed form page when the form version metadata is missing', async () => {
jest
.mocked(getFormMetadataById)
// @ts-expect-error - allow partial objects for tests
Expand All @@ -294,10 +294,13 @@ describe('Copy form routes', () => {

const { response } = await renderResponse(server, options)

expect(response.statusCode).toBe(StatusCodes.UNPROCESSABLE_ENTITY)
expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY)
expect(response.headers.location).toBe(
`/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/form-changed`
)
})

test('responds with unprocessable entity when the submission version metadata is missing', async () => {
test('redirects to the changed form page when the submission version metadata is missing', async () => {
jest
.mocked(getFormMetadataById)
// @ts-expect-error - allow partial objects for tests
Expand All @@ -320,7 +323,10 @@ describe('Copy form routes', () => {

const { response } = await renderResponse(server, options)

expect(response.statusCode).toBe(StatusCodes.UNPROCESSABLE_ENTITY)
expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY)
expect(response.headers.location).toBe(
`/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/form-changed`
)
})
})

Expand Down Expand Up @@ -395,6 +401,34 @@ describe('Copy form routes', () => {
expect(response.headers.location).toBe('/form/my-form')
})

test('redirects to the form when the form contains declaration fields', async () => {
jest
.mocked(getFormMetadataById)
// @ts-expect-error - allow partial objects for tests
.mockResolvedValueOnce({ id: FORM_ID, slug: 'my-form' })
jest.mocked(getSubmissionRecord).mockResolvedValueOnce(
// @ts-expect-error - allow partial objects for tests
buildSubmission({ versionNumber: 1 })
)

jest.mocked(getFormDefinition).mockResolvedValueOnce(
// @ts-expect-error - allow partial objects for tests
buildFormDefinition({ versionNumber: 1, hasDeclaration: true })
)

const options = {
method: 'POST',
url: `/confirm-copy-form/${FORM_ID}/${REFERENCE_NUMBER}/check-answers`,
auth: { strategy: 'citizen-session', credentials },
payload: { confirmCopy: true }
}

const { response } = await renderResponse(server, options)

expect(response.statusCode).toBe(StatusCodes.MOVED_TEMPORARILY)
expect(response.headers.location).toBe('/form/my-form')
})

test('redirects to the summary when the form version has not changed', async () => {
jest
.mocked(getFormMetadataById)
Expand Down
16 changes: 9 additions & 7 deletions src/server/utils/map-submission-to-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { randomUUID } from 'node:crypto'
/**
* Handles sub fields like address.addressLine1 and date.day
* @param {Record<string, FormStateValue>} subfieldObject
* @param {[string, RichFormValue|null]} entry
* @param {[string, RichFormValue | null]} entry
* @param {string[]} ignoreKeys - keys to ignore when copying state (e.g. declaration fields)
* @returns {Record<string, FormStateValue>}
*/
function handleSubfields(subfieldObject, [key, value]) {
function handleSubfields(subfieldObject, [key, value], ignoreKeys = []) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const subValues = Object.entries(value).reduce((acc2, [key2, value2]) => {
if (value2 === undefined) {
if (value2 === undefined || ignoreKeys.includes(key2)) {
return acc2
}
return {
Expand All @@ -24,7 +25,7 @@ function handleSubfields(subfieldObject, [key, value]) {
}
}

if (value === undefined) {
if (value === undefined || ignoreKeys.includes(key)) {
return subfieldObject
}

Expand All @@ -44,19 +45,20 @@ export const isString = (value) => typeof value === 'string'
/**
* Turns a form submission document into data suitable for session state
* @param {FormSubmissionDocument} formSubmissionMessage
* @param {string[]} [ignoreKeys] - keys to ignore when copying state (e.g. declaration fields)
*/
export function mapValueToState(formSubmissionMessage) {
export function mapValueToState(formSubmissionMessage, ignoreKeys = []) {
const mainEntries = Object.entries(formSubmissionMessage.data.main)
const main = mainEntries.reduce(
(acc, entry) => handleSubfields(acc, entry),
(acc, entry) => handleSubfields(acc, entry, ignoreKeys),
/** @type {Record<string, FormStateValue>} */ ({})
)

const repeaterEntries = Object.entries(formSubmissionMessage.data.repeaters)
const repeaters = repeaterEntries.reduce((repeaterObject, [key, value]) => {
const values = value.map((repeater) => {
const reduced = Object.entries(repeater).reduce(
(acc, entry) => handleSubfields(acc, entry),
(acc, entry) => handleSubfields(acc, entry, ignoreKeys),
/** @type {Record<string, FormStateValue>} */ ({})
)
return {
Expand Down
53 changes: 53 additions & 0 deletions src/server/utils/map-submission-to-state.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,59 @@ import {
} from '~/src/server/utils/map-submission-to-state.js'

describe('mapValueToState', () => {
it('should ignore nested keys when passed to the mapper', () => {
const message = {
data: {
main: {
declaration: 'I agree',
firstName: 'Alice',
address: {
line1: '1 High Street',
postcode: 'SW1A 1AA'
},
dateOfBirth: {
day: 1,
month: 2,
year: 2000
}
},
repeaters: {
people: [
{
name: 'Bob',
declaration: 'I agree',
dateOfBirth: {
day: 3,
month: 4,
year: 2001
}
}
]
}
}
}

expect(
// @ts-expect-error - test data is not a valid FormSubmissionDocument
mapValueToState(message, [
'declaration',
'line1',
'postcode',
'day',
'month',
'year'
])
).toEqual({
firstName: 'Alice',
people: [
{
name: 'Bob',
itemId: expect.any(String)
}
]
})
})

it('should map adaptor v2 message to state2', () => {
/**
* @type {import('~/src/server/services/submissionService.js').FormSubmissionDocument}
Expand Down
Loading