Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/instrument-guidelines/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,10 +473,16 @@ type FormInstrument<TData extends FormInstrument.Data, TLanguage extends Instrum
initialValues?: PartialDeep<TData>;
kind: 'FORM';
measures: InstrumentMeasures<TData, TLanguage> | null;
resetButton?: boolean;
};
```

- `initialValues` — optional pre-populated values, applied when the form is first rendered. This is a _deep_ partial (`PartialDeep<TData>`), 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,18 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon
</Dialog.Content>
</Dialog>
</div>
{/*
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.
*/}
<Form
preventResetValuesOnReset
content={instrument.content}
data-testid="form-content"
initialValues={instrument.initialValues}
preventResetValuesOnReset={!instrument.resetButton}
resetBtn={instrument.resetButton}
submitBtnLabel={submitButtonLabel ? t(submitButtonLabel) : undefined}
validationSchema={instrument.validationSchema}
onSubmit={(data) => void onSubmit({ data, kind: 'FORM' })}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,17 @@ import type { FormInstrument } from '../instrument.form.js';
Extract<FormInstrument<TData>['content'], unknown[]>[number]
>();
}

/** FormInstrument.resetButton */
{
type TData = { _: string };

expectTypeOf<FormInstrument<TData>['resetButton']>().toEqualTypeOf<boolean | undefined>();

/** Optional, so every instrument authored before it existed still satisfies the type */
expectTypeOf<{
content: FormInstrument<TData>['content'];
kind: 'FORM';
measures: FormInstrument<TData>['measures'];
}>().toMatchTypeOf<Partial<FormInstrument<TData>>>();
}
6 changes: 6 additions & 0 deletions packages/runtime-core/src/types/instrument.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,12 @@ declare type FormInstrument<
initialValues?: PartialDeep<TData>;
kind: 'FORM';
measures: InstrumentMeasures<TData, TLanguage> | 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;
}
>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/schemas/src/instrument/instrument.form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@ const $$FormInstrument = <TLanguage extends InstrumentLanguage>(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<FormInstrument<FormInstrument.Data, TLanguage>>;
};
Expand Down
3 changes: 3 additions & 0 deletions testing/src/pages/_app/instruments/render/$id.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,16 @@ 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;

constructor(page: Page) {
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');
Expand Down
84 changes: 84 additions & 0 deletions testing/src/specs/form-reset.spec.ts
Original file line number Diff line number Diff line change
@@ -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<RenderInstrumentPage> {
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();
});
});
Loading