diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss index 55c8f0319..97f2eaa48 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; @@ -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; 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-preview-fixtures.js b/scripts/component-preview-fixtures.js index 62e878137..7c70ab270 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 <select> dropdown that works fully. With JavaScript enabled it becomes a searchable autocomplete.', def: { type: ComponentType.AutocompleteField, name: 'country', @@ -183,6 +198,7 @@ export const fixtures = { payload: {} }, [ComponentType.DeclarationField]: { + jsLevel: 3, def: { type: ComponentType.DeclarationField, name: 'declaration', @@ -196,6 +212,9 @@ export const fixtures = { // FileUploadField acts as a file manager for files already in session state. // Actual uploading is handled by CDP via FileUploadPageController. [ComponentType.FileUploadField]: { + jsLevel: 2, + jsNotice: + 'This component is progressively enhanced. File uploads work without JavaScript using standard form submission. With JavaScript enabled, users get real-time upload progress.', variants: [ { label: 'No files uploaded', @@ -260,6 +279,7 @@ export const fixtures = { ] }, [ComponentType.Html]: { + jsLevel: 3, def: { type: ComponentType.Html, name: 'info', @@ -271,6 +291,7 @@ export const fixtures = { payload: {} }, [ComponentType.InsetText]: { + jsLevel: 3, def: { type: ComponentType.InsetText, name: 'notice', @@ -282,6 +303,7 @@ export const fixtures = { payload: {} }, [ComponentType.Details]: { + jsLevel: 3, def: { type: ComponentType.Details, name: 'help', @@ -293,6 +315,7 @@ export const fixtures = { payload: {} }, [ComponentType.Markdown]: { + jsLevel: 3, def: { type: ComponentType.Markdown, name: 'guidance', @@ -304,6 +327,7 @@ export const fixtures = { payload: {} }, [ComponentType.List]: { + jsLevel: 3, def: { type: ComponentType.List, name: 'steps', @@ -315,6 +339,7 @@ export const fixtures = { payload: {} }, [ComponentType.HiddenField]: { + jsLevel: 3, def: { type: ComponentType.HiddenField, name: 'ref', @@ -325,6 +350,10 @@ export const fixtures = { payload: {} }, [ComponentType.LatLongField]: { + jsLevel: 2, + jsNotice: + 'This component is progressively enhanced. The coordinate fields work without JavaScript. With JavaScript enabled, an interactive map lets users click to set their location.', + previewSuffix: 'Map appears here with JavaScript enabled', def: { type: ComponentType.LatLongField, name: 'location', @@ -333,10 +362,13 @@ export const fixtures = { schema: {} }, model: null, - payload: {}, - mapPlaceholder: true + payload: {} }, [ComponentType.EastingNorthingField]: { + jsLevel: 2, + jsNotice: + 'This component is progressively enhanced. The coordinate fields work without JavaScript. With JavaScript enabled, an interactive map lets users click to set their location.', + previewSuffix: 'Map appears here with JavaScript enabled', def: { type: ComponentType.EastingNorthingField, name: 'location', @@ -345,10 +377,13 @@ export const fixtures = { schema: {} }, model: null, - payload: {}, - mapPlaceholder: true + payload: {} }, [ComponentType.OsGridRefField]: { + jsLevel: 2, + jsNotice: + 'This component is progressively enhanced. The OS grid reference field works without JavaScript. With JavaScript enabled, an interactive map lets users click to set their location.', + previewSuffix: 'Map appears here with JavaScript enabled', def: { type: ComponentType.OsGridRefField, name: 'location', @@ -356,10 +391,13 @@ export const fixtures = { options: {} }, model: null, - payload: {}, - mapPlaceholder: true + payload: {} }, [ComponentType.NationalGridFieldNumberField]: { + jsLevel: 2, + jsNotice: + 'This component is progressively enhanced. The national grid reference field works without JavaScript. With JavaScript enabled, an interactive map lets users click to set their location.', + previewSuffix: 'Map appears here with JavaScript enabled', def: { type: ComponentType.NationalGridFieldNumberField, name: 'location', @@ -367,10 +405,14 @@ export const fixtures = { options: {} }, model: null, - payload: {}, - mapPlaceholder: true + payload: {} }, [ComponentType.GeospatialField]: { + jsLevel: 1, + jsNotice: [ + 'A multiline text input that accepts raw GeoJSON is available as a fallback when JavaScript is unavailable, but this is not a recommended user journey. This component has a hard client-side JavaScript requirement. If JavaScript availability is a concern, use a progressively enhanced component instead.', + 'We recommend using the map question pattern when asking users to select or draw locations on a map. This pattern provides alternatives so users who cannot use maps are not excluded.' + ], def: { type: ComponentType.GeospatialField, name: 'location', @@ -378,10 +420,10 @@ export const fixtures = { options: {} }, model: null, - payload: {}, - mapPlaceholder: true + payload: {} }, [ComponentType.PaymentField]: { + jsLevel: 3, variants: [ { label: 'Before payment', @@ -432,5 +474,24 @@ export const fixtures = { * @typedef {import('~/src/server/plugins/engine/types.js').FormPayload} FormPayload * @typedef {{ def: ComponentDef, model: Partial|null, payload: FormPayload }} FixtureRender * @typedef {{ label: string } & FixtureRender} FixtureVariant - * @typedef {{ mapPlaceholder?: boolean } & (FixtureRender | { variants: FixtureVariant[] })} Fixture + * @typedef {{ jsLevel: 1|2|3, jsNotice?: string | string[], previewSuffix?: string } & (FixtureRender | { variants: FixtureVariant[] })} Fixture + * + * jsLevel describes the component's JavaScript dependency: + * 1 — hard JS requirement, cannot be statically rendered (e.g. map). Docs show a jsNotice only. + * 2 — progressively enhanced, works without JS but degrades. Docs show a jsNotice + static preview. + * 3 — no meaningful JS dependency beyond what GOV.UK Frontend already provides. Docs show a static preview only. + * + * jsNotice — HTML/JSX string or array of strings explaining the JS dependency (required for levels 1 + * and 2). Each string is wrapped in a

. Use + * for links. previewSuffix is plain text (markdown context). + * + * previewSuffix — optional text appended below the static preview as a placeholder, used to + * indicate where a JS-rendered element (e.g. a map) would appear in a live environment. + * + * def — the component definition passed to the form engine to render the preview. + * model — partial FormModel state; null if no model context is needed. + * payload — form submission payload used to pre-populate the component. + * + * variants — alternative to a single render: an array of labelled def/model/payload sets, + * each rendered as a separate tab in the preview. */ diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 839078f82..81458ec01 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -27,6 +27,45 @@ const metadata = JSON.parse( /** @type {Record} */ const ACRONYMS = { Uk: 'UK', Os: 'OS', Html: 'HTML' } +const DEMO_FORM_URL = + 'https://submit-form-to-defra.service.gov.uk/form/register-a-unicorn' + +/** + * Build a JS notice for Level 1 or Level 2 components. + * jsNotice is a plain HTML/JSX string or array of strings; each is wrapped in a govuk-body + * paragraph and emitted as-is (no conversion). Level 1 renders a GOV.UK notification banner + * (JSX); Level 2 emits the paragraphs directly as HTML. + * @param {1|2} jsLevel + * @param {string | string[]} jsNotice - Plain HTML/JSX string(s). Use for links. + * @returns {string} + */ +export function buildJsNotice(jsLevel, jsNotice) { + if (typeof jsNotice === 'string') jsNotice = [jsNotice] + + 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.

` + + if (jsLevel === 1) { + return [ + `
`, + `
`, + `

Warning

`, + `
`, + `
`, + `

Requires client-side JavaScript

`, + `

This component cannot be previewed here — it requires Ordnance Survey API credentials and a running map service that aren't available in the documentation environment.

`, + ...noticeParagraphs, + demoLink, + `
`, + `
` + ].join('\n') + } + + return [...noticeParagraphs, demoLink].join('\n\n') +} + /** * @param {string} str * @returns {string} @@ -677,13 +716,15 @@ export function generateExample(componentName, interfaceData) { * @param {ComponentData} interfaceData * @param {number} sidebarPosition * @param {string|null} [previewSlug] + * @param {import('./component-preview-fixtures.js').Fixture|null} [fixture] * @returns {string} */ export function generateComponentMd( componentName, interfaceData, sidebarPosition, - previewSlug = null + previewSlug = null, + fixture = null ) { const description = metadata.components[componentName] ?? '' const label = toLabel(componentName) @@ -692,7 +733,9 @@ export function generateComponentMd( const links = metadata.componentLinks?.[componentName] ?? [] // leading '' ensures a blank line between frontmatter and the import - const previewImport = previewSlug + // Level 1 components require client-side JavaScript to render and can't be statically previewed + const hasPreviewFile = previewSlug && fixture?.jsLevel !== 1 + const previewImport = hasPreviewFile ? [``, `import Preview from './_previews/${previewSlug}.mdx'`] : [] @@ -713,8 +756,19 @@ export function generateComponentMd( lines.push(text, ``) } - if (previewSlug) { - lines.push(`## Preview`, ``, ``, ``) + // 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`, ``) + if (fixture.jsLevel === 2) { + lines.push(buildJsNotice(fixture.jsLevel, fixture.jsNotice ?? ''), ``) + } + if (hasPreviewFile) { + lines.push(``, ``) + } } lines.push( @@ -1081,20 +1135,32 @@ function main() { const slug = toKebabCase(name) const fixture = fixtures[name] const sidebarPosition = i + 1 + + if ( + fixture && + (fixture.jsLevel === 1 || fixture.jsLevel === 2) && + !fixture.jsNotice + ) { + throw new Error( + `Fixture for ${name} has jsLevel ${fixture.jsLevel} but no jsNotice` + ) + } + const content = generateComponentMd( name, interfaceData, sidebarPosition, - fixture ? slug : null + fixture ? slug : null, + fixture ?? null ) fs.writeFileSync(path.join(componentsOutputDir, `${slug}.mdx`), content) - if (fixture) { - writePreviewPartial(previewsOutputDir, slug, fixture) - } else { + if (!fixture) { console.warn( `Warning: no preview fixture for ${name} — add one to component-preview-fixtures.js` ) + } else if (fixture.jsLevel !== 1) { + writePreviewPartial(previewsOutputDir, slug, fixture) } } diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index fff0feff7..74823e0b3 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -48,6 +48,7 @@ jest.mock('fs', () => ({ })) import { + buildJsNotice, controllerLabel, controllerSlug, deriveCategory, @@ -327,12 +328,13 @@ describe('Component Documentation Generator', () => { describe('generateComponentMd with preview', () => { const interfaceData = { options: [], schema: [], props: [] } - it('includes preview import when slug is provided', () => { + it('includes preview import when slug and fixture are provided', () => { const result = generateComponentMd( 'TextField', interfaceData, 1, - 'text-field' + 'text-field', + { jsLevel: 3, render: () => '' } ) expect(result).toContain( "import Preview from './_previews/text-field.mdx'" @@ -341,10 +343,135 @@ describe('Component Documentation Generator', () => { expect(result).toContain('') }) - it('omits preview section when no slug is provided', () => { + it('omits preview section when no fixture is provided', () => { const result = generateComponentMd('TextField', interfaceData, 1) expect(result).not.toContain('import Preview') expect(result).not.toContain('## Preview') }) }) + + describe('buildJsNotice', () => { + it('Level 1: renders a GOV.UK notification banner with banner structure', () => { + const result = buildJsNotice(1, 'Notice text.') + expect(result).toContain('govuk-notification-banner') + expect(result).toContain('Warning') + expect(result).toContain('Requires client-side JavaScript') + expect(result).toContain('cannot be previewed here') + expect(result).toContain('Notice text.') + expect(result).toContain('view our demo form') + }) + + it('Level 1: wraps jsNotice strings in govuk-body paragraphs', () => { + const result = buildJsNotice(1, 'Plain notice text.') + expect(result).toContain( + '

Plain notice text.

' + ) + }) + + it('Level 1: emits inline HTML as-is within the paragraph', () => { + const result = buildJsNotice( + 1, + 'See the pattern for details.' + ) + expect(result).toContain( + 'pattern' + ) + }) + + it('Level 2: renders govuk-body paragraphs with demo link', () => { + const result = buildJsNotice( + 2, + 'This component is progressively enhanced.' + ) + expect(result).toContain( + '

This component is progressively enhanced.

' + ) + expect(result).toContain('To see the full experience,') + expect(result).toContain( + `view our demo form` + ) + expect(result).not.toContain('govuk-notification-banner') + }) + + it('Level 2: emits inline HTML as-is within the paragraph', () => { + const result = buildJsNotice( + 2, + 'See the pattern for details.' + ) + expect(result).toContain( + 'pattern' + ) + }) + }) + + describe('generateComponentMd with Level 1 fixture', () => { + const interfaceData = { options: [], schema: [], props: [] } + + 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', + interfaceData, + 1, + 'geospatial-field', + fixture + ) + 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', () => { + 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..09d7aa6ec 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -65,6 +65,27 @@ 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 or array', () => { + const lowLevelFixtures = Object.values(fixtures).filter( + (f) => f.jsLevel === 1 || f.jsLevel === 2 + ) + for (const fixture of lowLevelFixtures) { + 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) + } + } + }) }) describe('buildPartialMdx', () => { @@ -170,4 +191,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') + }) })