From 2c6a103ccb64575c0deefc44186a126444eb962c Mon Sep 17 00:00:00 2001 From: Colin Rotherham Date: Mon, 21 Oct 2024 22:39:33 +0100 Subject: [PATCH] Remove field value `DataType` formatting --- .../engine/components/ComponentBase.ts | 11 +- .../engine/components/DatePartsField.ts | 6 +- .../engine/components/FileUploadField.ts | 19 ++- .../engine/components/FormComponent.ts | 5 + .../engine/components/ListFormComponent.ts | 6 +- .../engine/components/MonthYearField.ts | 6 +- src/server/plugins/engine/components/types.ts | 9 -- .../plugins/engine/models/SummaryViewModel.ts | 70 +++++---- src/server/plugins/engine/models/types.ts | 121 ++-------------- .../pageControllers/PageControllerBase.ts | 6 +- .../pageControllers/RepeatPageController.ts | 7 +- .../pageControllers/SummaryPageController.ts | 133 +++--------------- src/server/schemas/types.ts | 9 -- test/form/govuk-notify.test.js | 20 +-- 14 files changed, 126 insertions(+), 302 deletions(-) delete mode 100644 src/server/schemas/types.ts diff --git a/src/server/plugins/engine/components/ComponentBase.ts b/src/server/plugins/engine/components/ComponentBase.ts index 4a275673..d74c9189 100644 --- a/src/server/plugins/engine/components/ComponentBase.ts +++ b/src/server/plugins/engine/components/ComponentBase.ts @@ -8,12 +8,8 @@ import joi, { type StringSchema } from 'joi' -import { - DataType, - type ViewModel -} from '~/src/server/plugins/engine/components/types.js' +import { type ViewModel } from '~/src/server/plugins/engine/components/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' -import { answerFromDetailItem } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js' import { type FormPayload, type FormSubmissionErrors @@ -27,11 +23,6 @@ export class ComponentBase { options?: Extract['options'] isFormComponent = false - - /** - * This is passed onto webhooks, see {@link answerFromDetailItem} - */ - dataType: DataType = DataType.Text model: FormModel /** joi schemas based on a component defined in the form JSON. This validates a user's answer and is generated from {@link ComponentDef} */ diff --git a/src/server/plugins/engine/components/DatePartsField.ts b/src/server/plugins/engine/components/DatePartsField.ts index 297adfdd..bc5e7ffc 100644 --- a/src/server/plugins/engine/components/DatePartsField.ts +++ b/src/server/plugins/engine/components/DatePartsField.ts @@ -6,10 +6,7 @@ import { ComponentCollection } from '~/src/server/plugins/engine/components/Comp import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' -import { - DataType, - type DateInputItem -} from '~/src/server/plugins/engine/components/types.js' +import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type FormPayload, @@ -22,7 +19,6 @@ import { export class DatePartsField extends FormComponent { declare options: DatePartsFieldComponent['options'] children: ComponentCollection - dataType: DataType = DataType.Date constructor(def: DatePartsFieldComponent, model: FormModel) { super(def, model) diff --git a/src/server/plugins/engine/components/FileUploadField.ts b/src/server/plugins/engine/components/FileUploadField.ts index 06a3c261..dd645477 100644 --- a/src/server/plugins/engine/components/FileUploadField.ts +++ b/src/server/plugins/engine/components/FileUploadField.ts @@ -1,8 +1,8 @@ import { type FileUploadFieldComponent } from '@defra/forms-model' import joi, { type ArraySchema } from 'joi' +import { config } from '~/src/config/index.js' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' -import { DataType } from '~/src/server/plugins/engine/components/types.js' import { filesize } from '~/src/server/plugins/engine/helpers.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { @@ -16,6 +16,8 @@ import { type FormSubmissionState } from '~/src/server/plugins/engine/types.js' +const designerUrl = config.get('designerUrl') + export const uploadIdSchema = joi.string().uuid().required() export const fileSchema = joi @@ -85,7 +87,6 @@ export const formItemSchema = itemSchema.append({ export class FileUploadField extends FormComponent { declare options: FileUploadFieldComponent['options'] declare schema: FileUploadFieldComponent['schema'] - dataType: DataType = DataType.File declare formSchema: ArraySchema declare stateSchema: ArraySchema @@ -135,6 +136,20 @@ export class FileUploadField extends FormComponent { return `You uploaded ${count} file${count !== 1 ? 's' : ''}` } + getMarkdownStringFromState(state: FormSubmissionState) { + const values = this.getFormValueFromState(state) + const count = values?.length + + const bullets = (values ?? []) + .map(({ status }) => { + const { filename, fileId } = status.form.file + return `* [${filename}](${designerUrl}/file-download/${fileId})` + }) + .join('\n') + + return `${count} file${count !== 1 ? 's' : ''} uploaded:\n\n${bullets}` + } + getViewModel(payload: FormPayload, errors?: FormSubmissionErrors) { const { options } = this diff --git a/src/server/plugins/engine/components/FormComponent.ts b/src/server/plugins/engine/components/FormComponent.ts index aab86bc2..c3ca02d3 100644 --- a/src/server/plugins/engine/components/FormComponent.ts +++ b/src/server/plugins/engine/components/FormComponent.ts @@ -160,4 +160,9 @@ export class FormComponent extends ComponentBase { typeof value === 'boolean' ) } + + getMarkdownStringFromState(state: FormSubmissionState) { + const formatted = this.getDisplayStringFromState(state) + return `\`\`\`\n${formatted}\n\`\`\`` + } } diff --git a/src/server/plugins/engine/components/ListFormComponent.ts b/src/server/plugins/engine/components/ListFormComponent.ts index 4150629a..be3eeb48 100644 --- a/src/server/plugins/engine/components/ListFormComponent.ts +++ b/src/server/plugins/engine/components/ListFormComponent.ts @@ -13,10 +13,7 @@ import joi, { } from 'joi' import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' -import { - DataType, - type ListItem -} from '~/src/server/plugins/engine/components/types.js' +import { type ListItem } from '~/src/server/plugins/engine/components/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type FormPayload, @@ -48,7 +45,6 @@ export class ListFormComponent extends FormComponent { list?: List listType: List['type'] = 'string' - dataType: DataType = DataType.List get items(): Item[] { return this.list?.items ?? [] diff --git a/src/server/plugins/engine/components/MonthYearField.ts b/src/server/plugins/engine/components/MonthYearField.ts index e2c1a5d9..c940c50c 100644 --- a/src/server/plugins/engine/components/MonthYearField.ts +++ b/src/server/plugins/engine/components/MonthYearField.ts @@ -4,10 +4,7 @@ import { ComponentCollection } from '~/src/server/plugins/engine/components/Comp import { FormComponent } from '~/src/server/plugins/engine/components/FormComponent.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' -import { - DataType, - type DateInputItem -} from '~/src/server/plugins/engine/components/types.js' +import { type DateInputItem } from '~/src/server/plugins/engine/components/types.js' import { type FormModel } from '~/src/server/plugins/engine/models/index.js' import { type FormPayload, @@ -20,7 +17,6 @@ import { export class MonthYearField extends FormComponent { declare options: MonthYearFieldComponent['options'] children: ComponentCollection - dataType: DataType = DataType.MonthYear constructor(def: MonthYearFieldComponent, model: FormModel) { super(def, model) diff --git a/src/server/plugins/engine/components/types.ts b/src/server/plugins/engine/components/types.ts index 68324b6d..89e00ec4 100644 --- a/src/server/plugins/engine/components/types.ts +++ b/src/server/plugins/engine/components/types.ts @@ -106,12 +106,3 @@ export interface ComponentViewModel { isFormComponent: boolean model: ViewModel } - -export enum DataType { - List = 'list', - Text = 'text', - Date = 'date', - MonthYear = 'monthYear', - Number = 'number', - File = 'file' -} diff --git a/src/server/plugins/engine/models/SummaryViewModel.ts b/src/server/plugins/engine/models/SummaryViewModel.ts index bc513568..b3c21fc4 100644 --- a/src/server/plugins/engine/models/SummaryViewModel.ts +++ b/src/server/plugins/engine/models/SummaryViewModel.ts @@ -11,9 +11,11 @@ import { RepeatPageController } from '~/src/server/plugins/engine/pageController import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js' import { type FormState, - type FormSubmissionState + type FormSubmissionState, + type RepeatState } from '~/src/server/plugins/engine/types.js' import { + type FormQuery, type FormRequest, type FormRequestPayload } from '~/src/server/routes/types.js' @@ -131,11 +133,13 @@ export class SummaryViewModel { ) sectionPages.forEach((page) => { + const { formItems } = page.components + if (page instanceof RepeatPageController) { addRepeaterItem(page, request, model, state, items) } else { - for (const component of page.components.formItems) { - const item = Item(component, state, page, model) + for (const component of formItems) { + const item = Item(page, model, state, component) if (items.find((cbItem) => cbItem.name === item.name)) return items.push(item) } @@ -178,37 +182,45 @@ function addRepeaterItem( state: FormSubmissionState, items: DetailItem[] ) { + const { basePath } = model + const { formItems } = page.components const { options } = page.repeat - const { name, title } = options - const repeatSummaryPath = page.getSummaryPath(request) - const path = `/${model.basePath}${page.path}` + const rawValue = page.getListFromState(state) const hasItems = rawValue.length > 0 const value = hasItems ? rawValue.length.toString() : '0' - const url = redirectUrl(hasItems ? repeatSummaryPath : path, { - returnUrl: redirectUrl(`/${model.basePath}/summary`) + + const pagePath = hasItems + ? page.getSummaryPath(request) + : `/${basePath}${page.path}` + + // Path to change link + const url = redirectUrl(pagePath, { + returnUrl: redirectUrl(page.defaultNextPath) }) const subItems: DetailItem[][] = [] rawValue.forEach((itemState) => { const sub: DetailItem[] = [] - for (const component of page.components.formItems) { - const item = Item(component, itemState, page, model) + + for (const component of formItems) { + const item = Item(page, model, itemState, component) if (sub.find((cbItem) => cbItem.name === item.name)) return sub.push(item) } + subItems.push(sub) }) items.push({ - name, - path: page.path, - label: hasItems ? `${title}s added` : title, - value: `You added ${value} ${title}${value === '1' ? '' : 's'}`, + name: options.name, + title: options.title, + label: hasItems ? `${options.title}s added` : options.title, + value: `You added ${value} ${options.title}${value === '1' ? '' : 's'}`, rawValue, + page, url, - title, subItems }) } @@ -217,23 +229,29 @@ function addRepeaterItem( * Creates an Item object for Details */ function Item( - component: FormComponentFieldClass, - state: FormState, page: PageControllerClass, model: FormModel, - params: { returnUrl: string } = { - returnUrl: redirectUrl(`/${model.basePath}/summary`) - } -): DetailItem { + state: FormState | RepeatState, + component: FormComponentFieldClass, + params?: FormQuery +) { + const { basePath } = model + + const pagePath = `/${basePath}${page.path}` + const returnUrl = redirectUrl(page.getSummaryPath()) + + // Path to change link + const url = redirectUrl(pagePath, { returnUrl, ...params }) + return { name: component.name, - path: page.path, + title: component.title, label: component.title, value: component.getDisplayStringFromState(state), + markdownValue: component.getMarkdownStringFromState(state), rawValue: component.getFormValueFromState(state), - url: redirectUrl(`/${model.basePath}${page.path}`, params), type: component.type, - title: component.title, - dataType: component.dataType - } + page, + url + } satisfies DetailItem } diff --git a/src/server/plugins/engine/models/types.ts b/src/server/plugins/engine/models/types.ts index 1df37a54..dfd68a2e 100644 --- a/src/server/plugins/engine/models/types.ts +++ b/src/server/plugins/engine/models/types.ts @@ -1,134 +1,43 @@ import { type ConditionWrapper, - type DatePartsFieldComponent, - type FileUploadFieldComponent, - type FormComponentsDef, - type FormDefinition, - type InputFieldsComponentsDef, - type Item, - type MonthYearFieldComponent, - type NumberFieldComponent, - type Section, - type SelectionComponentsDef + type FormComponentsDef } from '@defra/forms-model' import { type Expression } from 'expr-eval' -import { type ComponentBase } from '~/src/server/plugins/engine/components/ComponentBase.js' -import { type DataType } from '~/src/server/plugins/engine/components/types.js' import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js' import { - type FileState, + type FormPayload, type FormState, - type FormStateValue, - type FormSubmissionState, - type RepeatState + type FormValue } from '~/src/server/plugins/engine/types.js' export type ExecutableCondition = ConditionWrapper & { expr: Expression - fn: (state: FormSubmissionState) => boolean + fn: (state: FormState) => boolean } /** * Used to render a row on a Summary List (check your answers) */ -export interface DetailItemBase { - /** - * Name of the component defined in the JSON {@link FormDefinition} - */ - name: ComponentBase['name'] - - /** - * Title of the component defined in the JSON {@link FormDefinition} - * Used as a human readable form of {@link ComponentBase.name} and HTML content for HTML Label tag - */ - label: ComponentBase['title'] - - /** - * Path to page excluding base path - */ - path: PageControllerClass['path'] - - /** - * String and/or display value of a field. For example, a Date will be displayed as 25 December 2022 - */ +export interface DetailItem { + name: string + title: string + label?: string value: string - - /** - * Flag to indicate if field is in error and should be changed - */ - inError?: boolean - - /** - * Raw value of a field. For example, a Date will be displayed as 2022-12-25 - */ - rawValue: FormState | FormStateValue - - url: string + markdownValue?: string + rawValue: FormValue | FormPayload type?: FormComponentsDef['type'] - title: string - dataType?: DataType + page: PageControllerClass + url: string + inError?: boolean subItems?: DetailItem[][] } -export interface DetailItemDate extends DetailItemBase { - type: DatePartsFieldComponent['type'] - dataType: DataType.Date - rawValue: FormState | null -} - -export interface DetailItemMonthYear extends DetailItemBase { - type: MonthYearFieldComponent['type'] - dataType: DataType.MonthYear - rawValue: FormState | null -} - -export interface DetailItemSelection extends DetailItemBase { - type: SelectionComponentsDef['type'] - dataType: DataType.List - items: DetailItem[] - rawValue: Item['value'] | Item['value'][] | null -} - -export interface DetailItemNumber extends DetailItemBase { - type: NumberFieldComponent['type'] - dataType: DataType.Number - rawValue: number | null -} - -export interface DetailItemText extends DetailItemBase { - type: Exclude< - InputFieldsComponentsDef, - NumberFieldComponent | FileUploadFieldComponent - >['type'] - dataType: DataType.Text - rawValue: string | null -} - -export interface DetailItemFileUpload extends DetailItemBase { - type: FileUploadFieldComponent['type'] - dataType: DataType.File - rawValue: FileState[] | null -} - -export interface DetailItemRepeat extends DetailItemBase { - rawValue: RepeatState[] | null -} - -export type DetailItem = - | DetailItemDate - | DetailItemMonthYear - | DetailItemSelection - | DetailItemNumber - | DetailItemText - | DetailItemFileUpload - | DetailItemRepeat - /** * Used to render a row on a Summary List (check your answers) */ export interface Detail { - name?: Section['name'] - title?: Section['title'] + name?: string + title?: string items: DetailItem[] } diff --git a/src/server/plugins/engine/pageControllers/PageControllerBase.ts b/src/server/plugins/engine/pageControllers/PageControllerBase.ts index 11d922b2..ce87ac8f 100644 --- a/src/server/plugins/engine/pageControllers/PageControllerBase.ts +++ b/src/server/plugins/engine/pageControllers/PageControllerBase.ts @@ -205,6 +205,10 @@ export class PageControllerBase { .filter((v) => !!v) } + getSummaryPath() { + return this.defaultNextPath + } + /** * @param state - the values currently stored in a users session */ @@ -236,7 +240,7 @@ export class PageControllerBase { return `/${this.model.basePath || ''}${nextPage.path}` } - return this.defaultNextPath + return this.getSummaryPath() } /** diff --git a/src/server/plugins/engine/pageControllers/RepeatPageController.ts b/src/server/plugins/engine/pageControllers/RepeatPageController.ts index 70aaa723..4200bed6 100644 --- a/src/server/plugins/engine/pageControllers/RepeatPageController.ts +++ b/src/server/plugins/engine/pageControllers/RepeatPageController.ts @@ -410,7 +410,10 @@ export class RepeatPageController extends PageController { } } - getSummaryPath(request: FormRequest | FormRequestPayload) { - return `/${this.model.basePath}${this.path}/summary${request.url.search}` + getSummaryPath(request?: FormRequest | FormRequestPayload) { + const { model, path } = this + + const search = request?.url.search ?? '' + return `/${model.basePath}${path}/summary${search}` } } diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index 655224af..5628b9fb 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -12,7 +12,6 @@ import { addDays, format } from 'date-fns' import { config } from '~/src/config/index.js' import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' -import { DataType } from '~/src/server/plugins/engine/components/types.js' import { checkEmailAddressForLiveFormSubmission, checkFormStatus, @@ -41,18 +40,11 @@ import { type FormRequestPayloadRefs, type FormRequestRefs } from '~/src/server/routes/types.js' -import { type Field } from '~/src/server/schemas/types.js' import { sendNotification } from '~/src/server/utils/notify.js' const designerUrl = config.get('designerUrl') const templateId = config.get('notifyTemplateId') -interface QuestionRecord { - title: string - value: string - field: Field -} - export class SummaryPageController extends PageController { /** * The controller which is used when Page["controller"] is defined as "./pages/summary.js" @@ -271,25 +263,25 @@ async function extendFileRetention( } function submitData( - records: QuestionRecord[], + items: DetailItem[], retrievalKey: string, sessionId: string ) { - const main = records.filter((record) => record.field.type) - const repeaters = records.filter((record) => record.field.item.subItems) + const main = items.filter((item) => item.type) + const repeaters = items.filter((item) => item.subItems) const payload: SubmitPayload = { sessionId, retrievalKey, - main: main.map((record) => ({ - name: record.field.key, - title: record.title, - value: record.value + main: main.map((item) => ({ + name: item.name, + title: item.title, + value: item.value })), - repeaters: repeaters.map((record) => ({ - name: record.field.key, - title: record.title, - value: (record.field.item.subItems ?? []).map((detailItem) => + repeaters: repeaters.map((item) => ({ + name: item.name, + title: item.title, + value: (item.subItems ?? []).map((detailItem) => detailItem.map((item) => ({ name: item.name, title: item.title, @@ -361,46 +353,15 @@ export function getQuestions( summaryViewModel: SummaryViewModel, model: FormModel ) { - const { relevantPages, details } = summaryViewModel - const formSubmissionData = getFormSubmissionData( - relevantPages, - details, + return getFormSubmissionData( + summaryViewModel.relevantPages, + summaryViewModel.details, model - ) - const questions: QuestionRecord[] = [] - - formSubmissionData.questions.forEach((question) => { - question.fields.forEach((field) => { - const { title, answer, type } = field - let value = '' - - if (typeof answer === 'string') { - value = answer - } else if (typeof answer === 'number') { - value = answer.toString() - } else if (typeof answer === 'boolean') { - value = answer ? 'yes' : 'no' - } else if (Array.isArray(answer)) { - if (type === DataType.File) { - const uploads = FileUploadField.isValue(answer) ? answer : [] - - value = uploads - .map(({ status }) => status.form.file.fileId) - .toString() - } else { - value = answer.toString() - } - } - - questions.push({ title, value, field }) - }) - }) - - return questions + ).questions.flatMap(({ fields }) => fields) } export function getPersonalisation( - questions: QuestionRecord[], + questions: DetailItem[], model: FormModel, submitResponse: SubmitResponsePayload, formStatus: ReturnType @@ -454,8 +415,9 @@ export function getPersonalisation( line = literal(value) } - lines.push(`## ${title}`) - lines.push(line) + questions.forEach((item) => { + lines.push(`## ${item.title}`) + lines.push(item.markdownValue ?? item.value) lines.push('\n') }) @@ -469,24 +431,16 @@ export function getPersonalisation( } } -function literal(str: string) { - return `\`\`\`\n${str}\n\`\`\`` -} - function getFormSubmissionData( relevantPages: PageControllerClass[], details: Detail[], model: FormModel ) { const questions = relevantPages.map((page) => { - const itemsForPage = details.flatMap((detail) => - detail.items.filter((item) => item.path === page.path) + const fields = details.flatMap(({ items }) => + items.filter((item) => item.page.path === page.path) ) - const fields = itemsForPage.flatMap((item) => { - return [detailItemToField(item)] - }) - return { category: page.section?.name, question: page.title, @@ -500,48 +454,3 @@ function getFormSubmissionData( questions } } - -export function answerFromDetailItem(item: DetailItem) { - let value: DetailItem['rawValue'] = '' - - if (item.rawValue === null) { - return value - } - - switch (item.dataType) { - case DataType.List: - value = item.rawValue - break - - case DataType.File: - value = item.rawValue - break - - case DataType.Date: { - const [day, month, year] = Object.values(item.rawValue) - value = format(new Date(`${year}-${month}-${day}`), 'yyyy-MM-dd') - break - } - - case DataType.MonthYear: { - const [month, year] = Object.values(item.rawValue) - value = format(new Date(`${year}-${month}-1`), 'yyyy-MM') - break - } - - default: - value = item.value - } - - return value -} - -function detailItemToField(item: DetailItem): Field { - return { - key: item.name, - title: item.title, - type: item.dataType, - answer: answerFromDetailItem(item), - item - } -} diff --git a/src/server/schemas/types.ts b/src/server/schemas/types.ts deleted file mode 100644 index 7a712f8a..00000000 --- a/src/server/schemas/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type DetailItem } from '~/src/server/plugins/engine/models/types.js' - -export interface Field { - key: string - type: DetailItem['dataType'] - title: DetailItem['title'] - answer: DetailItem['rawValue'] - item: DetailItem -} diff --git a/test/form/govuk-notify.test.js b/test/form/govuk-notify.test.js index d5643c51..92c3bfc8 100644 --- a/test/form/govuk-notify.test.js +++ b/test/form/govuk-notify.test.js @@ -160,19 +160,19 @@ describe('Submission journey test', () => { ## Date parts field \`\`\` - 2012-12-12 + 12 December 2012 \`\`\` ## Month year field \`\`\` - 2012-12 + December 2012 \`\`\` ## Yes/No field \`\`\` - yes + Yes \`\`\` @@ -196,19 +196,19 @@ describe('Submission journey test', () => { ## Radios field \`\`\` - privateLimitedCompany + Private Limited Company \`\`\` ## Select field \`\`\` - 910400000 + Afghanistan \`\`\` ## Autocomplete field \`\`\` - 910400044 + Czech Republic \`\`\` @@ -220,24 +220,24 @@ describe('Submission journey test', () => { ## Checkboxes field 2 \`\`\` - Arabian,Shire,Race + Arabian, Shire, Race \`\`\` ## Checkboxes field 3 (number) \`\`\` - 1 + 1 point \`\`\` ## Checkboxes field 4 (number) \`\`\` - 0,1 + None, 1 point \`\`\` ## Upload your methodology statement - 1 file uploaded (links expire ${formattedExpiryDate}): + 1 file uploaded: * [test.pdf](https://test-designer.cdp-int.defra.cloud/file-download/5a76a1a3-bc8a-4bc0-859a-116d775c7f15)