import { ComponentType, type DatePartsFieldComponent } from '@defra/forms-model' import { add, format, isValid, parse, startOfToday, sub } from 'date-fns' import { type Context, type CustomValidator, type ObjectSchema } from 'joi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' import { FormComponent, isFormState, isFormValue } from '~/src/server/plugins/engine/components/FormComponent.js' import { NumberField } from '~/src/server/plugins/engine/components/NumberField.js' import { type DateInputItem, type DatePartsState } from '~/src/server/plugins/engine/components/types.js' import { parseStrictDate } from '~/src/server/plugins/engine/date-helper.js' import { messageTemplate } from '~/src/server/plugins/engine/pageControllers/validationOptions.js' import { type ErrorMessageTemplateList, type FormPayload, type FormState, type FormStateValue, type FormSubmissionError, type FormSubmissionState } from '~/src/server/plugins/engine/types.js' import { convertToLanguageMessages } from '~/src/server/utils/type-utils.js' export class DatePartsField extends FormComponent { declare options: DatePartsFieldComponent['options'] declare formSchema: ObjectSchema declare stateSchema: ObjectSchema declare collection: ComponentCollection constructor( def: DatePartsFieldComponent, props: ConstructorParameters[1] ) { super(def, props) const { name, options } = def const isRequired = options.required !== false const customValidationMessages = convertToLanguageMessages({ 'any.required': messageTemplate.objectMissing, 'number.base': messageTemplate.objectMissing, 'number.precision': messageTemplate.dateFormat, 'number.integer': messageTemplate.dateFormat, 'number.unsafe': messageTemplate.dateFormat, 'number.min': messageTemplate.dateFormat, 'number.max': messageTemplate.dateFormat }) this.collection = new ComponentCollection( [ { type: ComponentType.NumberField, name: `${name}__day`, title: 'Day', schema: { min: 1, max: 31, precision: 0 }, options: { required: isRequired, optionalText: true, classes: 'govuk-input--width-2', customValidationMessages } }, { type: ComponentType.NumberField, name: `${name}__month`, title: 'Month', schema: { min: 1, max: 12, precision: 0 }, options: { required: isRequired, optionalText: true, classes: 'govuk-input--width-2', customValidationMessages } }, { type: ComponentType.NumberField, name: `${name}__year`, title: 'Year', schema: { min: 1000, max: 3000, precision: 0 }, options: { required: isRequired, optionalText: true, classes: 'govuk-input--width-4', customValidationMessages } } ], { ...props, parent: this }, { custom: getValidatorDate(this), peers: [`${name}__day`, `${name}__month`, `${name}__year`] } ) this.options = options this.formSchema = this.collection.formSchema this.stateSchema = this.collection.stateSchema } getFormValueFromState(state: FormSubmissionState) { const value = super.getFormValueFromState(state) return this.isState(value) ? value : undefined } getDisplayStringFromFormValue(formValue: DatePartsState | undefined) { if (!formValue) { return '' } return format( `${formValue.year}-${formValue.month}-${formValue.day}`, 'd MMMM yyyy' ) } getDisplayStringFromState(state: FormSubmissionState) { const value = this.getFormValueFromState(state) return this.getDisplayStringFromFormValue(value) } getContextValueFromFormValue(value: DatePartsState | undefined) { if ( !value || !isValid( parseStrictDate( value, `${value.year}-${value.month}-${value.day}`, 'yyyy-MM-dd', new Date() ) ) ) { return null } return format(`${value.year}-${value.month}-${value.day}`, 'yyyy-MM-dd') } getContextValueFromState(state: FormSubmissionState) { const value = this.getFormValueFromState(state) return this.getContextValueFromFormValue(value) } getViewModel(payload: FormPayload, errors?: FormSubmissionError[]) { const { collection, name } = this const viewModel = super.getViewModel(payload, errors) let { fieldset, label } = viewModel // Check for component errors only const hasError = errors?.some((error) => error.name === name) // Use the component collection to generate the subitems const items: DateInputItem[] = collection .getViewModel(payload, errors) .map(({ model }) => { let { label, type, value, classes, errorMessage } = model if (label) { label.toString = () => label.text // Date component uses string labels } if (hasError || errorMessage) { classes = `${classes} govuk-input--error`.trim() } // Allow any `toString()`-able value so non-numeric // values are shown alongside their error messages if (!isFormValue(value)) { value = undefined } return { label, id: model.id, name: model.name, type, value, classes } }) fieldset ??= { legend: { text: label.text, classes: 'govuk-fieldset__legend--m' } } return { ...viewModel, fieldset, items } } isState(value?: FormStateValue | FormState) { return DatePartsField.isDateParts(value) } /** * For error preview page that shows all possible errors on a component */ getAllPossibleErrors(): ErrorMessageTemplateList { return DatePartsField.getAllPossibleErrors() } /** * Static version of getAllPossibleErrors that doesn't require a component instance. */ static getAllPossibleErrors(): ErrorMessageTemplateList { return { baseErrors: [ { type: 'required', template: messageTemplate.required }, { type: 'dateFormat', template: messageTemplate.dateFormat }, { type: 'dateFormatDay', template: '{{#label}} must include a day' }, { type: 'dateFormatMonth', template: '{{#label}} must include a month' }, { type: 'dateFormatYear', template: '{{#label}} must include a year' } ], advancedSettingsErrors: [ { type: 'dateMin', template: messageTemplate.dateMin }, { type: 'dateMax', template: messageTemplate.dateMax } ] } } static isDateParts( value?: FormStateValue | FormState ): value is DatePartsState { return ( isFormState(value) && NumberField.isNumber(value.day) && NumberField.isNumber(value.month) && NumberField.isNumber(value.year) ) } } export function getValidatorDate(component: DatePartsField) { const validator: CustomValidator = (payload: FormPayload, helpers) => { const { collection, name, options } = component const values = component.getFormValueFromState( component.getStateFromValidForm(payload) ) const context: Context = { missing: collection.keys, key: name } if (!component.isState(values)) { return options.required !== false ? helpers.error('object.required', context) : payload } const date = parse( `${values.year}-${values.month}-${values.day}`, 'yyyy-MM-dd', new Date() ) if (!isValid(date)) { return helpers.error('date.format', context) } // Minimum date from today const dateMin = options.maxDaysInPast || options.maxDaysInPast === 0 ? sub(startOfToday(), { days: options.maxDaysInPast }) : undefined // Maximum date from today const dateMax = options.maxDaysInFuture || options.maxDaysInFuture === 0 ? add(startOfToday(), { days: options.maxDaysInFuture }) : undefined if (dateMin && date < dateMin) { return helpers.error('date.min', { ...context, limit: dateMin }) } if (dateMax && date > dateMax) { return helpers.error('date.max', { ...context, limit: dateMax }) } return payload } return validator }