@defra/forms-engine-plugin 4.0.28 → 4.0.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.server/server/plugins/engine/beta/form-context.d.ts +25 -0
- package/.server/server/plugins/engine/beta/form-context.js +122 -0
- package/.server/server/plugins/engine/beta/form-context.js.map +1 -0
- package/.server/server/plugins/engine/index.d.ts +1 -0
- package/.server/server/plugins/engine/index.js +1 -0
- package/.server/server/plugins/engine/index.js.map +1 -1
- package/.server/server/plugins/engine/models/FormModel.d.ts +3 -2
- package/.server/server/plugins/engine/models/FormModel.js +3 -0
- package/.server/server/plugins/engine/models/FormModel.js.map +1 -1
- package/.server/server/plugins/engine/pageControllers/PageController.js +3 -1
- package/.server/server/plugins/engine/pageControllers/PageController.js.map +1 -1
- package/.server/server/plugins/engine/routes/index.js +11 -65
- package/.server/server/plugins/engine/routes/index.js.map +1 -1
- package/package.json +2 -2
- package/src/server/plugins/engine/beta/form-context.test.ts +359 -0
- package/src/server/plugins/engine/beta/form-context.ts +250 -0
- package/src/server/plugins/engine/index.ts +6 -0
- package/src/server/plugins/engine/models/FormModel.test.ts +70 -0
- package/src/server/plugins/engine/models/FormModel.ts +10 -3
- package/src/server/plugins/engine/pageControllers/PageController.ts +3 -3
- package/src/server/plugins/engine/pageControllers/QuestionPageController.test.ts +7 -5
- package/src/server/plugins/engine/routes/index.ts +10 -71
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":["Boom","isEqual","EXTERNAL_STATE_APPENDAGE","EXTERNAL_STATE_PAYLOAD","PREVIEW_PATH_PREFIX","FormComponent","isFormState","checkEmailAddressForLiveFormSubmission","checkFormStatus","findPage","getCacheService","getPage","getStartPath","proceed","FormModel","generateUniqueReference","defaultServices","redirectOrMakeHandler","request","h","onRequest","makeHandler","app","params","model","notFound","path","cacheService","server","page","state","getState","$$__referenceNumber","prefix","def","metadata","referenceNumberPrefix","badImplementation","referenceNumber","mergeState","importExternalComponentState","flash","getFlash","context","getFormContext","errors","relevantPath","getRelevantPath","summaryPath","getSummaryPath","result","continue","startsWith","isForceAccess","redirectTo","next","length","query","returnUrl","getHref","externalComponentData","yar","Array","isArray","typedStateAppendage","componentName","component","stateAppendage","data","componentMap","get","Error","TypeError","isStateValid","isState","componentState","Object","fromEntries","entries","map","key","value","pageState","getStateFromValidForm","savedState","payload","stashedPayload","localState","makeLoadFormPreHandler","options","realm","modifiers","route","services","controllers","ordnanceSurveyApiKey","formsService","handler","slug","isPreview","formState","getFormMetadata","id","item","models","updatedAt","logger","info","definition","getFormDefinition","emailAddress","notificationEmail","outputEmail","basePath","substring","versionNumber","versions","formId","set","dispatchHandler","servicePath"],"sources":["../../../../../src/server/plugins/engine/routes/index.ts"],"sourcesContent":["import Boom from '@hapi/boom'\nimport {\n type ResponseObject,\n type ResponseToolkit,\n type Server\n} from '@hapi/hapi'\nimport { isEqual } from 'date-fns'\n\nimport {\n EXTERNAL_STATE_APPENDAGE,\n EXTERNAL_STATE_PAYLOAD,\n PREVIEW_PATH_PREFIX\n} from '~/src/server/constants.js'\nimport {\n FormComponent,\n isFormState\n} from '~/src/server/plugins/engine/components/FormComponent.js'\nimport {\n checkEmailAddressForLiveFormSubmission,\n checkFormStatus,\n findPage,\n getCacheService,\n getPage,\n getStartPath,\n proceed\n} from '~/src/server/plugins/engine/helpers.js'\nimport { FormModel } from '~/src/server/plugins/engine/models/index.js'\nimport { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'\nimport { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'\nimport * as defaultServices from '~/src/server/plugins/engine/services/index.js'\nimport {\n type AnyFormRequest,\n type ExternalStateAppendage,\n type FormContext,\n type FormPayload,\n type FormSubmissionState,\n type OnRequestCallback,\n type PluginOptions\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormResponseToolkit\n} from '~/src/server/routes/types.js'\n\nexport async function redirectOrMakeHandler(\n request: AnyFormRequest,\n h: FormResponseToolkit,\n onRequest: OnRequestCallback | undefined,\n makeHandler: (\n page: PageControllerClass,\n context: FormContext\n ) => ResponseObject | Promise<ResponseObject>\n) {\n const { app, params } = request\n const { model } = app\n\n if (!model) {\n throw Boom.notFound(`No model found for /${params.path}`)\n }\n\n const cacheService = getCacheService(request.server)\n const page = getPage(model, request)\n let state = await page.getState(request)\n\n if (!state.$$__referenceNumber) {\n const prefix = model.def.metadata?.referenceNumberPrefix ?? ''\n\n if (typeof prefix !== 'string') {\n throw Boom.badImplementation(\n 'Reference number prefix must be a string or undefined'\n )\n }\n\n const referenceNumber = generateUniqueReference(prefix)\n state = await page.mergeState(request, state, {\n $$__referenceNumber: referenceNumber\n })\n }\n\n state = await importExternalComponentState(request, page, state)\n\n const flash = cacheService.getFlash(request)\n const context = model.getFormContext(request, state, flash?.errors)\n const relevantPath = page.getRelevantPath(request, context)\n const summaryPath = page.getSummaryPath()\n\n // Call the onRequest callback if it has been supplied\n if (onRequest) {\n const result = await onRequest(request, h, context)\n if (result !== h.continue) {\n return result\n }\n }\n\n // Return handler for relevant pages or preview URL direct access\n if (relevantPath.startsWith(page.path) || context.isForceAccess) {\n return makeHandler(page, context)\n }\n\n // Redirect back to last relevant page\n const redirectTo = findPage(model, relevantPath)\n\n // Set the return URL unless an exit page\n if (redirectTo?.next.length) {\n request.query.returnUrl = page.getHref(summaryPath)\n }\n\n return proceed(request, h, page.getHref(relevantPath))\n}\n\nasync function importExternalComponentState(\n request: AnyFormRequest,\n page: PageControllerClass,\n state: FormSubmissionState\n): Promise<FormSubmissionState> {\n const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE)\n\n if (Array.isArray(externalComponentData)) {\n return state\n }\n\n const typedStateAppendage = externalComponentData as ExternalStateAppendage\n const componentName = typedStateAppendage.component\n const stateAppendage = typedStateAppendage.data\n const component = request.app.model?.componentMap.get(componentName)\n\n if (!component) {\n throw new Error(`Component ${componentName} not found in form`)\n }\n\n if (!(component instanceof FormComponent)) {\n throw new TypeError(\n `Component ${componentName} is not a FormComponent and does not support isState`\n )\n }\n\n const isStateValid = component.isState(stateAppendage)\n\n if (!isStateValid) {\n throw new Error(`State for component ${componentName} is invalid`)\n }\n\n const componentState = isFormState(stateAppendage)\n ? Object.fromEntries(\n Object.entries(stateAppendage).map(([key, value]) => [\n `${componentName}__${key}`,\n value\n ])\n )\n : { [componentName]: stateAppendage }\n\n // Save the external component state immediately\n const pageState = page.getStateFromValidForm(\n request,\n state,\n componentState as FormPayload\n )\n const savedState = await page.mergeState(request, state, pageState)\n\n // Merge any stashed payload into the local state\n const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)\n const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload)\n\n const localState = page.getStateFromValidForm(request, savedState, {\n ...stashedPayload,\n ...componentState\n } as FormPayload)\n\n return { ...savedState, ...localState }\n}\n\nexport function makeLoadFormPreHandler(server: Server, options: PluginOptions) {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong\n const prefix = server.realm.modifiers.route.prefix ?? ''\n\n const {\n services = defaultServices,\n controllers,\n ordnanceSurveyApiKey\n } = options\n\n const { formsService } = services\n\n async function handler(request: AnyFormRequest, h: ResponseToolkit) {\n if (server.app.model) {\n request.app.model = server.app.model\n\n return h.continue\n }\n\n const { params } = request\n const { slug } = params\n const { isPreview, state: formState } = checkFormStatus(params)\n\n // Get the form metadata using the `slug` param\n const metadata = await formsService.getFormMetadata(slug)\n\n const { id, [formState]: state } = metadata\n\n // Check the metadata supports the requested state\n if (!state) {\n throw Boom.notFound(`No '${formState}' state for form metadata ${id}`)\n }\n\n // Cache the models based on id, state and whether\n // it's a preview or not. There could be up to 3 models\n // cached for a single form:\n // \"{id}_live_false\" (live/live)\n // \"{id}_live_true\" (live/preview)\n // \"{id}_draft_true\" (draft/preview)\n const key = `${id}_${formState}_${isPreview}`\n let item = server.app.models.get(key)\n\n if (!item || !isEqual(item.updatedAt, state.updatedAt)) {\n server.logger.info(`Getting form definition ${id} (${slug}) ${formState}`)\n\n // Get the form definition using the `id` from the metadata\n const definition = await formsService.getFormDefinition(id, formState)\n\n if (!definition) {\n throw Boom.notFound(\n `No definition found for form metadata ${id} (${slug}) ${formState}`\n )\n }\n\n const emailAddress = metadata.notificationEmail ?? definition.outputEmail\n\n checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)\n\n // Build the form model\n server.logger.info(\n `Building model for form definition ${id} (${slug}) ${formState}`\n )\n\n // Set up the basePath for the model\n const basePath = (\n isPreview\n ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}`\n : `${prefix}/${slug}`\n ).substring(1)\n\n const versionNumber = metadata.versions?.[0]?.versionNumber\n\n // Construct the form model\n const model = new FormModel(\n definition,\n { basePath, versionNumber, ordnanceSurveyApiKey, formId: id },\n services,\n controllers\n )\n\n // Create new item and add it to the item cache\n item = { model, updatedAt: state.updatedAt }\n server.app.models.set(key, item)\n }\n\n // Assign the model to the request data\n // for use in the downstream handler\n request.app.model = item.model\n\n return h.continue\n }\n\n return handler\n}\n\nexport function dispatchHandler(request: FormRequest, h: FormResponseToolkit) {\n const { model } = request.app\n\n const servicePath = model ? `/${model.basePath}` : ''\n return proceed(request, h, `${servicePath}${getStartPath(model)}`)\n}\n"],"mappings":"AAAA,OAAOA,IAAI,MAAM,YAAY;AAM7B,SAASC,OAAO,QAAQ,UAAU;AAElC,SACEC,wBAAwB,EACxBC,sBAAsB,EACtBC,mBAAmB;AAErB,SACEC,aAAa,EACbC,WAAW;AAEb,SACEC,sCAAsC,EACtCC,eAAe,EACfC,QAAQ,EACRC,eAAe,EACfC,OAAO,EACPC,YAAY,EACZC,OAAO;AAET,SAASC,SAAS;AAElB,SAASC,uBAAuB;AAChC,OAAO,KAAKC,eAAe;AAe3B,OAAO,eAAeC,qBAAqBA,CACzCC,OAAuB,EACvBC,CAAsB,EACtBC,SAAwC,EACxCC,WAG6C,EAC7C;EACA,MAAM;IAAEC,GAAG;IAAEC;EAAO,CAAC,GAAGL,OAAO;EAC/B,MAAM;IAAEM;EAAM,CAAC,GAAGF,GAAG;EAErB,IAAI,CAACE,KAAK,EAAE;IACV,MAAMxB,IAAI,CAACyB,QAAQ,CAAC,uBAAuBF,MAAM,CAACG,IAAI,EAAE,CAAC;EAC3D;EAEA,MAAMC,YAAY,GAAGjB,eAAe,CAACQ,OAAO,CAACU,MAAM,CAAC;EACpD,MAAMC,IAAI,GAAGlB,OAAO,CAACa,KAAK,EAAEN,OAAO,CAAC;EACpC,IAAIY,KAAK,GAAG,MAAMD,IAAI,CAACE,QAAQ,CAACb,OAAO,CAAC;EAExC,IAAI,CAACY,KAAK,CAACE,mBAAmB,EAAE;IAC9B,MAAMC,MAAM,GAAGT,KAAK,CAACU,GAAG,CAACC,QAAQ,EAAEC,qBAAqB,IAAI,EAAE;IAE9D,IAAI,OAAOH,MAAM,KAAK,QAAQ,EAAE;MAC9B,MAAMjC,IAAI,CAACqC,iBAAiB,CAC1B,uDACF,CAAC;IACH;IAEA,MAAMC,eAAe,GAAGvB,uBAAuB,CAACkB,MAAM,CAAC;IACvDH,KAAK,GAAG,MAAMD,IAAI,CAACU,UAAU,CAACrB,OAAO,EAAEY,KAAK,EAAE;MAC5CE,mBAAmB,EAAEM;IACvB,CAAC,CAAC;EACJ;EAEAR,KAAK,GAAG,MAAMU,4BAA4B,CAACtB,OAAO,EAAEW,IAAI,EAAEC,KAAK,CAAC;EAEhE,MAAMW,KAAK,GAAGd,YAAY,CAACe,QAAQ,CAACxB,OAAO,CAAC;EAC5C,MAAMyB,OAAO,GAAGnB,KAAK,CAACoB,cAAc,CAAC1B,OAAO,EAAEY,KAAK,EAAEW,KAAK,EAAEI,MAAM,CAAC;EACnE,MAAMC,YAAY,GAAGjB,IAAI,CAACkB,eAAe,CAAC7B,OAAO,EAAEyB,OAAO,CAAC;EAC3D,MAAMK,WAAW,GAAGnB,IAAI,CAACoB,cAAc,CAAC,CAAC;;EAEzC;EACA,IAAI7B,SAAS,EAAE;IACb,MAAM8B,MAAM,GAAG,MAAM9B,SAAS,CAACF,OAAO,EAAEC,CAAC,EAAEwB,OAAO,CAAC;IACnD,IAAIO,MAAM,KAAK/B,CAAC,CAACgC,QAAQ,EAAE;MACzB,OAAOD,MAAM;IACf;EACF;;EAEA;EACA,IAAIJ,YAAY,CAACM,UAAU,CAACvB,IAAI,CAACH,IAAI,CAAC,IAAIiB,OAAO,CAACU,aAAa,EAAE;IAC/D,OAAOhC,WAAW,CAACQ,IAAI,EAAEc,OAAO,CAAC;EACnC;;EAEA;EACA,MAAMW,UAAU,GAAG7C,QAAQ,CAACe,KAAK,EAAEsB,YAAY,CAAC;;EAEhD;EACA,IAAIQ,UAAU,EAAEC,IAAI,CAACC,MAAM,EAAE;IAC3BtC,OAAO,CAACuC,KAAK,CAACC,SAAS,GAAG7B,IAAI,CAAC8B,OAAO,CAACX,WAAW,CAAC;EACrD;EAEA,OAAOnC,OAAO,CAACK,OAAO,EAAEC,CAAC,EAAEU,IAAI,CAAC8B,OAAO,CAACb,YAAY,CAAC,CAAC;AACxD;AAEA,eAAeN,4BAA4BA,CACzCtB,OAAuB,EACvBW,IAAyB,EACzBC,KAA0B,EACI;EAC9B,MAAM8B,qBAAqB,GAAG1C,OAAO,CAAC2C,GAAG,CAACpB,KAAK,CAACvC,wBAAwB,CAAC;EAEzE,IAAI4D,KAAK,CAACC,OAAO,CAACH,qBAAqB,CAAC,EAAE;IACxC,OAAO9B,KAAK;EACd;EAEA,MAAMkC,mBAAmB,GAAGJ,qBAA+C;EAC3E,MAAMK,aAAa,GAAGD,mBAAmB,CAACE,SAAS;EACnD,MAAMC,cAAc,GAAGH,mBAAmB,CAACI,IAAI;EAC/C,MAAMF,SAAS,GAAGhD,OAAO,CAACI,GAAG,CAACE,KAAK,EAAE6C,YAAY,CAACC,GAAG,CAACL,aAAa,CAAC;EAEpE,IAAI,CAACC,SAAS,EAAE;IACd,MAAM,IAAIK,KAAK,CAAC,aAAaN,aAAa,oBAAoB,CAAC;EACjE;EAEA,IAAI,EAAEC,SAAS,YAAY7D,aAAa,CAAC,EAAE;IACzC,MAAM,IAAImE,SAAS,CACjB,aAAaP,aAAa,sDAC5B,CAAC;EACH;EAEA,MAAMQ,YAAY,GAAGP,SAAS,CAACQ,OAAO,CAACP,cAAc,CAAC;EAEtD,IAAI,CAACM,YAAY,EAAE;IACjB,MAAM,IAAIF,KAAK,CAAC,uBAAuBN,aAAa,aAAa,CAAC;EACpE;EAEA,MAAMU,cAAc,GAAGrE,WAAW,CAAC6D,cAAc,CAAC,GAC9CS,MAAM,CAACC,WAAW,CAChBD,MAAM,CAACE,OAAO,CAACX,cAAc,CAAC,CAACY,GAAG,CAAC,CAAC,CAACC,GAAG,EAAEC,KAAK,CAAC,KAAK,CACnD,GAAGhB,aAAa,KAAKe,GAAG,EAAE,EAC1BC,KAAK,CACN,CACH,CAAC,GACD;IAAE,CAAChB,aAAa,GAAGE;EAAe,CAAC;;EAEvC;EACA,MAAMe,SAAS,GAAGrD,IAAI,CAACsD,qBAAqB,CAC1CjE,OAAO,EACPY,KAAK,EACL6C,cACF,CAAC;EACD,MAAMS,UAAU,GAAG,MAAMvD,IAAI,CAACU,UAAU,CAACrB,OAAO,EAAEY,KAAK,EAAEoD,SAAS,CAAC;;EAEnE;EACA,MAAMG,OAAO,GAAGnE,OAAO,CAAC2C,GAAG,CAACpB,KAAK,CAACtC,sBAAsB,CAAC;EACzD,MAAMmF,cAAc,GAAGxB,KAAK,CAACC,OAAO,CAACsB,OAAO,CAAC,GAAG,CAAC,CAAC,GAAIA,OAAuB;EAE7E,MAAME,UAAU,GAAG1D,IAAI,CAACsD,qBAAqB,CAACjE,OAAO,EAAEkE,UAAU,EAAE;IACjE,GAAGE,cAAc;IACjB,GAAGX;EACL,CAAgB,CAAC;EAEjB,OAAO;IAAE,GAAGS,UAAU;IAAE,GAAGG;EAAW,CAAC;AACzC;AAEA,OAAO,SAASC,sBAAsBA,CAAC5D,MAAc,EAAE6D,OAAsB,EAAE;EAC7E;EACA,MAAMxD,MAAM,GAAGL,MAAM,CAAC8D,KAAK,CAACC,SAAS,CAACC,KAAK,CAAC3D,MAAM,IAAI,EAAE;EAExD,MAAM;IACJ4D,QAAQ,GAAG7E,eAAe;IAC1B8E,WAAW;IACXC;EACF,CAAC,GAAGN,OAAO;EAEX,MAAM;IAAEO;EAAa,CAAC,GAAGH,QAAQ;EAEjC,eAAeI,OAAOA,CAAC/E,OAAuB,EAAEC,CAAkB,EAAE;IAClE,IAAIS,MAAM,CAACN,GAAG,CAACE,KAAK,EAAE;MACpBN,OAAO,CAACI,GAAG,CAACE,KAAK,GAAGI,MAAM,CAACN,GAAG,CAACE,KAAK;MAEpC,OAAOL,CAAC,CAACgC,QAAQ;IACnB;IAEA,MAAM;MAAE5B;IAAO,CAAC,GAAGL,OAAO;IAC1B,MAAM;MAAEgF;IAAK,CAAC,GAAG3E,MAAM;IACvB,MAAM;MAAE4E,SAAS;MAAErE,KAAK,EAAEsE;IAAU,CAAC,GAAG5F,eAAe,CAACe,MAAM,CAAC;;IAE/D;IACA,MAAMY,QAAQ,GAAG,MAAM6D,YAAY,CAACK,eAAe,CAACH,IAAI,CAAC;IAEzD,MAAM;MAAEI,EAAE;MAAE,CAACF,SAAS,GAAGtE;IAAM,CAAC,GAAGK,QAAQ;;IAE3C;IACA,IAAI,CAACL,KAAK,EAAE;MACV,MAAM9B,IAAI,CAACyB,QAAQ,CAAC,OAAO2E,SAAS,6BAA6BE,EAAE,EAAE,CAAC;IACxE;;IAEA;IACA;IACA;IACA;IACA;IACA;IACA,MAAMtB,GAAG,GAAG,GAAGsB,EAAE,IAAIF,SAAS,IAAID,SAAS,EAAE;IAC7C,IAAII,IAAI,GAAG3E,MAAM,CAACN,GAAG,CAACkF,MAAM,CAAClC,GAAG,CAACU,GAAG,CAAC;IAErC,IAAI,CAACuB,IAAI,IAAI,CAACtG,OAAO,CAACsG,IAAI,CAACE,SAAS,EAAE3E,KAAK,CAAC2E,SAAS,CAAC,EAAE;MACtD7E,MAAM,CAAC8E,MAAM,CAACC,IAAI,CAAC,2BAA2BL,EAAE,KAAKJ,IAAI,KAAKE,SAAS,EAAE,CAAC;;MAE1E;MACA,MAAMQ,UAAU,GAAG,MAAMZ,YAAY,CAACa,iBAAiB,CAACP,EAAE,EAAEF,SAAS,CAAC;MAEtE,IAAI,CAACQ,UAAU,EAAE;QACf,MAAM5G,IAAI,CAACyB,QAAQ,CACjB,yCAAyC6E,EAAE,KAAKJ,IAAI,KAAKE,SAAS,EACpE,CAAC;MACH;MAEA,MAAMU,YAAY,GAAG3E,QAAQ,CAAC4E,iBAAiB,IAAIH,UAAU,CAACI,WAAW;MAEzEzG,sCAAsC,CAACuG,YAAY,EAAEX,SAAS,CAAC;;MAE/D;MACAvE,MAAM,CAAC8E,MAAM,CAACC,IAAI,CAChB,sCAAsCL,EAAE,KAAKJ,IAAI,KAAKE,SAAS,EACjE,CAAC;;MAED;MACA,MAAMa,QAAQ,GAAG,CACfd,SAAS,GACL,GAAGlE,MAAM,GAAG7B,mBAAmB,IAAIgG,SAAS,IAAIF,IAAI,EAAE,GACtD,GAAGjE,MAAM,IAAIiE,IAAI,EAAE,EACvBgB,SAAS,CAAC,CAAC,CAAC;MAEd,MAAMC,aAAa,GAAGhF,QAAQ,CAACiF,QAAQ,GAAG,CAAC,CAAC,EAAED,aAAa;;MAE3D;MACA,MAAM3F,KAAK,GAAG,IAAIV,SAAS,CACzB8F,UAAU,EACV;QAAEK,QAAQ;QAAEE,aAAa;QAAEpB,oBAAoB;QAAEsB,MAAM,EAAEf;MAAG,CAAC,EAC7DT,QAAQ,EACRC,WACF,CAAC;;MAED;MACAS,IAAI,GAAG;QAAE/E,KAAK;QAAEiF,SAAS,EAAE3E,KAAK,CAAC2E;MAAU,CAAC;MAC5C7E,MAAM,CAACN,GAAG,CAACkF,MAAM,CAACc,GAAG,CAACtC,GAAG,EAAEuB,IAAI,CAAC;IAClC;;IAEA;IACA;IACArF,OAAO,CAACI,GAAG,CAACE,KAAK,GAAG+E,IAAI,CAAC/E,KAAK;IAE9B,OAAOL,CAAC,CAACgC,QAAQ;EACnB;EAEA,OAAO8C,OAAO;AAChB;AAEA,OAAO,SAASsB,eAAeA,CAACrG,OAAoB,EAAEC,CAAsB,EAAE;EAC5E,MAAM;IAAEK;EAAM,CAAC,GAAGN,OAAO,CAACI,GAAG;EAE7B,MAAMkG,WAAW,GAAGhG,KAAK,GAAG,IAAIA,KAAK,CAACyF,QAAQ,EAAE,GAAG,EAAE;EACrD,OAAOpG,OAAO,CAACK,OAAO,EAAEC,CAAC,EAAE,GAAGqG,WAAW,GAAG5G,YAAY,CAACY,KAAK,CAAC,EAAE,CAAC;AACpE","ignoreList":[]}
|
|
1
|
+
{"version":3,"file":"index.js","names":["Boom","EXTERNAL_STATE_APPENDAGE","EXTERNAL_STATE_PAYLOAD","resolveFormModel","FormComponent","isFormState","checkFormStatus","findPage","getCacheService","getPage","getStartPath","proceed","generateUniqueReference","defaultServices","redirectOrMakeHandler","request","h","onRequest","makeHandler","app","params","model","notFound","path","cacheService","server","page","state","getState","$$__referenceNumber","prefix","def","metadata","referenceNumberPrefix","badImplementation","referenceNumber","mergeState","importExternalComponentState","flash","getFlash","context","getFormContext","errors","relevantPath","getRelevantPath","summaryPath","getSummaryPath","result","continue","startsWith","isForceAccess","redirectTo","next","length","query","returnUrl","getHref","externalComponentData","yar","Array","isArray","typedStateAppendage","componentName","component","stateAppendage","data","componentMap","get","Error","TypeError","isStateValid","isState","componentState","Object","fromEntries","entries","map","key","value","pageState","getStateFromValidForm","savedState","payload","stashedPayload","localState","makeLoadFormPreHandler","options","realm","modifiers","route","services","controllers","ordnanceSurveyApiKey","handler","slug","isPreview","formState","routePrefix","dispatchHandler","servicePath","basePath"],"sources":["../../../../../src/server/plugins/engine/routes/index.ts"],"sourcesContent":["import Boom from '@hapi/boom'\nimport {\n type ResponseObject,\n type ResponseToolkit,\n type Server\n} from '@hapi/hapi'\n\nimport {\n EXTERNAL_STATE_APPENDAGE,\n EXTERNAL_STATE_PAYLOAD\n} from '~/src/server/constants.js'\nimport { resolveFormModel } from '~/src/server/plugins/engine/beta/form-context.js'\nimport {\n FormComponent,\n isFormState\n} from '~/src/server/plugins/engine/components/FormComponent.js'\nimport {\n checkFormStatus,\n findPage,\n getCacheService,\n getPage,\n getStartPath,\n proceed\n} from '~/src/server/plugins/engine/helpers.js'\nimport { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'\nimport { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'\nimport * as defaultServices from '~/src/server/plugins/engine/services/index.js'\nimport {\n type AnyFormRequest,\n type ExternalStateAppendage,\n type FormContext,\n type FormPayload,\n type FormSubmissionState,\n type OnRequestCallback,\n type PluginOptions\n} from '~/src/server/plugins/engine/types.js'\nimport {\n type FormRequest,\n type FormResponseToolkit\n} from '~/src/server/routes/types.js'\n\nexport async function redirectOrMakeHandler(\n request: AnyFormRequest,\n h: FormResponseToolkit,\n onRequest: OnRequestCallback | undefined,\n makeHandler: (\n page: PageControllerClass,\n context: FormContext\n ) => ResponseObject | Promise<ResponseObject>\n) {\n const { app, params } = request\n const { model } = app\n\n if (!model) {\n throw Boom.notFound(`No model found for /${params.path}`)\n }\n\n const cacheService = getCacheService(request.server)\n const page = getPage(model, request)\n let state = await page.getState(request)\n\n if (!state.$$__referenceNumber) {\n const prefix = model.def.metadata?.referenceNumberPrefix ?? ''\n\n if (typeof prefix !== 'string') {\n throw Boom.badImplementation(\n 'Reference number prefix must be a string or undefined'\n )\n }\n\n const referenceNumber = generateUniqueReference(prefix)\n state = await page.mergeState(request, state, {\n $$__referenceNumber: referenceNumber\n })\n }\n\n state = await importExternalComponentState(request, page, state)\n\n const flash = cacheService.getFlash(request)\n const context = model.getFormContext(request, state, flash?.errors)\n const relevantPath = page.getRelevantPath(request, context)\n const summaryPath = page.getSummaryPath()\n\n // Call the onRequest callback if it has been supplied\n if (onRequest) {\n const result = await onRequest(request, h, context)\n if (result !== h.continue) {\n return result\n }\n }\n\n // Return handler for relevant pages or preview URL direct access\n if (relevantPath.startsWith(page.path) || context.isForceAccess) {\n return makeHandler(page, context)\n }\n\n // Redirect back to last relevant page\n const redirectTo = findPage(model, relevantPath)\n\n // Set the return URL unless an exit page\n if (redirectTo?.next.length) {\n request.query.returnUrl = page.getHref(summaryPath)\n }\n\n return proceed(request, h, page.getHref(relevantPath))\n}\n\nasync function importExternalComponentState(\n request: AnyFormRequest,\n page: PageControllerClass,\n state: FormSubmissionState\n): Promise<FormSubmissionState> {\n const externalComponentData = request.yar.flash(EXTERNAL_STATE_APPENDAGE)\n\n if (Array.isArray(externalComponentData)) {\n return state\n }\n\n const typedStateAppendage = externalComponentData as ExternalStateAppendage\n const componentName = typedStateAppendage.component\n const stateAppendage = typedStateAppendage.data\n const component = request.app.model?.componentMap.get(componentName)\n\n if (!component) {\n throw new Error(`Component ${componentName} not found in form`)\n }\n\n if (!(component instanceof FormComponent)) {\n throw new TypeError(\n `Component ${componentName} is not a FormComponent and does not support isState`\n )\n }\n\n const isStateValid = component.isState(stateAppendage)\n\n if (!isStateValid) {\n throw new Error(`State for component ${componentName} is invalid`)\n }\n\n const componentState = isFormState(stateAppendage)\n ? Object.fromEntries(\n Object.entries(stateAppendage).map(([key, value]) => [\n `${componentName}__${key}`,\n value\n ])\n )\n : { [componentName]: stateAppendage }\n\n // Save the external component state immediately\n const pageState = page.getStateFromValidForm(\n request,\n state,\n componentState as FormPayload\n )\n const savedState = await page.mergeState(request, state, pageState)\n\n // Merge any stashed payload into the local state\n const payload = request.yar.flash(EXTERNAL_STATE_PAYLOAD)\n const stashedPayload = Array.isArray(payload) ? {} : (payload as FormPayload)\n\n const localState = page.getStateFromValidForm(request, savedState, {\n ...stashedPayload,\n ...componentState\n } as FormPayload)\n\n return { ...savedState, ...localState }\n}\n\nexport function makeLoadFormPreHandler(server: Server, options: PluginOptions) {\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong\n const prefix = server.realm.modifiers.route.prefix ?? ''\n\n const {\n services = defaultServices,\n controllers,\n ordnanceSurveyApiKey\n } = options\n\n async function handler(request: AnyFormRequest, h: ResponseToolkit) {\n if (server.app.model) {\n request.app.model = server.app.model\n\n return h.continue\n }\n\n const { params } = request\n const { slug } = params\n const { isPreview, state: formState } = checkFormStatus(params)\n\n const model = await resolveFormModel(server, slug, formState, {\n services,\n controllers,\n ordnanceSurveyApiKey,\n routePrefix: prefix,\n isPreview\n })\n\n request.app.model = model\n\n return h.continue\n }\n\n return handler\n}\n\nexport function dispatchHandler(request: FormRequest, h: FormResponseToolkit) {\n const { model } = request.app\n\n const servicePath = model ? `/${model.basePath}` : ''\n return proceed(request, h, `${servicePath}${getStartPath(model)}`)\n}\n"],"mappings":"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","ignoreList":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@defra/forms-engine-plugin",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.30",
|
|
4
4
|
"description": "Defra forms engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
},
|
|
71
71
|
"license": "SEE LICENSE IN LICENSE",
|
|
72
72
|
"dependencies": {
|
|
73
|
-
"@defra/forms-model": "^3.0.
|
|
73
|
+
"@defra/forms-model": "^3.0.597",
|
|
74
74
|
"@defra/hapi-tracing": "^1.29.0",
|
|
75
75
|
"@elastic/ecs-pino-format": "^1.5.0",
|
|
76
76
|
"@hapi/boom": "^10.0.1",
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { type Request } from '@hapi/hapi'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getFirstJourneyPage,
|
|
5
|
+
getFormContext,
|
|
6
|
+
getFormModel,
|
|
7
|
+
resolveFormModel,
|
|
8
|
+
type FormModelOptions
|
|
9
|
+
} from '~/src/server/plugins/engine/beta/form-context.js'
|
|
10
|
+
import { PageController } from '~/src/server/plugins/engine/pageControllers/PageController.js'
|
|
11
|
+
import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js'
|
|
12
|
+
import { type FormContext } from '~/src/server/plugins/engine/types.js'
|
|
13
|
+
import { FormStatus } from '~/src/server/routes/types.js'
|
|
14
|
+
import { type FormsService, type Services } from '~/src/server/types.js'
|
|
15
|
+
|
|
16
|
+
const mockGetCacheService = jest.fn()
|
|
17
|
+
const mockCacheService = { getState: jest.fn() }
|
|
18
|
+
const mockCheckEmailAddressForLiveFormSubmission = jest.fn()
|
|
19
|
+
|
|
20
|
+
jest.mock('../models/index.ts', () => ({
|
|
21
|
+
__esModule: true,
|
|
22
|
+
FormModel: jest.fn()
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
jest.mock('~/src/server/plugins/engine/services/index.js', () => ({
|
|
26
|
+
__esModule: true,
|
|
27
|
+
formsService: {
|
|
28
|
+
getFormMetadata: jest.fn(),
|
|
29
|
+
getFormDefinition: jest.fn()
|
|
30
|
+
},
|
|
31
|
+
formSubmissionService: {},
|
|
32
|
+
outputService: {}
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
jest.mock('../pageControllers/index.ts', () => {
|
|
36
|
+
class MockTerminalPageController {
|
|
37
|
+
path = ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
__esModule: true,
|
|
42
|
+
TerminalPageController: MockTerminalPageController
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
jest.mock('../helpers.ts', () => ({
|
|
47
|
+
__esModule: true,
|
|
48
|
+
getCacheService: (...args: unknown[]) => mockGetCacheService(...args),
|
|
49
|
+
checkEmailAddressForLiveFormSubmission: (...args: unknown[]) =>
|
|
50
|
+
mockCheckEmailAddressForLiveFormSubmission(...args)
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
const mockServices = jest.requireMock(
|
|
54
|
+
'~/src/server/plugins/engine/services/index.js'
|
|
55
|
+
)
|
|
56
|
+
const mockFormsService = mockServices.formsService
|
|
57
|
+
const { FormModel } = jest.requireMock('../models/index.ts')
|
|
58
|
+
const { TerminalPageController: MockTerminalPageController } = jest.requireMock(
|
|
59
|
+
'../pageControllers/index.ts'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
describe('getFormContext helper', () => {
|
|
63
|
+
const request = {
|
|
64
|
+
yar: { set: jest.fn() } as unknown as Request['yar'],
|
|
65
|
+
server: {
|
|
66
|
+
app: {},
|
|
67
|
+
realm: { modifiers: { route: { prefix: '' } } }
|
|
68
|
+
} as unknown as Request['server']
|
|
69
|
+
} satisfies Pick<Request, 'yar' | 'server'>
|
|
70
|
+
const slug = 'tb-origin'
|
|
71
|
+
const cachedState = { answered: true }
|
|
72
|
+
const returnedContext = { errors: [] }
|
|
73
|
+
const metadata = {
|
|
74
|
+
id: 'metadata-123',
|
|
75
|
+
live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
|
|
76
|
+
draft: { updatedAt: new Date('2024-10-10T10:00:00Z') },
|
|
77
|
+
versions: [{ versionNumber: 9 }],
|
|
78
|
+
notificationEmail: 'test@example.com'
|
|
79
|
+
}
|
|
80
|
+
const definition = { pages: [] }
|
|
81
|
+
let formModel: { getFormContext: jest.Mock }
|
|
82
|
+
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
jest.clearAllMocks()
|
|
85
|
+
formModel = { getFormContext: jest.fn().mockResolvedValue(returnedContext) }
|
|
86
|
+
FormModel.mockImplementation(
|
|
87
|
+
(_definition: unknown, modelOptions: FormModelOptions) =>
|
|
88
|
+
Object.assign(formModel, { basePath: modelOptions.basePath })
|
|
89
|
+
)
|
|
90
|
+
mockFormsService.getFormMetadata.mockResolvedValue(metadata)
|
|
91
|
+
mockFormsService.getFormDefinition.mockResolvedValue(definition)
|
|
92
|
+
mockGetCacheService.mockReturnValue(mockCacheService)
|
|
93
|
+
mockCacheService.getState.mockResolvedValue(cachedState)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('passes preview state into the summary request and uses cached reference numbers', async () => {
|
|
97
|
+
const errors = [
|
|
98
|
+
{ href: '#field', name: 'field', path: ['field'], text: 'is required' }
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
mockCacheService.getState.mockResolvedValue({
|
|
102
|
+
...cachedState,
|
|
103
|
+
$$__referenceNumber: 'CACHED-REF'
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const context = await getFormContext(request, slug, 'preview', {
|
|
107
|
+
errors
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const summaryRequest = mockCacheService.getState.mock.calls[0][0]
|
|
111
|
+
|
|
112
|
+
expect(summaryRequest.params).toEqual({
|
|
113
|
+
path: 'summary',
|
|
114
|
+
slug,
|
|
115
|
+
state: 'live'
|
|
116
|
+
})
|
|
117
|
+
expect(summaryRequest.path).toBe('/preview/live/tb-origin/summary')
|
|
118
|
+
expect(summaryRequest.url.toString()).toBe(
|
|
119
|
+
'https://form-context.local/preview/live/tb-origin/summary'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
expect(formModel.getFormContext).toHaveBeenCalledWith(
|
|
123
|
+
summaryRequest,
|
|
124
|
+
expect.objectContaining({ $$__referenceNumber: 'CACHED-REF' }),
|
|
125
|
+
errors
|
|
126
|
+
)
|
|
127
|
+
expect(context).toBe(returnedContext)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('getFormModel helper', () => {
|
|
132
|
+
const slug = 'tb-origin'
|
|
133
|
+
const state = FormStatus.Draft
|
|
134
|
+
class CustomController extends PageController {}
|
|
135
|
+
const controllers = { CustomController }
|
|
136
|
+
const metadata = {
|
|
137
|
+
id: 'form-meta-123',
|
|
138
|
+
versions: [{ versionNumber: 17 }]
|
|
139
|
+
}
|
|
140
|
+
const definition = { pages: [{ path: '/start' }] }
|
|
141
|
+
let formsService: FormsService
|
|
142
|
+
let services: Services
|
|
143
|
+
let formModelInstance: { id: string }
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
jest.clearAllMocks()
|
|
147
|
+
formModelInstance = { id: 'form-model-instance' }
|
|
148
|
+
FormModel.mockImplementation(() => formModelInstance)
|
|
149
|
+
services = {
|
|
150
|
+
formsService: {
|
|
151
|
+
getFormMetadata: jest.fn().mockResolvedValue(metadata),
|
|
152
|
+
getFormMetadataById: jest.fn(),
|
|
153
|
+
getFormDefinition: jest.fn().mockResolvedValue(definition)
|
|
154
|
+
},
|
|
155
|
+
formSubmissionService: {
|
|
156
|
+
persistFiles: jest.fn(),
|
|
157
|
+
submit: jest.fn()
|
|
158
|
+
},
|
|
159
|
+
outputService: {
|
|
160
|
+
submit: jest.fn()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
formsService = services.formsService
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('constructs a FormModel using fetched metadata and definition', async () => {
|
|
167
|
+
const model = await getFormModel(slug, state, { services, controllers })
|
|
168
|
+
|
|
169
|
+
expect(formsService.getFormMetadata).toHaveBeenCalledWith(slug)
|
|
170
|
+
expect(formsService.getFormDefinition).toHaveBeenCalledWith(
|
|
171
|
+
metadata.id,
|
|
172
|
+
state
|
|
173
|
+
)
|
|
174
|
+
expect(FormModel).toHaveBeenCalledWith(
|
|
175
|
+
definition,
|
|
176
|
+
{
|
|
177
|
+
basePath: slug,
|
|
178
|
+
versionNumber: metadata.versions[0].versionNumber,
|
|
179
|
+
ordnanceSurveyApiKey: undefined,
|
|
180
|
+
formId: metadata.id
|
|
181
|
+
},
|
|
182
|
+
services,
|
|
183
|
+
controllers
|
|
184
|
+
)
|
|
185
|
+
expect(model).toBe(formModelInstance)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
test('maps preview state requests to the live form definition', async () => {
|
|
189
|
+
await getFormModel(slug, 'preview', { services, controllers })
|
|
190
|
+
|
|
191
|
+
expect(formsService.getFormDefinition).toHaveBeenCalledWith(
|
|
192
|
+
metadata.id,
|
|
193
|
+
'live'
|
|
194
|
+
)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('throws when no form definition is available', async () => {
|
|
198
|
+
jest.mocked(formsService.getFormDefinition).mockResolvedValue(undefined)
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
getFormModel(slug, state, { services, controllers })
|
|
202
|
+
).rejects.toThrow(
|
|
203
|
+
`No definition found for form metadata ${metadata.id} (${slug}) ${state}`
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
expect(FormModel).not.toHaveBeenCalled()
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
describe('resolveFormModel helper', () => {
|
|
211
|
+
const slug = 'tb-origin'
|
|
212
|
+
const definition = { pages: [], outputEmail: 'fallback@example.com' }
|
|
213
|
+
const metadata = {
|
|
214
|
+
id: 'metadata-123',
|
|
215
|
+
live: { updatedAt: new Date('2024-10-15T10:00:00Z') },
|
|
216
|
+
versions: [{ versionNumber: 9 }]
|
|
217
|
+
}
|
|
218
|
+
let server: Request['server']
|
|
219
|
+
let formModelInstance: { id: string }
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
jest.clearAllMocks()
|
|
223
|
+
server = {
|
|
224
|
+
app: {},
|
|
225
|
+
realm: { modifiers: { route: { prefix: '/forms/' } } }
|
|
226
|
+
} as unknown as Request['server']
|
|
227
|
+
formModelInstance = { id: 'form-model-instance' }
|
|
228
|
+
FormModel.mockImplementation(() => formModelInstance)
|
|
229
|
+
mockFormsService.getFormMetadata.mockResolvedValue(metadata)
|
|
230
|
+
mockFormsService.getFormDefinition.mockResolvedValue(definition)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
test('reuses cached models when metadata timestamps match', async () => {
|
|
234
|
+
const model = await resolveFormModel(server, slug, FormStatus.Live)
|
|
235
|
+
const cached = await resolveFormModel(server, slug, FormStatus.Live)
|
|
236
|
+
|
|
237
|
+
expect(model).toBe(formModelInstance)
|
|
238
|
+
expect(cached).toBe(model)
|
|
239
|
+
expect(server.app.models).toBeInstanceOf(Map)
|
|
240
|
+
expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(1)
|
|
241
|
+
expect(FormModel).toHaveBeenCalledTimes(1)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
test('rebuilds the model when metadata changes and uses preview routing', async () => {
|
|
245
|
+
const refreshedModel = { id: 'refreshed-model' }
|
|
246
|
+
|
|
247
|
+
FormModel.mockImplementationOnce(
|
|
248
|
+
() => formModelInstance
|
|
249
|
+
).mockImplementationOnce(() => refreshedModel)
|
|
250
|
+
mockFormsService.getFormMetadata
|
|
251
|
+
.mockResolvedValueOnce({ ...metadata, notificationEmail: undefined })
|
|
252
|
+
.mockResolvedValueOnce({
|
|
253
|
+
...metadata,
|
|
254
|
+
notificationEmail: undefined,
|
|
255
|
+
live: { updatedAt: new Date('2024-12-01T09:00:00Z') }
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
const model = await resolveFormModel(server, slug, 'preview', {
|
|
259
|
+
ordnanceSurveyApiKey: 'os-api-key'
|
|
260
|
+
})
|
|
261
|
+
const rebuilt = await resolveFormModel(server, slug, 'preview')
|
|
262
|
+
|
|
263
|
+
expect(model).toBe(formModelInstance)
|
|
264
|
+
expect(rebuilt).toBe(refreshedModel)
|
|
265
|
+
expect(FormModel).toHaveBeenCalledTimes(2)
|
|
266
|
+
expect(mockFormsService.getFormDefinition).toHaveBeenCalledTimes(2)
|
|
267
|
+
expect(mockCheckEmailAddressForLiveFormSubmission).toHaveBeenCalledWith(
|
|
268
|
+
definition.outputEmail,
|
|
269
|
+
true
|
|
270
|
+
)
|
|
271
|
+
expect(FormModel).toHaveBeenCalledWith(
|
|
272
|
+
definition,
|
|
273
|
+
expect.objectContaining({
|
|
274
|
+
basePath: 'forms/preview/live/tb-origin',
|
|
275
|
+
versionNumber: metadata.versions[0].versionNumber,
|
|
276
|
+
ordnanceSurveyApiKey: 'os-api-key',
|
|
277
|
+
formId: metadata.id
|
|
278
|
+
}),
|
|
279
|
+
mockServices,
|
|
280
|
+
undefined
|
|
281
|
+
)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('throws when requested form state does not exist on metadata', async () => {
|
|
285
|
+
mockFormsService.getFormMetadata.mockResolvedValue({
|
|
286
|
+
id: 'metadata-123',
|
|
287
|
+
live: { updatedAt: new Date('2024-10-15T10:00:00Z') }
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
await expect(
|
|
291
|
+
resolveFormModel(server, slug, FormStatus.Draft)
|
|
292
|
+
).rejects.toThrow("No 'draft' state for form metadata metadata-123")
|
|
293
|
+
|
|
294
|
+
expect(FormModel).not.toHaveBeenCalled()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
test('throws when no form definition is available for the requested state', async () => {
|
|
298
|
+
mockFormsService.getFormDefinition.mockResolvedValue(undefined)
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
resolveFormModel(server, slug, FormStatus.Live)
|
|
302
|
+
).rejects.toThrow(
|
|
303
|
+
`No definition found for form metadata ${metadata.id} (${slug}) ${FormStatus.Live}`
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
expect(FormModel).not.toHaveBeenCalled()
|
|
307
|
+
expect(mockCheckEmailAddressForLiveFormSubmission).not.toHaveBeenCalled()
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('getFirstJourneyPage helper', () => {
|
|
312
|
+
const buildPage = (path: string, keys: string[] = []) =>
|
|
313
|
+
({ path, keys }) as unknown as PageControllerClass
|
|
314
|
+
|
|
315
|
+
test('returns undefined when no context or relevant target path is available', () => {
|
|
316
|
+
expect(getFirstJourneyPage()).toBeUndefined()
|
|
317
|
+
expect(getFirstJourneyPage({ relevantPages: [] })).toBeUndefined()
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('returns the page matching the last recorded path', () => {
|
|
321
|
+
const startPage = buildPage('/start')
|
|
322
|
+
const nextPage = buildPage('/animals')
|
|
323
|
+
|
|
324
|
+
const context: Pick<FormContext, 'relevantPages'> = {
|
|
325
|
+
relevantPages: [startPage, nextPage]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
expect(getFirstJourneyPage(context)).toBe(nextPage)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('steps back from terminal pages to the previous relevant page', () => {
|
|
332
|
+
const startPage = buildPage('/start')
|
|
333
|
+
const exitPage = Object.assign(new MockTerminalPageController(), {
|
|
334
|
+
path: '/stop'
|
|
335
|
+
}) as unknown as PageControllerClass
|
|
336
|
+
|
|
337
|
+
const context: Pick<FormContext, 'relevantPages'> = {
|
|
338
|
+
relevantPages: [startPage, exitPage]
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
expect(getFirstJourneyPage(context)).toBe(startPage)
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
test('returns the terminal page when it is the only relevant page available', () => {
|
|
345
|
+
const exitPage = Object.assign(new MockTerminalPageController(), {
|
|
346
|
+
path: '/stop'
|
|
347
|
+
}) as unknown as PageControllerClass
|
|
348
|
+
|
|
349
|
+
const context: Pick<FormContext, 'relevantPages'> = {
|
|
350
|
+
relevantPages: [exitPage]
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
expect(getFirstJourneyPage(context)).toBe(exitPage)
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @import { FormContext } from '../types.js'
|
|
359
|
+
*/
|