diff --git a/docs/assets/css/docusaurus.scss b/docs/assets/css/docusaurus.scss index 97f2eaa48..1be6614f8 100644 --- a/docs/assets/css/docusaurus.scss +++ b/docs/assets/css/docusaurus.scss @@ -80,6 +80,39 @@ color: #505a5f; } +.app-page-preview__header { + background: #1d70b8; + padding: 10px 20px; + display: flex; + align-items: center; + margin-bottom: 0; +} + +.app-page-preview__service-name { + color: #ffffff; + font-weight: 700; + font-size: 16px; + font-family: sans-serif; +} + +.app-page-preview__main { + padding: 20px; + background: #ffffff; +} + +.app-page-preview__footer { + background: #f3f2f1; + border-top: 1px solid #b1b4b6; + padding: 10px 20px; + font-size: 14px; + color: #505a5f; + font-family: sans-serif; +} + +.component-preview--page { + padding: 0; +} + @media (min-width: 48.125em) { .govuk-template--rebranded .app-masthead .govuk-grid-row { display: flex; diff --git a/scripts/generate-component-docs.js b/scripts/generate-component-docs.js index 81458ec01..da55ea3fd 100644 --- a/scripts/generate-component-docs.js +++ b/scripts/generate-component-docs.js @@ -7,6 +7,8 @@ import ts from 'typescript' import { fixtures } from './component-preview-fixtures.js' import { writePreviewPartial } from './generate-component-previews.js' +import { writePagePreviewPartial } from './generate-page-previews.js' +import { pageFixtures } from './page-preview-fixtures.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -863,12 +865,14 @@ export function controllerSlug(controllerKey) { * @param {string} controllerKey * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps * @param {string} [examplePath] + * @param {Array|null} [exampleComponents] * @returns {Record} */ export function generatePageExample( controllerKey, uniqueProps, - examplePath = '/page-path' + examplePath = '/page-path', + exampleComponents = null ) { const controllerValue = controllerKey === 'PageController' ? null : controllerKey @@ -886,6 +890,8 @@ export function generatePageExample( setNestedValue(example, prop.name, placeholderForType(prop.type)) } + if (exampleComponents) example.components = exampleComponents + return example } @@ -894,12 +900,16 @@ export function generatePageExample( * @param {Array<{name: string, type: string, optional: boolean}>} uniqueProps * @param {string} examplePath * @param {number} sidebarPosition + * @param {string|null} [previewSlug] + * @param {Array|null} [exampleComponents] */ -function generatePageMd( +export function generatePageMd( controllerKey, uniqueProps, examplePath, - sidebarPosition + sidebarPosition, + previewSlug = null, + exampleComponents = null ) { const description = metadata.pages[controllerKey] if (!description) return null @@ -908,11 +918,16 @@ function generatePageMd( const isDefault = controllerKey === 'PageController' const links = metadata.pageLinks?.[controllerKey] ?? [] + const previewImport = previewSlug + ? [``, `import Preview from './_previews/${previewSlug}.mdx'`] + : [] + const lines = [ `---`, `sidebar_label: "${label}"`, `sidebar_position: ${sidebarPosition}`, `---`, + ...previewImport, ``, `# ${label}`, ``, @@ -933,12 +948,21 @@ function generatePageMd( lines.push(`**Controller value:** \`"${controllerKey}"\``, ``) } + if (previewSlug) { + lines.push(`## Preview`, ``, ``, ``) + } + lines.push( `## JSON definition`, ``, '```json', JSON.stringify( - generatePageExample(controllerKey, uniqueProps, examplePath), + generatePageExample( + controllerKey, + uniqueProps, + examplePath, + exampleComponents + ), null, 2 ), @@ -1032,7 +1056,7 @@ function generatePagesIndex() { for (const [key, description] of Object.entries(metadata.pages)) { const label = controllerLabel(key) const slug = controllerSlug(key) - lines.push(`- [**${label}**](./${slug}.md) — ${description}`) + lines.push(`- [**${label}**](./${slug}.mdx) — ${description}`) } lines.push(``) @@ -1092,6 +1116,8 @@ function main() { fs.rmSync(pagesOutputDir, { recursive: true, force: true }) } fs.mkdirSync(pagesOutputDir, { recursive: true }) + const pagePreviewsDir = path.resolve(pagesOutputDir, '_previews') + fs.mkdirSync(pagePreviewsDir, { recursive: true }) // Parse sources const interfaces = parseComponentInterfaces(componentsDtsPath) @@ -1180,13 +1206,25 @@ function main() { } const { props: uniqueProps = [], examplePath = '/page-path' } = pageInterfaces[key] ?? {} - const content = generatePageMd(key, uniqueProps, examplePath, i + 1) + const fixture = pageFixtures[key] + const sidebarPosition = i + 1 + const content = generatePageMd( + key, + uniqueProps, + examplePath, + sidebarPosition, + fixture ? slug : null, + fixture?.exampleComponents ?? null + ) if (content) { - fs.writeFileSync(path.join(pagesOutputDir, `${slug}.md`), content) + fs.writeFileSync(path.join(pagesOutputDir, `${slug}.mdx`), content) + } + if (fixture) { + writePagePreviewPartial(pagePreviewsDir, slug, fixture) } } - fs.writeFileSync(path.join(pagesOutputDir, 'index.md'), generatePagesIndex()) + fs.writeFileSync(path.join(pagesOutputDir, 'index.mdx'), generatePagesIndex()) console.log( `Generated ${componentOrder.length} component pages and ${Object.keys(metadata.pages).length} page type pages.` diff --git a/scripts/generate-component-docs.test.js b/scripts/generate-component-docs.test.js index 74823e0b3..6173fec50 100644 --- a/scripts/generate-component-docs.test.js +++ b/scripts/generate-component-docs.test.js @@ -30,6 +30,14 @@ jest.mock('./component-preview-fixtures.js', () => ({ fixtures: {} })) +jest.mock('./generate-page-previews.js', () => ({ + writePagePreviewPartial: jest.fn() +})) + +jest.mock('./page-preview-fixtures.js', () => ({ + pageFixtures: {} +})) + // jest.mock factories are hoisted before variable declarations, so the // component-metadata.json payload must be inlined rather than referenced. jest.mock('fs', () => ({ @@ -39,7 +47,7 @@ jest.mock('fs', () => ({ readdirSync: jest.fn(), readFileSync: jest.fn().mockImplementation((filePath) => { if (String(filePath ?? '').includes('component-metadata.json')) { - return '{"components":{"TextField":"Single-line text input."},"pages":{"PageController":"The default page type.","RepeatPageController":"Allows repeated answers."},"properties":{"rows":"Number of rows for the textarea."},"pageProperties":{"repeat.options.name":"Identifier for the repeatable section."}}' + return '{"components":{"TextField":"Single-line text input."},"pages":{"PageController":"The default page type.","RepeatPageController":"Allows repeated answers.","SummaryPageController":"Summary page type."},"properties":{"rows":"Number of rows for the textarea."},"pageProperties":{"repeat.options.name":"Identifier for the repeatable section."}}' } return '' }), @@ -55,6 +63,7 @@ import { generateComponentMd, generateExample, generatePageExample, + generatePageMd, placeholderForType, setNestedValue, simplifyType, @@ -287,6 +296,83 @@ describe('Component Documentation Generator', () => { expect(result.repeat.options.name).toBe('') expect(result.repeat).not.toHaveProperty('schema') }) + + it('injects exampleComponents into the example when provided', () => { + const components = [ + { + type: 'FileUploadField', + name: 'upload', + title: 'Upload a document', + options: {}, + schema: {} + } + ] + const result = generatePageExample( + 'FileUploadPageController', + [], + '/file-upload', + components + ) + expect(result.components).toEqual(components) + }) + + it('does not add components when exampleComponents is null', () => { + const result = generatePageExample( + 'PageController', + [], + '/page-path', + null + ) + expect(result).not.toHaveProperty('components') + }) + }) + + describe('generatePageMd with preview', () => { + it('includes preview import when previewSlug is provided', () => { + const result = generatePageMd( + 'PageController', + [], + '/page-path', + 1, + 'page-controller' + ) + expect(result).toContain( + "import Preview from './_previews/page-controller.mdx'" + ) + }) + + it('includes ## Preview section and when previewSlug is provided', () => { + const result = generatePageMd( + 'PageController', + [], + '/page-path', + 1, + 'page-controller' + ) + expect(result).toContain('## Preview') + expect(result).toContain('') + }) + + it('places ## Preview before ## JSON definition', () => { + const result = generatePageMd( + 'SummaryPageController', + [], + '/summary', + 1, + 'summary-page-controller' + ) + const previewIdx = result.indexOf('## Preview') + const jsonIdx = result.indexOf('## JSON definition') + expect(previewIdx).toBeGreaterThan(-1) + expect(previewIdx).toBeLessThan(jsonIdx) + }) + + it('omits import, ## Preview, and when previewSlug is absent', () => { + const result = generatePageMd('PageController', [], '/page-path', 1) + expect(result).not.toContain('import Preview') + expect(result).not.toContain('## Preview') + expect(result).not.toContain('') + }) }) describe('controllerLabel', () => { diff --git a/scripts/generate-component-previews.js b/scripts/generate-component-previews.js index 53ac39f3f..7fcf546b7 100644 --- a/scripts/generate-component-previews.js +++ b/scripts/generate-component-previews.js @@ -50,9 +50,10 @@ export function renderComponent(fixture) { /** * Build the MDX partial content from one or more rendered HTML strings. * @param {Array<{ label?: string, html: string }>} renders + * @param {string} [wrapperClass] * @returns {string} */ -export function buildPartialMdx(renders) { +export function buildPartialMdx(renders, wrapperClass = 'component-preview') { return renders .map(({ label, html }) => { const escaped = html.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') @@ -67,7 +68,7 @@ export function buildPartialMdx(renders) { const labelLine = safeLabel ? `

${safeLabel}

\n` : '' - return `${labelLine}
\n
\n
` + return `${labelLine}
\n
\n
` }) .join('\n\n') } diff --git a/scripts/generate-component-previews.test.js b/scripts/generate-component-previews.test.js index 09d7aa6ec..52413f536 100644 --- a/scripts/generate-component-previews.test.js +++ b/scripts/generate-component-previews.test.js @@ -116,6 +116,17 @@ describe('buildPartialMdx', () => { expect(result).toContain('\\`backtick\\`') expect(result).toContain('\\' + dollarBrace + 'expr}') }) + + it('uses custom wrapperClass when provided', () => { + const result = buildPartialMdx( + [{ html: '' }], + 'component-preview component-preview--page' + ) + expect(result).toContain( + 'className="component-preview component-preview--page"' + ) + expect(result).not.toContain('className="component-preview"') + }) }) describe('renderComponent', () => { diff --git a/scripts/generate-page-previews.js b/scripts/generate-page-previews.js new file mode 100644 index 000000000..c87d1e4bb --- /dev/null +++ b/scripts/generate-page-previews.js @@ -0,0 +1,61 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +import { buildPartialMdx } from './generate-component-previews.js' + +import { environment } from '~/src/server/plugins/nunjucks/environment.js' + +// Make preview-layout.html discoverable by name within the Nunjucks environment. +const scriptsDir = fileURLToPath(new URL('.', import.meta.url)) +for (const loader of /** @type {any} */ (environment).loaders ?? []) { + if (loader.searchPaths) loader.searchPaths.push(scriptsDir) +} + +/** + * Render a single page fixture context to an HTML string. + * Reads the view name from context.page.viewName — set automatically by the + * real page controller via getViewModel, or manually on the page stub for + * fixtures that don't use pageViewContext. + * Passes baseLayoutPath to strip the GOV.UK page wrapper. + * @param {PageViewModelBase} context + * @returns {string} + */ +export function renderPage(context) { + const html = environment.render(`${context.page.viewName}.html`, { + ...context, + baseLayoutPath: 'preview-layout.html' + }) + // Neutralise interactive elements — this is documentation, not a working service. + return html + .replace(/]*>/g, '
') + .replace(/<\/form>/g, '
') + .replace(/href="[^"]*"/g, 'href="#"') +} + +/** + * Renders all variants (or single context) for a page fixture and writes the + * MDX partial to previewsDir/.mdx. + * @param {string} previewsDir + * @param {string} slug + * @param {{ context?: PageViewModelBase, variants?: Array<{label: string, context: PageViewModelBase}> }} fixture + */ +export function writePagePreviewPartial(previewsDir, slug, fixture) { + fs.mkdirSync(previewsDir, { recursive: true }) + + const renders = fixture.variants + ? fixture.variants.map(({ label, context }) => ({ + label, + html: renderPage(context) + })) + : [{ html: renderPage(/** @type {PageViewModelBase} */ (fixture.context)) }] + + fs.writeFileSync( + path.join(previewsDir, `${slug}.mdx`), + buildPartialMdx(renders, 'component-preview component-preview--page') + ) +} + +/** + * @typedef {import('~/src/server/plugins/engine/types.js').PageViewModelBase} PageViewModelBase + */ diff --git a/scripts/generate-page-previews.test.js b/scripts/generate-page-previews.test.js new file mode 100644 index 000000000..b2d551551 --- /dev/null +++ b/scripts/generate-page-previews.test.js @@ -0,0 +1,186 @@ +// @ts-nocheck + +jest.mock('fs', () => ({ + mkdirSync: jest.fn(), + writeFileSync: jest.fn() +})) + +jest.mock('~/src/server/plugins/nunjucks/environment.js', () => ({ + environment: { render: jest.fn() } +})) + +jest.mock('./generate-component-previews.js', () => ({ + buildPartialMdx: jest.fn().mockReturnValue('') +})) + +import { mkdirSync, writeFileSync } from 'fs' + +import { buildPartialMdx } from './generate-component-previews.js' +import { + renderPage, + writePagePreviewPartial +} from './generate-page-previews.js' + +import { environment } from '~/src/server/plugins/nunjucks/environment.js' + +describe('renderPage', () => { + beforeEach(() => { + environment.render.mockReturnValue('

Page

') + }) + + it('calls environment.render with the template name from context.page.viewName', () => { + renderPage({ pageTitle: 'Test', page: { viewName: 'index' } }) + expect(environment.render).toHaveBeenCalledWith( + 'index.html', + expect.objectContaining({ pageTitle: 'Test' }) + ) + }) + + it('overrides baseLayoutPath with preview-layout.html', () => { + renderPage({ + pageTitle: 'Test', + baseLayoutPath: 'something-else.html', + page: { viewName: 'summary' } + }) + expect(environment.render).toHaveBeenCalledWith( + 'summary.html', + expect.objectContaining({ baseLayoutPath: 'preview-layout.html' }) + ) + }) + + it('returns the rendered HTML string', () => { + environment.render.mockReturnValue('

Check your answers

') + const result = renderPage({ + pageTitle: 'Check your answers', + page: { viewName: 'summary' } + }) + expect(result).toBe('

Check your answers

') + }) + + it('replaces all href values with # to neutralise links', () => { + environment.render.mockReturnValue( + 'ChangeRemove' + ) + const result = renderPage({ + pageTitle: 'Test', + page: { viewName: 'index' } + }) + expect(result).not.toContain('href="/some/path"') + expect(result).not.toContain('href="/another?q=1"') + expect(result).toContain('href="#"') + }) + + it('replaces
tags with divs to prevent form submission', () => { + environment.render.mockReturnValue( + '
' + ) + const result = renderPage({ + pageTitle: 'Test', + page: { viewName: 'index' } + }) + expect(result).not.toContain('') + expect(result).toContain('
') + expect(result).toContain('
') + }) +}) + +describe('writePagePreviewPartial', () => { + beforeEach(() => { + environment.render.mockReturnValue('
page html
') + buildPartialMdx.mockReturnValue('') + }) + + it('creates the output directory', () => { + writePagePreviewPartial('/out/_previews', 'page-controller', { + context: { pageTitle: 'Question', page: { viewName: 'index' } } + }) + expect(mkdirSync).toHaveBeenCalledWith('/out/_previews', { + recursive: true + }) + }) + + it('writes MDX to the correct path', () => { + writePagePreviewPartial('/out/_previews', 'summary-page-controller', { + context: { + pageTitle: 'Check your answers', + page: { viewName: 'summary' } + } + }) + expect(writeFileSync).toHaveBeenCalledWith( + '/out/_previews/summary-page-controller.mdx', + '' + ) + }) + + it('passes a single unlabelled render to buildPartialMdx for a flat fixture', () => { + writePagePreviewPartial('/out/_previews', 'page-controller', { + context: { pageTitle: 'Question', page: { viewName: 'index' } } + }) + expect(buildPartialMdx).toHaveBeenCalledWith( + [{ html: '
page html
' }], + 'component-preview component-preview--page' + ) + }) + + it('passes labelled renders to buildPartialMdx for a variant fixture', () => { + writePagePreviewPartial('/out/_previews', 'file-upload-page-controller', { + variants: [ + { + label: 'No files uploaded', + context: { pageTitle: 'Upload', page: { viewName: 'file-upload' } } + }, + { + label: 'With files uploaded', + context: { pageTitle: 'Upload', page: { viewName: 'file-upload' } } + } + ] + }) + expect(buildPartialMdx).toHaveBeenCalledWith( + [ + { label: 'No files uploaded', html: '
page html
' }, + { label: 'With files uploaded', html: '
page html
' } + ], + 'component-preview component-preview--page' + ) + }) + + it('renders each variant context independently', () => { + environment.render + .mockReturnValueOnce('
empty
') + .mockReturnValueOnce('
with files
') + + writePagePreviewPartial('/out/_previews', 'file-upload-page-controller', { + variants: [ + { + label: 'No files uploaded', + context: { + pageTitle: 'Upload', + formAction: null, + page: { viewName: 'file-upload' } + } + }, + { + label: 'With files uploaded', + context: { + pageTitle: 'Upload', + formAction: 'preview', + page: { viewName: 'file-upload' } + } + } + ] + }) + + expect(environment.render).toHaveBeenCalledTimes(2) + expect(environment.render).toHaveBeenNthCalledWith( + 1, + 'file-upload.html', + expect.objectContaining({ formAction: null }) + ) + expect(environment.render).toHaveBeenNthCalledWith( + 2, + 'file-upload.html', + expect.objectContaining({ formAction: 'preview' }) + ) + }) +}) diff --git a/scripts/page-preview-fixtures.js b/scripts/page-preview-fixtures.js new file mode 100644 index 000000000..12b91e8b1 --- /dev/null +++ b/scripts/page-preview-fixtures.js @@ -0,0 +1,310 @@ +import { ComponentType, ControllerType, Engine } from '@defra/forms-model' + +import { fixtures as componentFixtures } from './component-preview-fixtures.js' + +import { FormModel } from '~/src/server/plugins/engine/models/FormModel.js' + +const SUMMARY_PAGE_DEF = + /** @type {import('@defra/forms-model').PageSummary} */ ({ + path: '/summary', + controller: ControllerType.Summary, + title: 'Check your answers', + components: [] + }) + +/** + * Instantiates the real page controller for the given page definition and + * calls getViewModel (or an optional override) with a minimal mock + * request/context. The controller handles showTitle, label sizing, + * isPageHeading, allowContinue, and viewName automatically. + * @param {PageViewContextOptions} options + * @returns {PageViewModel} + */ +function pageViewContext({ + pages, + renderPage = pages[0].path, + state = {}, + payload, + getViewModelOverride +}) { + const allPageDefs = [...pages, SUMMARY_PAGE_DEF] + const model = new FormModel( + { + name: 'preview', + schema: 2, + engine: Engine.V2, + startPage: allPageDefs[0].path, + sections: [], + conditions: [], + lists: [], + pages: allPageDefs + }, + { basePath: '/preview' } + ) + const controller = model.pages.find((p) => p.path === renderPage) + if (!controller) + throw new Error(`No page controller found for '${renderPage}'`) + const mockContext = /** @type {FormContext} */ ( + /** @type {unknown} */ ({ + payload: payload ?? state, + errors: undefined, + evaluationState: state, + relevantState: state, + paths: [], + state, + isForceAccess: false, + relevantPages: model.pages.filter((p) => p.viewName !== 'summary') + }) + ) + const mockRequest = /** @type {FormContextRequest} */ ( + /** @type {unknown} */ ({ + query: {}, + params: {}, + path: renderPage, + url: { search: '' }, + server: { plugins: { 'forms-engine-plugin': {} } } + }) + ) + return getViewModelOverride + ? getViewModelOverride(controller, model, mockRequest, mockContext) + : controller.getViewModel(/** @type {any} */ (mockRequest), mockContext) +} + +const fileUploadWithFilesVariant = /** @type {any} */ ( + componentFixtures[ComponentType.FileUploadField] +)?.variants?.find( + (/** @type {{ label: string }} */ v) => v.label === 'With files uploaded' +) + +/** @type {Record} */ +export const pageFixtures = { + PageController: { + variants: [ + { + label: 'Single question', + context: pageViewContext({ + pages: [ + { + path: '/pagepath', + title: 'What is your full name?', + next: [], + components: [ + { + type: ComponentType.TextField, + name: 'fullname', + title: 'What is your full name?', + hint: 'As shown on your passport', + options: {}, + schema: {} + } + ] + } + ] + }) + }, + { + label: 'Multiple questions', + context: pageViewContext({ + pages: [ + { + path: '/pagepath', + title: 'Tell us about yourself', + next: [], + components: [ + { + type: ComponentType.TextField, + name: 'fullname', + title: 'What is your full name?', + options: {}, + schema: {} + }, + { + type: ComponentType.DatePartsField, + name: 'dob', + title: 'What is your date of birth?', + hint: 'For example, 27 3 2007', + options: {} + } + ] + } + ] + }) + } + ] + }, + + StartPageController: { + context: pageViewContext({ + pages: [ + { + path: '/start', + controller: ControllerType.Start, + title: 'Apply for a licence', + next: [], + components: [] + } + ] + }) + }, + + TerminalPageController: { + context: pageViewContext({ + pages: [ + { + path: '/ineligible', + controller: ControllerType.Terminal, + title: 'You are not eligible', + next: [], + components: [ + /** @type {ComponentDef} */ ({ + type: ComponentType.Html, + name: 'eligibility', + content: + '

You do not meet the eligibility criteria for this service.

', + options: {} + }) + ] + } + ] + }) + }, + + RepeatPageController: { + context: pageViewContext({ + pages: [ + { + path: '/people', + controller: ControllerType.Repeat, + title: 'People', + next: [], + repeat: { + options: { name: 'people', title: 'Person' }, + schema: { min: 1, max: 25 } + }, + components: [ + { + type: ComponentType.TextField, + name: 'fullname', + title: 'Full name', + options: {}, + schema: {} + } + ] + } + ], + getViewModelOverride: (ctrl, _model, req, ctx) => { + const repeat = /** @type {RepeatPageController} */ (ctrl) + const vm = repeat.getListSummaryViewModel(req, ctx, [ + { itemId: '1', fullname: 'Sarah Phillips' }, + { itemId: '2', fullname: 'David Jones' }, + { itemId: '3', fullname: 'Emma Wilson' } + ]) + return /** @type {PageViewModel} */ ( + /** @type {unknown} */ ({ + ...vm, + page: { ...vm.page, viewName: repeat.listSummaryViewName } + }) + ) + } + }) + }, + + FileUploadPageController: { + exampleComponents: [fileUploadWithFilesVariant.def], + variants: [ + { + label: 'No files uploaded', + context: pageViewContext({ + pages: [ + { + path: '/upload', + controller: ControllerType.FileUpload, + title: 'Upload a document', + next: [], + components: [fileUploadWithFilesVariant.def] + } + ], + state: /** @type {FormState} */ ( + /** @type {unknown} */ ({ + upload: { + '/upload': { upload: { uploadUrl: 'preview' }, files: [] } + } + }) + ) + }) + }, + { + label: 'With files uploaded', + context: pageViewContext({ + pages: [ + { + path: '/upload', + controller: ControllerType.FileUpload, + title: 'Upload a document', + next: [], + components: [fileUploadWithFilesVariant.def] + } + ], + state: /** @type {FormState} */ ( + /** @type {unknown} */ ({ + upload: { + '/upload': { upload: { uploadUrl: 'preview' }, files: [] } + } + }) + ), + payload: fileUploadWithFilesVariant.payload + }) + } + ] + }, + + SummaryPageController: { + context: pageViewContext({ + pages: [ + { + path: '/details', + title: 'Your details', + next: [], + components: [ + { + type: ComponentType.TextField, + name: 'fullname', + title: 'Full name', + options: {}, + schema: {} + }, + { + type: ComponentType.EmailAddressField, + name: 'email', + title: 'Email address', + options: {} + } + ] + } + ], + renderPage: '/summary', + state: { fullname: 'Sarah Phillips', email: 'sarah@example.gov.uk' }, + getViewModelOverride: (ctrl, _model, req, ctx) => { + const summary = /** @type {SummaryPageController} */ (ctrl) + return /** @type {PageViewModel} */ ( + /** @type {unknown} */ (summary.getSummaryViewModel(req, ctx)) + ) + } + }) + } +} + +/** + * @typedef {import('@defra/forms-model').ComponentDef} ComponentDef + * @typedef {import('@defra/forms-model').Page} Page + * @typedef {import('~/src/server/plugins/engine/types.js').FormContext} FormContext + * @typedef {import('~/src/server/plugins/engine/types.js').FormContextRequest} FormContextRequest + * @typedef {import('~/src/server/plugins/engine/types.js').FormState} FormState + * @typedef {import('~/src/server/plugins/engine/types.js').PageViewModel} PageViewModel + * @typedef {import('~/src/server/plugins/engine/pageControllers/PageController.js').PageController} PageController + * @typedef {import('~/src/server/plugins/engine/pageControllers/RepeatPageController.js').RepeatPageController} RepeatPageController + * @typedef {import('~/src/server/plugins/engine/pageControllers/SummaryPageController.js').SummaryPageController} SummaryPageController + * @typedef {{ label: string, context: PageViewModel }} PageFixtureVariant + * @typedef {{ context?: PageViewModel, variants?: PageFixtureVariant[], exampleComponents?: ComponentDef[] }} PageFixture + * @typedef {{ pages: Page[], renderPage?: string, state?: FormState, payload?: FormState, getViewModelOverride?: (controller: PageController, model: FormModel, request: FormContextRequest, context: FormContext) => PageViewModel }} PageViewContextOptions + */ diff --git a/scripts/page-preview-fixtures.test.js b/scripts/page-preview-fixtures.test.js new file mode 100644 index 000000000..e2517bc45 --- /dev/null +++ b/scripts/page-preview-fixtures.test.js @@ -0,0 +1,53 @@ +// @ts-nocheck + +import { pageFixtures } from './page-preview-fixtures.js' + +describe('page-preview-fixtures', () => { + it('all 6 expected page types are present', () => { + const expected = [ + 'PageController', + 'StartPageController', + 'TerminalPageController', + 'RepeatPageController', + 'FileUploadPageController', + 'SummaryPageController' + ] + for (const key of expected) { + expect(pageFixtures[key]).toBeDefined() + } + }) + + it('every fixture has either context or variants', () => { + for (const [_key, fixture] of Object.entries(pageFixtures)) { + expect(!!fixture.context || !!fixture.variants).toBe(true) + } + }) + + it('every context has a page.viewName string', () => { + for (const [_key, fixture] of Object.entries(pageFixtures)) { + const contexts = fixture.variants + ? fixture.variants.map((v) => v.context) + : [fixture.context] + for (const context of contexts) { + expect(typeof context.page?.viewName).toBe('string') + } + } + }) + + it('variant fixtures have label and context on every variant', () => { + const variantFixtures = Object.entries(pageFixtures).filter( + ([, fixture]) => fixture.variants + ) + expect(variantFixtures.length).toBeGreaterThan(0) + for (const [, fixture] of variantFixtures) { + for (const variant of fixture.variants) { + expect(typeof variant.label).toBe('string') + expect(variant.context).toBeDefined() + } + } + }) + + it('FileUploadPageController has exactly 2 variants', () => { + expect(pageFixtures.FileUploadPageController.variants).toHaveLength(2) + }) +}) diff --git a/scripts/preview-layout.html b/scripts/preview-layout.html new file mode 100644 index 000000000..4af2593ca --- /dev/null +++ b/scripts/preview-layout.html @@ -0,0 +1,9 @@ +
+ + Example service + +
+
+ {% block content %}{% endblock %} +
+