Skip to content
Draft
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
117 changes: 117 additions & 0 deletions src/server/plugins/engine/components/ComposableComponentCollection.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = 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)
}
}
121 changes: 121 additions & 0 deletions src/server/plugins/engine/components/helpers/component-renderer.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
}

/**
* 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<string, unknown>
})
}

// 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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
hasComponents,
hasNext,
hasRepeater,
type ComponentDef,
type Link,
type Page
} from '@defra/forms-model'
Expand All @@ -17,6 +18,7 @@
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 {
Expand Down Expand Up @@ -63,12 +65,17 @@
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

Check failure on line 70 in src/server/plugins/engine/pageControllers/QuestionPageController.ts

View workflow job for this annotation

GitHub Actions / Build

Property 'after' does not exist on type 'ComponentDef'.

Check failure on line 70 in src/server/plugins/engine/pageControllers/QuestionPageController.ts

View workflow job for this annotation

GitHub Actions / Build

Property 'before' does not exist on type 'ComponentDef'.
)

const CollectionClass = hasComposable
? ComposableComponentCollection
: ComponentCollection

this.collection = new CollectionClass(components, { model, page: this })

this.collection.formSchema = this.collection.formSchema.keys({
crumb: crumbSchema,
action: actionSchema
Expand Down
17 changes: 13 additions & 4 deletions src/server/plugins/engine/pageControllers/SummaryPageController.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
hasComponentsEvenIfNoNext,
type ComponentDef,
type FormMetadata,
type Page,
type SubmitPayload
Expand All @@ -8,6 +9,7 @@
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 {
Expand Down Expand Up @@ -49,11 +51,18 @@
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

Check failure on line 58 in src/server/plugins/engine/pageControllers/SummaryPageController.ts

View workflow job for this annotation

GitHub Actions / Build

Property 'after' does not exist on type 'ComponentDef'.

Check failure on line 58 in src/server/plugins/engine/pageControllers/SummaryPageController.ts

View workflow job for this annotation

GitHub Actions / Build

Property 'before' does not exist on type 'ComponentDef'.
)

const CollectionClass = hasComposable
? ComposableComponentCollection
: ComponentCollection

this.collection = new CollectionClass(components, { model, page: this })
}

getSummaryViewModel(
Expand Down
Loading
Loading