From d1d28c0ca7252e08fafdc7508a14d0e59227d4be Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 12 May 2026 14:56:44 +0100 Subject: [PATCH 1/6] feat: add JS level system for component preview notices Introduces jsLevel (1/2/3) on fixtures to classify JavaScript dependency: - Level 1: hard JS requirement, shows a GOV.UK notification banner only - Level 2: progressively enhanced, shows a notice + static preview - Level 3: no meaningful JS dependency beyond GOV.UK Frontend, static preview only Adds buildJsNotice helper, wires notices into generateComponentMd, and documents the jsLevel/jsNotice/previewSuffix fixture properties. --- docs/assets/css/docusaurus.scss | 2 +- scripts/component-preview-fixtures.js | 79 +++++++++++-- scripts/generate-component-docs.js | 77 ++++++++++-- scripts/generate-component-docs.test.js | 123 +++++++++++++++++++- scripts/generate-component-previews.js | 16 +-- scripts/generate-component-previews.test.js | 39 +++++++ 6 files changed, 302 insertions(+), 34 deletions(-) diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss index 55c8f0319..509b3a1d8 100644 --- a/docs/assets/css/docusaurus.scss +++ b/docs/assets/css/docusaurus.scss @@ -69,7 +69,7 @@ margin-bottom: 0; } -.app-map-placeholder { +.app-preview-placeholder { margin-top: 20px; margin-bottom: 20px; padding: 40px 20px; diff --git a/scripts/component-preview-fixtures.js b/scripts/component-preview-fixtures.js index 62e878137..72fb50a47 100644 --- a/scripts/component-preview-fixtures.js +++ b/scripts/component-preview-fixtures.js @@ -26,6 +26,7 @@ const sampleList = { /** @type {Partial>} */ export const fixtures = { [ComponentType.TextField]: { + jsLevel: 3, def: { type: ComponentType.TextField, name: 'full-name', @@ -38,6 +39,7 @@ export const fixtures = { payload: {} }, [ComponentType.EmailAddressField]: { + jsLevel: 3, def: { type: ComponentType.EmailAddressField, name: 'email', @@ -48,6 +50,7 @@ export const fixtures = { payload: {} }, [ComponentType.MultilineTextField]: { + jsLevel: 3, def: { type: ComponentType.MultilineTextField, name: 'description', @@ -60,6 +63,7 @@ export const fixtures = { payload: {} }, [ComponentType.NumberField]: { + jsLevel: 3, def: { type: ComponentType.NumberField, name: 'age', @@ -71,6 +75,7 @@ export const fixtures = { payload: {} }, [ComponentType.TelephoneNumberField]: { + jsLevel: 3, def: { type: ComponentType.TelephoneNumberField, name: 'phone', @@ -81,6 +86,7 @@ export const fixtures = { payload: {} }, [ComponentType.MonthYearField]: { + jsLevel: 3, def: { type: ComponentType.MonthYearField, name: 'start-date', @@ -92,6 +98,7 @@ export const fixtures = { payload: {} }, [ComponentType.DatePartsField]: { + jsLevel: 3, def: { type: ComponentType.DatePartsField, name: 'dob', @@ -103,6 +110,7 @@ export const fixtures = { payload: {} }, [ComponentType.UkAddressField]: { + jsLevel: 3, variants: [ { label: 'With postcode lookup', @@ -129,6 +137,7 @@ export const fixtures = { ] }, [ComponentType.YesNoField]: { + jsLevel: 3, def: { type: ComponentType.YesNoField, name: 'agree', @@ -139,6 +148,7 @@ export const fixtures = { payload: {} }, [ComponentType.RadiosField]: { + jsLevel: 3, def: { type: ComponentType.RadiosField, name: 'colour', @@ -150,6 +160,7 @@ export const fixtures = { payload: {} }, [ComponentType.CheckboxesField]: { + jsLevel: 3, def: { type: ComponentType.CheckboxesField, name: 'colours', @@ -161,6 +172,7 @@ export const fixtures = { payload: {} }, [ComponentType.SelectField]: { + jsLevel: 3, def: { type: ComponentType.SelectField, name: 'country', @@ -172,6 +184,9 @@ export const fixtures = { payload: {} }, [ComponentType.AutocompleteField]: { + jsLevel: 2, + jsNotice: + 'This component is progressively enhanced. Without JavaScript it renders as a standard element.') + expect(result).toContain('<select>') + expect(result).not.toContain(' element.') + expect(result).toContain('<input>') + expect(result).not.toContain('') + }) + + it('HTML-escapes ampersands in jsNotice', () => { + const result = buildJsNotice(2, 'Fish & chips') + expect(result).toContain('Fish & chips') + expect(result).not.toContain('Fish & chips') + }) + }) + + describe('generateComponentMd with Level 1 fixture', () => { + const interfaceData = { options: [], schema: [], props: [] } + + it('emits ## Preview section with Level 1 banner', () => { + const fixture = { jsLevel: 1, jsNotice: 'Requires OS API credentials.' } + const result = generateComponentMd( + 'GeospatialField', + interfaceData, + 1, + 'geospatial-field', + fixture + ) + expect(result).toContain('## Preview') + expect(result).toContain('Requires client-side JavaScript') + expect(result).toContain('cannot be previewed here') + expect(result).toContain('Requires OS API credentials.') + }) + + it('does not emit or import statement for Level 1', () => { + const fixture = { jsLevel: 1, jsNotice: 'Requires OS API credentials.' } + const result = generateComponentMd( + 'GeospatialField', + interfaceData, + 1, + 'geospatial-field', + fixture + ) + expect(result).not.toContain('') + expect(result).not.toContain('import Preview') + }) + }) + + describe('generateComponentMd with Level 2 fixture', () => { + const interfaceData = { options: [], schema: [], props: [] } + + it('emits jsNotice text under ## Preview, before ', () => { + const fixture = { jsLevel: 2, jsNotice: 'Progressively enhanced.' } + const result = generateComponentMd( + 'TextField', + interfaceData, + 1, + 'text-field', + fixture + ) + expect(result).toContain('## Preview') + expect(result).not.toContain('govuk-notification-banner') + expect(result).toContain('Progressively enhanced.') + expect(result).toContain('') + expect(result.indexOf('Progressively enhanced.')).toBeLessThan( + result.indexOf('') + ) + }) + + it('still imports the preview partial for Level 2', () => { + const fixture = { jsLevel: 2, jsNotice: 'Progressively enhanced.' } + const result = generateComponentMd( + 'TextField', + interfaceData, + 1, + 'text-field', + fixture + ) + expect(result).toContain( + "import Preview from './_previews/text-field.mdx'" + ) + }) + }) }) diff --git a/scripts/generate-component-previews.js b/scripts/generate-component-previews.js index c79501fda..53ac39f3f 100644 --- a/scripts/generate-component-previews.js +++ b/scripts/generate-component-previews.js @@ -72,12 +72,6 @@ export function buildPartialMdx(renders) { .join('\n\n') } -const MAP_PLACEHOLDER = - `
` + - `

` + - `Map appears here with JavaScript enabled` + - `

` - /** * Renders all variants for a component and writes the MDX partial to _previews/.mdx. * @param {string} previewsDir - absolute path to the _previews/ directory @@ -87,18 +81,18 @@ const MAP_PLACEHOLDER = export function writePreviewPartial(previewsDir, slug, fixture) { fs.mkdirSync(previewsDir, { recursive: true }) - // rendering the real map component is too hard as it has server-side dependencies, so we - // just put a placeholder in the documentation - const appendHtml = fixture.mapPlaceholder ? `\n${MAP_PLACEHOLDER}` : '' + const suffixHtml = fixture.previewSuffix + ? `\n

${fixture.previewSuffix}

` + : '' let renders if ('variants' in fixture) { renders = fixture.variants.map((variant) => ({ label: variant.label, - html: renderComponent(variant) + appendHtml + html: renderComponent(variant) + suffixHtml })) } else { - renders = [{ html: renderComponent(fixture) + appendHtml }] + renders = [{ html: renderComponent(fixture) + suffixHtml }] } const content = buildPartialMdx(renders) diff --git a/scripts/generate-component-previews.test.js b/scripts/generate-component-previews.test.js index d56f031a2..995511134 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -65,6 +65,22 @@ describe('component-preview-fixtures', () => { expect(typeof fixtures[type].model?.getList).toBe('function') } }) + + it('every fixture has a jsLevel of 1, 2, or 3', () => { + for (const fixture of Object.values(fixtures)) { + expect([1, 2, 3]).toContain(fixture.jsLevel) + } + }) + + it('every level 1 or 2 fixture has a non-empty jsNotice string', () => { + const lowLevelFixtures = Object.values(fixtures).filter( + (f) => f.jsLevel === 1 || f.jsLevel === 2 + ) + for (const fixture of lowLevelFixtures) { + expect(typeof fixture.jsNotice).toBe('string') + expect(fixture.jsNotice.length).toBeGreaterThan(0) + } + }) }) describe('buildPartialMdx', () => { @@ -170,4 +186,27 @@ describe('writePreviewPartial', () => { const matches = written.match(/className="component-preview"/g) expect(matches).toHaveLength(2) }) + + it('appends previewSuffix text wrapped in app-preview-placeholder div', () => { + const fixture = { + def: { type: 'TextField', name: 'loc', title: 'Location', options: {} }, + model: null, + payload: {}, + previewSuffix: 'Map appears here with JavaScript enabled' + } + writePreviewPartial('/out/_previews', 'location', fixture) + const written = writeFileSync.mock.calls[0][1] + expect(written).toContain('app-preview-placeholder') + expect(written).toContain('Map appears here with JavaScript enabled') + }) + + it('does not append placeholder when previewSuffix is absent', () => { + writePreviewPartial('/out/_previews', 'text-field', { + def: { type: 'TextField', name: 'field', title: 'Field', options: {} }, + model: null, + payload: {} + }) + const written = writeFileSync.mock.calls[0][1] + expect(written).not.toContain('app-preview-placeholder') + }) }) From 3900a16ec920b7567cd7b526ac222853d0f6dda5 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 12 May 2026 15:14:42 +0100 Subject: [PATCH 2/6] fi image url --- docs/assets/css/docusaurus.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss index 509b3a1d8..97f2eaa48 100644 --- a/docs/assets/css/docusaurus.scss +++ b/docs/assets/css/docusaurus.scss @@ -86,7 +86,7 @@ } .govuk-template--rebranded .app-masthead .govuk-grid-column-one-third-from-desktop { - background-image: url("../../../static/assets/images/form-input-screenshot.png"); + background-image: url("/forms-engine-plugin/assets/images/form-input-screenshot.png"); background-repeat: no-repeat; background-position: center bottom; background-size: 220px; From 86989017a8ef354fc484d6440eccba658f15c3e7 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Tue, 12 May 2026 16:53:28 +0100 Subject: [PATCH 3/6] add notice about map question pattern --- scripts/component-metadata.json | 5 ++++- scripts/generate-component-docs.js | 12 +++++++----- scripts/generate-component-docs.test.js | 7 +++++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index d50cb9a8c..0cfc4614b 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -103,7 +103,10 @@ "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the latitude and longitude inputs. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." ], "GeospatialField": [ - "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs across multiple coordinate formats. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs across multiple coordinate formats. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs.", + "## Using maps in your form", + "We recommend using the [map question pattern](https://submit-form-to-defra.service.gov.uk/form/preview/draft/geospatial-pattern/site-information) when asking users to select or draw locations on a map.", + "This pattern provides alternatives so users who cannot use maps are not excluded." ] }, "pageProperties": { diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 334e1772f..cc7d174d0 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -753,12 +753,14 @@ export function generateComponentMd( lines.push(text, ``) } - if (fixture) { + // Level 1: jsNotice only, no ## Preview heading — notice appears above ## JSON definition, static render not possible + // Level 2: jsNotice + under ## Preview — hasPreviewFile is true + // Level 3: only under ## Preview — no jsNotice, hasPreviewFile is true + if (fixture?.jsLevel === 1) { + lines.push(buildJsNotice(1, fixture.jsNotice), ``) + } else if (fixture) { lines.push(`## Preview`, ``) - // Level 1: jsNotice only — hasPreviewFile is false, static render not possible - // Level 2: jsNotice + — hasPreviewFile is true - // Level 3: only — no jsNotice, hasPreviewFile is true - if (fixture.jsLevel === 1 || fixture.jsLevel === 2) { + if (fixture.jsLevel === 2) { lines.push(buildJsNotice(fixture.jsLevel, fixture.jsNotice), ``) } if (hasPreviewFile) { diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index bc28807d6..99d2419c6 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -400,7 +400,7 @@ describe('Component Documentation Generator', () => { describe('generateComponentMd with Level 1 fixture', () => { const interfaceData = { options: [], schema: [], props: [] } - it('emits ## Preview section with Level 1 banner', () => { + it('omits ## Preview heading and places banner above ## JSON definition for Level 1', () => { const fixture = { jsLevel: 1, jsNotice: 'Requires OS API credentials.' } const result = generateComponentMd( 'GeospatialField', @@ -409,10 +409,13 @@ describe('Component Documentation Generator', () => { 'geospatial-field', fixture ) - expect(result).toContain('## Preview') + expect(result).not.toContain('## Preview') expect(result).toContain('Requires client-side JavaScript') expect(result).toContain('cannot be previewed here') expect(result).toContain('Requires OS API credentials.') + expect(result.indexOf('Requires OS API credentials.')).toBeLessThan( + result.indexOf('## JSON definition') + ) }) it('does not emit or import statement for Level 1', () => { From 23960882013cf8027830404f1bbb70e56db06a9f Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 12:04:15 +0100 Subject: [PATCH 4/6] add link for map location pattern --- docusaurus.config.cjs | 1 - scripts/component-metadata.json | 5 +- scripts/component-preview-fixtures.js | 12 +++-- scripts/generate-component-docs.js | 25 +++++---- scripts/generate-component-docs.test.js | 71 ++++++++++++++----------- 5 files changed, 62 insertions(+), 52 deletions(-) diff --git a/docusaurus.config.cjs b/docusaurus.config.cjs index 8ae5e2259..849ff8346 100644 --- a/docusaurus.config.cjs +++ b/docusaurus.config.cjs @@ -78,7 +78,6 @@ const config = { text: 'Features', href: '/features', sidebar: [ - { text: 'Overview', href: '/features' }, { text: 'Components', href: '/features/components' }, { text: 'Page Types', href: '/features/pages' }, { diff --git a/scripts/component-metadata.json b/scripts/component-metadata.json index 0cfc4614b..d50cb9a8c 100644 --- a/scripts/component-metadata.json +++ b/scripts/component-metadata.json @@ -103,10 +103,7 @@ "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the latitude and longitude inputs. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." ], "GeospatialField": [ - "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs across multiple coordinate formats. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs.", - "## Using maps in your form", - "We recommend using the [map question pattern](https://submit-form-to-defra.service.gov.uk/form/preview/draft/geospatial-pattern/site-information) when asking users to select or draw locations on a map.", - "This pattern provides alternatives so users who cannot use maps are not excluded." + "This component renders an inline Ordnance Survey map that lets users click a location to auto-populate the coordinate inputs across multiple coordinate formats. The map requires the `ordnanceSurveyApiKey` and `ordnanceSurveyApiSecret` [plugin options](../../plugin-options.md#geospatial-map) to be set — without them the component falls back to plain text inputs." ] }, "pageProperties": { diff --git a/scripts/component-preview-fixtures.js b/scripts/component-preview-fixtures.js index 72fb50a47..7c70ab270 100644 --- a/scripts/component-preview-fixtures.js +++ b/scripts/component-preview-fixtures.js @@ -186,7 +186,7 @@ export const fixtures = { [ComponentType.AutocompleteField]: { jsLevel: 2, jsNotice: - 'This component is progressively enhanced. Without JavaScript it renders as a standard element.') - expect(result).toContain('<select>') - expect(result).not.toContain(' element.') - expect(result).toContain('<input>') - expect(result).not.toContain('') - }) - - it('HTML-escapes ampersands in jsNotice', () => { - const result = buildJsNotice(2, 'Fish & chips') - expect(result).toContain('Fish & chips') - expect(result).not.toContain('Fish & chips') + it('Level 2: emits inline HTML as-is within the paragraph', () => { + const result = buildJsNotice( + 2, + 'See the pattern for details.' + ) + expect(result).toContain( + 'pattern' + ) }) }) From f70fd984dd94e0a8260458a2b9b9e2437ff50bc3 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 12:18:31 +0100 Subject: [PATCH 5/6] jsdoc types --- scripts/generate-component-docs.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index f468f7206..81458ec01 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -42,7 +42,7 @@ const DEMO_FORM_URL = export function buildJsNotice(jsLevel, jsNotice) { if (typeof jsNotice === 'string') jsNotice = [jsNotice] - const toP = (s) => `

${s}

` + const toP = (/** @type {string} */ s) => `

${s}

` const noticeParagraphs = jsNotice.map(toP) const demoLink = `

To see the full experience, view our demo form which includes most components.

` @@ -716,7 +716,7 @@ export function generateExample(componentName, interfaceData) { * @param {ComponentData} interfaceData * @param {number} sidebarPosition * @param {string|null} [previewSlug] - * @param {object|null} [fixture] + * @param {import('./component-preview-fixtures.js').Fixture|null} [fixture] * @returns {string} */ export function generateComponentMd( @@ -760,11 +760,11 @@ export function generateComponentMd( // Level 2: jsNotice + under ## Preview — hasPreviewFile is true // Level 3: only under ## Preview — no jsNotice, hasPreviewFile is true if (fixture?.jsLevel === 1) { - lines.push(buildJsNotice(1, fixture.jsNotice), ``) + lines.push(buildJsNotice(1, fixture.jsNotice ?? ''), ``) } else if (fixture) { lines.push(`## Preview`, ``) if (fixture.jsLevel === 2) { - lines.push(buildJsNotice(fixture.jsLevel, fixture.jsNotice), ``) + lines.push(buildJsNotice(fixture.jsLevel, fixture.jsNotice ?? ''), ``) } if (hasPreviewFile) { lines.push(``, ``) From a4e86daee972381d4f04dab132a1310083a7cd81 Mon Sep 17 00:00:00 2001 From: Alex Luckett Date: Wed, 13 May 2026 13:57:47 +0100 Subject: [PATCH 6/6] fix test --- scripts/generate-component-previews.test.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/scripts/generate-component-previews.test.js b/scripts/generate-component-previews.test.js index 995511134..09d7aa6ec 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -72,13 +72,18 @@ describe('component-preview-fixtures', () => { } }) - it('every level 1 or 2 fixture has a non-empty jsNotice string', () => { + it('every level 1 or 2 fixture has a non-empty jsNotice string or array', () => { const lowLevelFixtures = Object.values(fixtures).filter( (f) => f.jsLevel === 1 || f.jsLevel === 2 ) for (const fixture of lowLevelFixtures) { - expect(typeof fixture.jsNotice).toBe('string') - expect(fixture.jsNotice.length).toBeGreaterThan(0) + const { jsNotice } = fixture + expect(typeof jsNotice === 'string' || Array.isArray(jsNotice)).toBe(true) + const items = Array.isArray(jsNotice) ? jsNotice : [jsNotice] + expect(items.length).toBeGreaterThan(0) + for (const item of items) { + expect(item.length).toBeGreaterThan(0) + } } }) })