diff --git a/packages/instrument-guidelines/AGENTS.md b/packages/instrument-guidelines/AGENTS.md index 73885b3bb..4e3b7e65e 100644 --- a/packages/instrument-guidelines/AGENTS.md +++ b/packages/instrument-guidelines/AGENTS.md @@ -473,10 +473,16 @@ type FormInstrument; kind: 'FORM'; measures: InstrumentMeasures | null; + resetButton?: boolean; }; ``` - `initialValues` — optional pre-populated values, applied when the form is first rendered. This is a _deep_ partial (`PartialDeep`), so nested composite values (e.g. individual fields within `record-array` rows) may be partially specified. +- `resetButton` — whether to show a button that clears every answer. Defaults to `false`. + + **The button takes effect on the first click.** There is no confirmation step and no undo, and the subject loses everything they have entered. Set it on a long form the subject may reasonably want to start over, and leave it off a short one, where a stray click costs more than the button saves. + + It also governs what happens to the answers after a successful submit: a form with `resetButton: true` is emptied, and a form without it retains its values. Neither is visible in the standard flow, which replaces the form once it is submitted. #### 1.3.2 Interactive Instruments diff --git a/packages/instrument-library/src/forms/DNP_ENHANCED_DEMOGRAPHICS_QUESTIONNAIRE/index.ts b/packages/instrument-library/src/forms/DNP_ENHANCED_DEMOGRAPHICS_QUESTIONNAIRE/index.ts index d2ba055ed..64d04d095 100644 --- a/packages/instrument-library/src/forms/DNP_ENHANCED_DEMOGRAPHICS_QUESTIONNAIRE/index.ts +++ b/packages/instrument-library/src/forms/DNP_ENHANCED_DEMOGRAPHICS_QUESTIONNAIRE/index.ts @@ -598,6 +598,9 @@ export default defineInstrument({ en: ['Demographics'], fr: ['Démographie'] }, + // This questionnaire is long enough that a subject who realises they have been answering for the + // wrong person is better served starting over than clearing twenty fields by hand. + resetButton: true, content: [ { fields: { diff --git a/packages/react-core/src/components/FormContent/FormContent.tsx b/packages/react-core/src/components/FormContent/FormContent.tsx index ffeec04a8..c3b30b802 100644 --- a/packages/react-core/src/components/FormContent/FormContent.tsx +++ b/packages/react-core/src/components/FormContent/FormContent.tsx @@ -43,11 +43,18 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon + {/* + libui's `resetBtn` and `preventResetValuesOnReset` are two ends of one switch: its `reset` + only empties the fields when the latter is absent, so offering the button while preventing + the clear would give a Reset that wipes validation errors and leaves every answer in place. + An instrument therefore either has a working button or has none. + */}
void onSubmit({ data, kind: 'FORM' })} diff --git a/packages/runtime-core/src/types/__tests__/instrument.form.test-d.ts b/packages/runtime-core/src/types/__tests__/instrument.form.test-d.ts index cb179d45a..b13126b3e 100644 --- a/packages/runtime-core/src/types/__tests__/instrument.form.test-d.ts +++ b/packages/runtime-core/src/types/__tests__/instrument.form.test-d.ts @@ -338,3 +338,17 @@ import type { FormInstrument } from '../instrument.form.js'; Extract['content'], unknown[]>[number] >(); } + +/** FormInstrument.resetButton */ +{ + type TData = { _: string }; + + expectTypeOf['resetButton']>().toEqualTypeOf(); + + /** Optional, so every instrument authored before it existed still satisfies the type */ + expectTypeOf<{ + content: FormInstrument['content']; + kind: 'FORM'; + measures: FormInstrument['measures']; + }>().toMatchTypeOf>>(); +} diff --git a/packages/runtime-core/src/types/instrument.form.ts b/packages/runtime-core/src/types/instrument.form.ts index 8759c3f64..2ed7c791e 100644 --- a/packages/runtime-core/src/types/instrument.form.ts +++ b/packages/runtime-core/src/types/instrument.form.ts @@ -302,6 +302,12 @@ declare type FormInstrument< initialValues?: PartialDeep; kind: 'FORM'; measures: InstrumentMeasures | null; + /** + * Whether to offer a button that clears every answer. Defaults to false. The button takes effect + * immediately, with no confirmation step and no way to undo, so it suits a long form the subject + * may want to start over rather than a short one where a stray click costs more than it saves. + */ + resetButton?: boolean; } >; diff --git a/packages/schemas/src/instrument/__tests__/instrument.form.test.ts b/packages/schemas/src/instrument/__tests__/instrument.form.test.ts index 333f1bdc9..fbad47029 100644 --- a/packages/schemas/src/instrument/__tests__/instrument.form.test.ts +++ b/packages/schemas/src/instrument/__tests__/instrument.form.test.ts @@ -32,6 +32,25 @@ describe('$FormInstrument', () => { }); expect(result.success).toBe(true); }); + + // Zod strips what it does not declare, and `apps/web` only validates in development while the + // playground always does — so omitting `resetButton` here would drop the flag in exactly the places + // an author tests their instrument, while leaving it intact in production. + it('should preserve resetButton rather than stripping it', () => { + const result = $FormInstrument.safeParse({ ...unilingualFormInstrument.instance, resetButton: true }); + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('resetButton', true); + }); + + it('should parse a form that omits resetButton, since it is optional', () => { + const result = $FormInstrument.safeParse(unilingualFormInstrument.instance); + expect(result.success).toBe(true); + expect(result.data).not.toHaveProperty('resetButton'); + }); + + it('should reject a non-boolean resetButton', () => { + expect($FormInstrument.safeParse({ ...unilingualFormInstrument.instance, resetButton: 'yes' }).success).toBe(false); + }); }); describe('$FormInstrumentBlock', () => { diff --git a/packages/schemas/src/instrument/instrument.form.ts b/packages/schemas/src/instrument/instrument.form.ts index 3737df037..78b5cd01e 100644 --- a/packages/schemas/src/instrument/instrument.form.ts +++ b/packages/schemas/src/instrument/instrument.form.ts @@ -225,6 +225,7 @@ const $$FormInstrument = (language?: TLang content: $$FormInstrumentContent(language), initialValues: z.record(z.string(), z.any()).optional(), kind: z.literal('FORM'), + resetButton: z.boolean().optional(), validationSchema: $InstrumentValidationSchema }) satisfies z.ZodType>; }; diff --git a/testing/src/pages/_app/instruments/render/$id.page.ts b/testing/src/pages/_app/instruments/render/$id.page.ts index 2f5273183..fb36bc5f9 100644 --- a/testing/src/pages/_app/instruments/render/$id.page.ts +++ b/testing/src/pages/_app/instruments/render/$id.page.ts @@ -10,6 +10,8 @@ export class RenderInstrumentPage extends AppPage { readonly beginButton: Locator; readonly consentPreamble: Locator; readonly errorMessages: Locator; + /** Rendered only for an instrument declaring `resetButton: true`; libui gives it this aria-label. */ + readonly resetButton: Locator; readonly submitButton: Locator; readonly summaryHeading: Locator; @@ -17,6 +19,7 @@ export class RenderInstrumentPage extends AppPage { super(page); this.beginButton = page.getByRole('button', { name: 'Begin' }); this.consentPreamble = page.getByTestId('consent-preamble'); + this.resetButton = page.getByRole('button', { name: 'Reset' }); this.submitButton = page.getByRole('button', { name: 'Submit' }); this.summaryHeading = page.getByRole('heading', { name: /Summary of Results/i }); this.errorMessages = page.getByTestId('error-message-text'); diff --git a/testing/src/specs/form-reset.spec.ts b/testing/src/specs/form-reset.spec.ts new file mode 100644 index 000000000..983d4f79a --- /dev/null +++ b/testing/src/specs/form-reset.spec.ts @@ -0,0 +1,84 @@ +import type { Page } from '@playwright/test'; + +import { RenderInstrumentPage } from '../pages/_app/instruments/render/$id.page'; +import { expect, test } from '../support/fixtures'; + +import type { GetPageModel } from '../support/fixtures'; + +/** Declares `resetButton: true`; it is the longest form in the library, which is why it opts in. */ +const RESETTABLE_INSTRUMENT_TITLE = 'Enhanced Demographics Questionnaire'; + +/** Declares no `resetButton`, so it stands in for every instrument authored before the option existed. */ +const NON_RESETTABLE_INSTRUMENT_TITLE = 'Happiness Questionnaire'; + +/** + * Starts a session and opens an instrument by the title on its card. The session lives in memory, so + * every step navigates through the sidebar rather than by loading a URL. + */ +async function openInstrument( + getPageModel: GetPageModel, + page: Page, + uniqueId: string, + title: string +): Promise { + const startSessionPage = await getPageModel('/session/start-session'); + await startSessionPage.sessionForm.waitFor({ state: 'visible' }); + await startSessionPage.selectIdentificationMethod('PERSONAL_INFO'); + await startSessionPage.fillSessionForm(`Reset${uniqueId}`, `Subject${uniqueId}`, 'Female'); + await startSessionPage.submitForm(); + await expect(startSessionPage.successMessage).toBeVisible(); + + await page.getByTestId('nav-button-/instruments/accessible-instruments').click(); + await page.waitForURL('**/instruments/accessible-instruments'); + + const card = page.locator('[data-testid^="instrument-card-"]').filter({ hasText: title }).first(); + await expect(card).toBeVisible(); + await card.click(); + + const instrumentPage = new RenderInstrumentPage(page); + await instrumentPage.begin(); + return instrumentPage; +} + +test.describe('form reset button', () => { + test('should clear every answer when an instrument opts in @smoke', async ({ getPageModel, page, uniqueId }) => { + const instrumentPage = await openInstrument(getPageModel, page, uniqueId, RESETTABLE_INSTRUMENT_TITLE); + await expect(instrumentPage.resetButton).toBeVisible(); + + await instrumentPage.completeEnhancedDemographicsQuestionnaire(); + + const householdSize = page.locator('[name="householdSize"]'); + const maritalStatus = page.locator('[name="maritalStatus"]'); + await expect(householdSize).toHaveValue('3'); + await expect(maritalStatus).toHaveValue('married'); + + await instrumentPage.resetButton.click(); + + // Values, not just validation errors: this is what `preventResetValuesOnReset` would suppress if + // it were left on for an instrument that offers the button. + await expect(householdSize).toHaveValue(''); + await expect(maritalStatus).toHaveValue(''); + }); + + test('should not offer the button to an instrument that does not opt in', async ({ + getPageModel, + page, + uniqueId + }) => { + const instrumentPage = await openInstrument(getPageModel, page, uniqueId, NON_RESETTABLE_INSTRUMENT_TITLE); + + await expect(instrumentPage.submitButton).toBeVisible(); + await expect(instrumentPage.resetButton).toHaveCount(0); + }); + + test('should still submit normally after a reset', async ({ getPageModel, page, uniqueId }) => { + const instrumentPage = await openInstrument(getPageModel, page, uniqueId, RESETTABLE_INSTRUMENT_TITLE); + + await instrumentPage.completeEnhancedDemographicsQuestionnaire(); + await instrumentPage.resetButton.click(); + await instrumentPage.completeEnhancedDemographicsQuestionnaire(); + await instrumentPage.submit(); + + await expect(instrumentPage.summaryHeading).toBeVisible(); + }); +});