diff --git a/designer/server/src/models/forms/editor-v2/advanced-settings-fields.js b/designer/server/src/models/forms/editor-v2/advanced-settings-fields.js index ed99280c3f..c683c5b4ca 100644 --- a/designer/server/src/models/forms/editor-v2/advanced-settings-fields.js +++ b/designer/server/src/models/forms/editor-v2/advanced-settings-fields.js @@ -8,6 +8,7 @@ import { GOVUK_INPUT_WIDTH_3, GOVUK_LABEL__M } from '~/src/models/forms/editor-v2/common.js' +import { mapComposableSettings } from '~/src/models/forms/editor-v2/composable-settings-fields.js' const MIN_FILES_ERROR_MESSAGE = 'Minimum file count must be a whole number between 1 and 25' @@ -458,6 +459,7 @@ export function mapBaseQuestionDetails(payload) { const additionalOptions = getAdditionalOptions(payload) const additionalSchema = getAdditionalSchema(payload) const extraRootFields = mapExtraRootFields(payload) + const composableSettings = mapComposableSettings(payload) return /** @type {Partial} */ ({ type: payload.questionType, @@ -466,6 +468,7 @@ export function mapBaseQuestionDetails(payload) { shortDescription: payload.shortDescription, hint: payload.hintText, ...extraRootFields, + ...composableSettings, // Add composable settings (before/after) options: { required: !isCheckboxSelected(payload.questionOptional), ...additionalOptions diff --git a/designer/server/src/models/forms/editor-v2/composable-settings-fields.js b/designer/server/src/models/forms/editor-v2/composable-settings-fields.js new file mode 100644 index 0000000000..540833b6b0 --- /dev/null +++ b/designer/server/src/models/forms/editor-v2/composable-settings-fields.js @@ -0,0 +1,207 @@ +import { ComponentType } from '@defra/forms-model' + +import { GOVUK_LABEL__M } from '~/src/models/forms/editor-v2/common.js' + +/** + * Fields for configuring composable component relationships + */ + +export const composableSettingsFields = { + /** + * Checkbox to enable adding a component after the main component + */ + addComponentAfter: { + name: 'addComponentAfter', + id: 'addComponentAfter', + classes: 'govuk-checkboxes--small', + items: [ + { + value: 'true', + text: 'Add additional component after this field', + checked: false, + hint: { + text: 'Add instructions, guidance, or other content after this field' + } + } + ] + }, + + /** + * Select the type of component to add after + */ + afterComponentType: { + name: 'afterComponentType', + id: 'afterComponentType', + label: { + text: 'Component type', + classes: 'govuk-label--s' + }, + items: [ + { value: '', text: 'Select component type' }, + { value: ComponentType.Details, text: 'Details (collapsible content)' }, + { value: ComponentType.InsetText, text: 'Inset text' }, + { value: ComponentType.Html, text: 'HTML content' }, + { value: ComponentType.Markdown, text: 'Markdown content' } + ] + }, + + /** + * Content for Details component + */ + afterComponentDetailsContent: { + name: 'afterComponentDetailsContent', + id: 'afterComponentDetailsContent', + label: { + text: 'Details content', + classes: GOVUK_LABEL__M + }, + hint: { + text: 'Enter the content to display in the collapsible section. You can use markdown for formatting.' + }, + rows: 5 + }, + + /** + * Summary text for Details component + */ + afterComponentDetailsSummary: { + name: 'afterComponentDetailsSummary', + id: 'afterComponentDetailsSummary', + label: { + text: 'Summary text', + classes: GOVUK_LABEL__M + }, + hint: { + text: 'Text shown on the collapsible button (e.g., "How to find location details")' + } + }, + + /** + * Content for InsetText component + */ + afterComponentInsetContent: { + name: 'afterComponentInsetContent', + id: 'afterComponentInsetContent', + label: { + text: 'Inset text content', + classes: GOVUK_LABEL__M + }, + hint: { + text: 'Important information to highlight to users' + }, + rows: 3 + }, + + /** + * Content for HTML component + */ + afterComponentHtmlContent: { + name: 'afterComponentHtmlContent', + id: 'afterComponentHtmlContent', + label: { + text: 'HTML content', + classes: GOVUK_LABEL__M + }, + hint: { + text: 'Custom HTML content (use with caution)' + }, + rows: 5 + }, + + /** + * Content for Markdown component + */ + afterComponentMarkdownContent: { + name: 'afterComponentMarkdownContent', + id: 'afterComponentMarkdownContent', + label: { + text: 'Markdown content', + classes: GOVUK_LABEL__M + }, + hint: { + text: 'Content with markdown formatting support' + }, + rows: 5 + } +} + +/** + * Maps form payload to component definition with composable support + * @param {any} payload - Form payload + * @returns {any} Component definition with after property if configured + */ +export function mapComposableSettings(payload) { + /** @type {any} */ + const result = {} + + // Check if adding component after is enabled + if (payload.addComponentAfter === 'true' && payload.afterComponentType) { + /** @type {any} */ + const afterComponent = { + type: payload.afterComponentType, + name: `${payload.name}_after`, + options: {} + } + + // Configure based on component type + switch (payload.afterComponentType) { + case ComponentType.Details: + afterComponent.title = + payload.afterComponentDetailsSummary ?? 'More information' + afterComponent.content = payload.afterComponentDetailsContent ?? '' + break + + case ComponentType.InsetText: + afterComponent.content = payload.afterComponentInsetContent ?? '' + break + + case ComponentType.Html: + afterComponent.content = payload.afterComponentHtmlContent ?? '' + break + + case ComponentType.Markdown: + afterComponent.content = payload.afterComponentMarkdownContent ?? '' + break + } + + result.after = afterComponent + } + + return result +} + +/** + * Extract composable settings from existing component + * @param {any} component - Component definition + * @returns {any} Form values for composable settings + */ +export function extractComposableSettings(component) { + /** @type {any} */ + const values = {} + + if (component.after) { + values.addComponentAfter = 'true' + values.afterComponentType = component.after.type + + switch (component.after.type) { + case ComponentType.Details: + values.afterComponentDetailsSummary = component.after.title ?? '' + values.afterComponentDetailsContent = component.after.content ?? '' + break + + case ComponentType.InsetText: + values.afterComponentInsetContent = component.after.content ?? '' + break + + case ComponentType.Html: + values.afterComponentHtmlContent = component.after.content ?? '' + break + + case ComponentType.Markdown: + values.afterComponentMarkdownContent = component.after.content ?? '' + break + } + } + + return values +} diff --git a/examples/composable-components-example.json b/examples/composable-components-example.json new file mode 100644 index 0000000000..50b817ec54 --- /dev/null +++ b/examples/composable-components-example.json @@ -0,0 +1,52 @@ +{ + "name": "composable components 2", + "engine": "V2", + "schema": 2, + "startPage": "/summary", + "pages": [ + { + "title": "", + "path": "/whats-your-email", + "components": [ + { + "type": "EmailAddressField", + "title": "whats your email?", + "name": "UzHJfU", + "shortDescription": "xcvbxcvb", + "hint": "sdfsdfsdf", + "options": { + "required": true + }, + "schema": {}, + "id": "40c77003-aafe-42c0-9bd8-635623875b7a", + "before": { + "type": "InsetText", + "name": "email_notice", + "title": "Important", + "content": "We will only use your email address to send you a confirmation of your submission.", + "options": {} + }, + "after": { + "type": "Details", + "name": "email_help", + "title": "Why we need your email", + "content": "Your email address helps us: Send you confirmation of receipt. Contact you if we need more information. Keep you updated on the progress of your application.", + "options": {} + } + } + ], + "next": [], + "id": "bd251184-230f-459f-864a-f07b7f0236fc" + }, + { + "id": "449a45f6-4541-4a46-91bd-8b8931b07b50", + "title": "Summary", + "path": "/summary", + "controller": "SummaryPageWithConfirmationEmailController", + "next": [] + } + ], + "conditions": [], + "sections": [], + "lists": [] +} diff --git a/model/src/components/types.ts b/model/src/components/types.ts index 8a0b76a1c4..a30fc5e5e4 100644 --- a/model/src/components/types.ts +++ b/model/src/components/types.ts @@ -252,7 +252,13 @@ export interface SelectFieldComponent extends ListFieldBase { } } -export type ComponentDef = FormComponentsDef | ContentComponentsDef +export interface ComposableComponent { + before?: ComponentDef | ComponentDef[] + after?: ComponentDef | ComponentDef[] +} + +export type ComponentDef = (FormComponentsDef | ContentComponentsDef) & + Partial // Components that render form fields export type FormComponentsDef = diff --git a/model/src/form/form-definition/index.ts b/model/src/form/form-definition/index.ts index 4393de3651..145941a867 100644 --- a/model/src/form/form-definition/index.ts +++ b/model/src/form/form-definition/index.ts @@ -491,7 +491,14 @@ export const componentSchema = Joi.object() .optional() .description( 'Reference to a predefined list of options for select components' - ) + ), + // Just for the spike: Composable components - allow before/after + before: Joi.any() + .optional() + .description('Component(s) to render before this component'), + after: Joi.any() + .optional() + .description('Component(s) to render after this component') }) .unknown(true)