diff --git a/src/server/plugins/engine/components/ComposableComponentCollection.ts b/src/server/plugins/engine/components/ComposableComponentCollection.ts new file mode 100644 index 000000000..73bbfbb8e --- /dev/null +++ b/src/server/plugins/engine/components/ComposableComponentCollection.ts @@ -0,0 +1,117 @@ +import { type ComponentDef } from '@defra/forms-model' +import { type CustomValidator } from 'joi' + +import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { ComponentRenderer } from '~/src/server/plugins/engine/components/helpers/component-renderer.js' +import { type Component } from '~/src/server/plugins/engine/components/helpers/components.js' +import { type ComponentViewModel } from '~/src/server/plugins/engine/components/types.js' +import { type FormModel } from '~/src/server/plugins/engine/models/index.js' +import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { + type FormPayload, + type FormSubmissionError +} from '~/src/server/plugins/engine/types.js' +import { type FormQuery } from '~/src/server/routes/types.js' + +/** + * ComponentCollection that supports composable components + * with before/after relationships + */ +export class ComposableComponentCollection extends ComponentCollection { + private renderer: ComponentRenderer | undefined + + constructor( + defs: ComponentDef[], + props: { + page?: PageControllerClass + parent?: Component + model: FormModel + }, + schema?: { + peers?: string[] + custom?: CustomValidator + } + ) { + // Process defs to extract main components (without before/after for base class) + const mainDefs = defs.map((def) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { before, after, ...mainDef } = def as ComponentDef & { + before?: unknown + after?: unknown + } + return mainDef as ComponentDef + }) + + // Initialize base class with main components only + super(mainDefs, props, schema) + + // Store original defs with before/after + this.originalDefs = defs + + // Initialize renderer if we have a page + if (props.page) { + this.renderer = new ComponentRenderer(props.page) + } else { + this.renderer = undefined + } + } + + private originalDefs: ComponentDef[] + + /** + * Override getViewModel to handle composable components + */ + getViewModel( + payload: FormPayload, + errors?: FormSubmissionError[], + query: FormQuery = {} + ): ComponentViewModel[] { + // If no renderer (shouldn't happen), fall back to base implementation + if (!this.renderer) { + return super.getViewModel(payload, errors, query) + } + + const result: ComponentViewModel[] = [] + + // Process each original definition with before/after support + for (const def of this.originalDefs) { + const viewModels = this.renderer.renderComponentDef(def, payload, errors) + + // Convert to component view models that work with existing templates + for (const vm of viewModels) { + // Make sure the model has the right structure for existing templates + const componentModel: Record = vm.model ?? {} + + result.push({ + type: vm.type, + isFormComponent: this.isFormComponentType(vm.type), + model: componentModel + } as ComponentViewModel) + } + } + + return result + } + + private isFormComponentType(type: string): boolean { + // List of component types that are form components + const formTypes = [ + 'TextField', + 'EmailAddressField', + 'NumberField', + 'MultilineTextField', + 'TelephoneNumberField', + 'DatePartsField', + 'MonthYearField', + 'UkAddressField', + 'FileUploadField', + 'AutocompleteField', + 'CheckboxesField', + 'RadiosField', + 'SelectField', + 'YesNoField' + ] + + return formTypes.includes(type) + } +} diff --git a/src/server/plugins/engine/components/helpers/component-renderer.ts b/src/server/plugins/engine/components/helpers/component-renderer.ts new file mode 100644 index 000000000..3bcbc8eea --- /dev/null +++ b/src/server/plugins/engine/components/helpers/component-renderer.ts @@ -0,0 +1,121 @@ +import { type ComponentDef } from '@defra/forms-model' + +import { + createComponent, + type Component +} from '~/src/server/plugins/engine/components/helpers/components.js' +import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers/pages.js' +import { + type FormPayload, + type FormSubmissionError +} from '~/src/server/plugins/engine/types.js' + +/** + * Rendered component view model structure + */ +export interface RenderedComponentViewModel { + type: string + model: Record +} + +/** + * ComponentRenderer handles the rendering of components with support for + * composable architecture (before/after components) + */ +export class ComponentRenderer { + private page: PageControllerClass + + constructor(page: PageControllerClass) { + this.page = page + } + + /** + * Renders a component definition to its view model(s) + * Handles single component or array of components + */ + renderComponentDef( + def: ComponentDef | ComponentDef[], + payload: FormPayload, + errors?: FormSubmissionError[] + ): RenderedComponentViewModel[] { + const components = Array.isArray(def) ? def : [def] + const viewModels: RenderedComponentViewModel[] = [] + + for (const componentDef of components) { + // Cast to access before/after properties that aren't in ComponentDef type + const composableDef = componentDef as ComponentDef & { + before?: ComponentDef | ComponentDef[] + after?: ComponentDef | ComponentDef[] + } + + // Render "before" components if they exist + if (composableDef.before) { + viewModels.push( + ...this.renderComponentDef(composableDef.before, payload, errors) + ) + } + + // Create and render the main component + const component = this.createComponentFromDef(componentDef) + if (component) { + const model = component.getViewModel(payload, errors) + viewModels.push({ + type: componentDef.type, + model: (model ?? {}) as Record + }) + } + + // Render "after" components if they exist + if (composableDef.after) { + viewModels.push( + ...this.renderComponentDef(composableDef.after, payload, errors) + ) + } + } + + return viewModels + } + + /** + * Creates a component instance from a definition + */ + private createComponentFromDef(def: ComponentDef): Component | null { + try { + // Remove before/after from the definition when creating the component + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { before, after, ...cleanDef } = def as ComponentDef & { + before?: unknown + after?: unknown + } + + // Create component with correct options structure + const model = this.page.model + return createComponent(cleanDef as ComponentDef, { + page: this.page, + model + }) + } catch (error) { + console.error('Failed to create component:', error) + return null + } + } + + /** + * Flattens the nested structure of view models for template rendering + */ + flattenViewModels( + viewModels: RenderedComponentViewModel[] + ): RenderedComponentViewModel[] { + const flattened: RenderedComponentViewModel[] = [] + + for (const item of viewModels) { + if (Array.isArray(item)) { + flattened.push(...this.flattenViewModels(item)) + } else { + flattened.push(item) + } + } + + return flattened + } +} diff --git a/src/server/plugins/engine/pageControllers/QuestionPageController.ts b/src/server/plugins/engine/pageControllers/QuestionPageController.ts index 20ef943ae..a88a66854 100644 --- a/src/server/plugins/engine/pageControllers/QuestionPageController.ts +++ b/src/server/plugins/engine/pageControllers/QuestionPageController.ts @@ -5,6 +5,7 @@ import { hasComponents, hasNext, hasRepeater, + type ComponentDef, type Link, type Page } from '@defra/forms-model' @@ -17,6 +18,7 @@ import { EXTERNAL_STATE_PAYLOAD } from '~/src/server/constants.js' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { ComposableComponentCollection } from '~/src/server/plugins/engine/components/ComposableComponentCollection.js' import { optionalText } from '~/src/server/plugins/engine/components/constants.js' import { type BackLink } from '~/src/server/plugins/engine/components/types.js' import { @@ -63,12 +65,17 @@ export class QuestionPageController extends PageController { constructor(model: FormModel, pageDef: Page) { super(model, pageDef) - // Components collection - this.collection = new ComponentCollection( - hasComponents(pageDef) ? pageDef.components : [], - { model, page: this } + const components = hasComponents(pageDef) ? pageDef.components : [] + const hasComposable = components.some( + (c: ComponentDef) => c.before ?? c.after ) + const CollectionClass = hasComposable + ? ComposableComponentCollection + : ComponentCollection + + this.collection = new CollectionClass(components, { model, page: this }) + this.collection.formSchema = this.collection.formSchema.keys({ crumb: crumbSchema, action: actionSchema diff --git a/src/server/plugins/engine/pageControllers/SummaryPageController.ts b/src/server/plugins/engine/pageControllers/SummaryPageController.ts index aac9d3b55..4ebadd6e4 100644 --- a/src/server/plugins/engine/pageControllers/SummaryPageController.ts +++ b/src/server/plugins/engine/pageControllers/SummaryPageController.ts @@ -1,5 +1,6 @@ import { hasComponentsEvenIfNoNext, + type ComponentDef, type FormMetadata, type Page, type SubmitPayload @@ -8,6 +9,7 @@ import Boom from '@hapi/boom' import { type RouteOptions } from '@hapi/hapi' import { ComponentCollection } from '~/src/server/plugins/engine/components/ComponentCollection.js' +import { ComposableComponentCollection } from '~/src/server/plugins/engine/components/ComposableComponentCollection.js' import { FileUploadField } from '~/src/server/plugins/engine/components/FileUploadField.js' import { getAnswer } from '~/src/server/plugins/engine/components/helpers/components.js' import { @@ -49,11 +51,18 @@ export class SummaryPageController extends QuestionPageController { super(model, pageDef) this.viewName = 'summary' - // Components collection - this.collection = new ComponentCollection( - hasComponentsEvenIfNoNext(pageDef) ? pageDef.components : [], - { model, page: this } + const components = hasComponentsEvenIfNoNext(pageDef) + ? pageDef.components + : [] + const hasComposable = components.some( + (c: ComponentDef) => c.before ?? c.after ) + + const CollectionClass = hasComposable + ? ComposableComponentCollection + : ComponentCollection + + this.collection = new CollectionClass(components, { model, page: this }) } getSummaryViewModel( diff --git a/src/server/plugins/engine/views/partials/component-renderer.html b/src/server/plugins/engine/views/partials/component-renderer.html new file mode 100644 index 000000000..cb373585a --- /dev/null +++ b/src/server/plugins/engine/views/partials/component-renderer.html @@ -0,0 +1,83 @@ +{% from "components/textfield.html" import TextField %} +{% from "components/emailaddressfield.html" import EmailAddressField %} +{% from "components/numberfield.html" import NumberField %} +{% from "components/multilinetextfield.html" import MultilineTextField %} +{% from "components/telephonenumberfield.html" import TelephoneNumberField %} +{% from "components/monthyearfield.html" import MonthYearField %} +{% from "components/datepartsfield.html" import DatePartsField %} +{% from "components/ukaddressfield.html" import UkAddressField %} +{% from "components/fileuploadfield.html" import FileUploadField %} +{% from "components/autocompletefield.html" import AutocompleteField %} +{% from "components/checkboxesfield.html" import CheckboxesField %} +{% from "components/radiosfield.html" import RadiosField %} +{% from "components/selectfield.html" import SelectField %} +{% from "components/yesnofield.html" import YesNoField %} +{% from "components/details.html" import Details %} +{% from "components/html.html" import Html %} +{% from "components/markdown.html" import Markdown %} +{% from "components/insettext.html" import InsetText %} +{% from "components/list.html" import List %} + +{# + Dynamic component renderer that selects the appropriate component macro + based on the component type. This enables composable component architecture. +#} +{% macro renderComponent(componentData) %} + {% if componentData.type == "TextField" %} + {{ TextField(componentData.model) }} + {% elif componentData.type == "EmailAddressField" %} + {{ EmailAddressField(componentData.model) }} + {% elif componentData.type == "NumberField" %} + {{ NumberField(componentData.model) }} + {% elif componentData.type == "MultilineTextField" %} + {{ MultilineTextField(componentData.model) }} + {% elif componentData.type == "TelephoneNumberField" %} + {{ TelephoneNumberField(componentData.model) }} + {% elif componentData.type == "MonthYearField" %} + {{ MonthYearField(componentData.model) }} + {% elif componentData.type == "DatePartsField" %} + {{ DatePartsField(componentData.model) }} + {% elif componentData.type == "UkAddressField" %} + {{ UkAddressField(componentData.model) }} + {% elif componentData.type == "FileUploadField" %} + {{ FileUploadField(componentData.model) }} + {% elif componentData.type == "AutocompleteField" %} + {{ AutocompleteField(componentData.model) }} + {% elif componentData.type == "CheckboxesField" %} + {{ CheckboxesField(componentData.model) }} + {% elif componentData.type == "RadiosField" %} + {{ RadiosField(componentData.model) }} + {% elif componentData.type == "SelectField" %} + {{ SelectField(componentData.model) }} + {% elif componentData.type == "YesNoField" %} + {{ YesNoField(componentData.model) }} + {% elif componentData.type == "Details" %} + {{ Details(componentData.model) }} + {% elif componentData.type == "Html" %} + {{ Html(componentData.model) }} + {% elif componentData.type == "Markdown" %} + {{ Markdown(componentData.model) }} + {% elif componentData.type == "InsetText" %} + {{ InsetText(componentData.model) }} + {% elif componentData.type == "List" %} + {{ List(componentData.model) }} + {% else %} + {# Fallback for unknown component types #} +
+ + + Warning + Unknown component type: {{ componentData.type }} + +
+ {% endif %} +{% endmacro %} + +{# + Render multiple components sequentially +#} +{% macro renderComponents(components) %} + {% for component in components %} + {{ renderComponent(component) }} + {% endfor %} +{% endmacro %} \ No newline at end of file