diff --git a/packages/react-core/src/components/FormContent/FormContent.tsx b/packages/react-core/src/components/FormContent/FormContent.tsx index 3dc24b059..7c1584ff1 100644 --- a/packages/react-core/src/components/FormContent/FormContent.tsx +++ b/packages/react-core/src/components/FormContent/FormContent.tsx @@ -6,6 +6,8 @@ import type { AnyUnilingualFormInstrument, FormInstrument, InstrumentKind } from import { InfoIcon } from 'lucide-react'; import type { Promisable } from 'type-fest'; +import { InstrumentInstructions } from '../InstrumentInstructions'; + import type { LocalizedText } from '../../types'; // libui wraps every field in `data-field-group` and renders that field's errors inside it as @@ -52,7 +54,16 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon {instrument.clientDetails?.title ?? instrument.details.title} - @@ -67,7 +78,7 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon -

{instructions?.join(', ')}

+
diff --git a/packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx b/packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx index c1158f6df..bbec0e9c1 100644 --- a/packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx +++ b/packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx @@ -32,7 +32,7 @@ const renderFormContent = (props: Partial = {}) => { return onSubmit; }; -const getInfoButton = () => screen.getByRole('heading').parentElement!.querySelector('button')!; +const getInfoButton = () => screen.getByRole('button', { name: 'Instructions' }); describe('FormContent', () => { beforeAll(() => { @@ -69,8 +69,10 @@ describe('FormContent', () => { }); fireEvent.click(getInfoButton()); await waitFor(() => { - expect(screen.getByText('Answer honestly, Be brief')).toBeTruthy(); + expect(screen.getByText('Answer honestly').tagName).toBe('P'); + expect(screen.getByText('Be brief').tagName).toBe('P'); }); + expect(screen.queryByText('Answer honestly, Be brief')).toBeNull(); }); it('should disable the info button when there are no instructions to show', () => { diff --git a/packages/react-core/src/components/InstrumentInstructions/InstrumentInstructions.tsx b/packages/react-core/src/components/InstrumentInstructions/InstrumentInstructions.tsx new file mode 100644 index 000000000..0f22f0304 --- /dev/null +++ b/packages/react-core/src/components/InstrumentInstructions/InstrumentInstructions.tsx @@ -0,0 +1,17 @@ +export type InstrumentInstructionsProps = { + instructions: string[]; +}; + +/** Renders each instruction in its own paragraph, keeping the line breaks within an entry. */ +export const InstrumentInstructions = ({ instructions }: InstrumentInstructionsProps) => { + return ( + <> + {instructions.map((instruction, i) => ( + // entries may repeat, so the index is the only stable key +

+ {instruction} +

+ ))} + + ); +}; diff --git a/packages/react-core/src/components/InstrumentInstructions/__tests__/InstrumentInstructions.test.tsx b/packages/react-core/src/components/InstrumentInstructions/__tests__/InstrumentInstructions.test.tsx new file mode 100644 index 000000000..5512fd4e3 --- /dev/null +++ b/packages/react-core/src/components/InstrumentInstructions/__tests__/InstrumentInstructions.test.tsx @@ -0,0 +1,32 @@ +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { InstrumentInstructions } from '../InstrumentInstructions'; + +describe('InstrumentInstructions', () => { + afterEach(cleanup); + + it('should render each instruction in its own paragraph', () => { + render(); + expect(screen.getByText('Step one').tagName).toBe('P'); + expect(screen.getByText('Step two').tagName).toBe('P'); + expect(screen.queryByText('Step one, Step two')).toBeNull(); + }); + + it('should render repeated instructions as separate paragraphs', () => { + render(); + expect(screen.getAllByText('Repeat me')).toHaveLength(2); + }); + + it('should keep the line breaks within an instruction', () => { + const { container } = render(); + const paragraph = container.querySelector('p')!; + expect(paragraph.textContent).toBe('Answer\nhonestly.'); + expect(paragraph.className).toContain('whitespace-pre-line'); + }); + + it('should render nothing when there are no instructions', () => { + const { container } = render(); + expect(container.childElementCount).toBe(0); + }); +}); diff --git a/packages/react-core/src/components/InstrumentInstructions/index.ts b/packages/react-core/src/components/InstrumentInstructions/index.ts new file mode 100644 index 000000000..4b99baad1 --- /dev/null +++ b/packages/react-core/src/components/InstrumentInstructions/index.ts @@ -0,0 +1 @@ +export * from './InstrumentInstructions'; diff --git a/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx b/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx index 8d3d1a642..cf17a4008 100644 --- a/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx +++ b/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx @@ -4,6 +4,8 @@ import { Button, Heading } from '@douglasneuroinformatics/libui/components'; import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; import type { AnyUnilingualInstrument } from '@opendatacapture/runtime-core'; +import { InstrumentInstructions } from '../InstrumentInstructions'; + export type InstrumentOverviewProps = { /** Content rendered directly above the "Begin" button (e.g., a verification gate). */ beforeBegin?: ReactNode; @@ -48,7 +50,7 @@ export const InstrumentOverview = ({ beforeBegin, disableBegin, instrument, onNe fr: 'Instructions' })} -

{instructions!.join(', ')}

+ )} diff --git a/packages/react-core/src/components/InstrumentOverview/__tests__/InstrumentOverview.test.tsx b/packages/react-core/src/components/InstrumentOverview/__tests__/InstrumentOverview.test.tsx index 38e1cbb6c..40667b536 100644 --- a/packages/react-core/src/components/InstrumentOverview/__tests__/InstrumentOverview.test.tsx +++ b/packages/react-core/src/components/InstrumentOverview/__tests__/InstrumentOverview.test.tsx @@ -36,7 +36,20 @@ describe('InstrumentOverview', () => { render(); expect(screen.getByText('Estimated Duration')).toBeTruthy(); expect(screen.getByText('Instructions')).toBeTruthy(); - expect(screen.getByText('Step one, Step two')).toBeTruthy(); + expect(screen.getByText('Step one').tagName).toBe('P'); + expect(screen.getByText('Step two').tagName).toBe('P'); + expect(screen.queryByText('Step one, Step two')).toBeNull(); + }); + + it('should render repeated instructions as separate paragraphs', () => { + const instrument = { + details: { + instructions: ['Repeat me', 'Repeat me'], + title: 'My Instrument' + } + } as unknown as AnyUnilingualInstrument; + render(); + expect(screen.getAllByText('Repeat me')).toHaveLength(2); }); it('should omit the duration and instructions sections when the instrument declares neither', () => { diff --git a/testing/src/specs/playground.spec.ts b/testing/src/specs/playground.spec.ts index a4610baef..81872e6b5 100644 --- a/testing/src/specs/playground.spec.ts +++ b/testing/src/specs/playground.spec.ts @@ -154,6 +154,29 @@ export default defineInstrument({ `; } +/** + * A form with two instructions, the second spanning two lines, so each should render as its own + * element and keep its line break. + */ +function instructionsInstrumentSource(title: string): string { + return ` +import { defineInstrument } from '/runtime/v1/@opendatacapture/runtime-core'; +import { z } from '/runtime/v1/zod@3.x'; + +export default defineInstrument({ + kind: 'FORM', + language: 'en', + tags: ['Instructions'], + internal: { edition: 1, name: 'INSTRUCTIONS' }, + clientDetails: { estimatedDuration: 1, instructions: ['Read each question.', 'Answer\\nhonestly.'] }, + content: { note: { kind: 'string', label: 'Note', variant: 'input' } }, + details: { description: 'Instructions', license: 'Apache-2.0', title: '${title}' }, + measures: {}, + validationSchema: z.object({ note: z.string().optional() }) +}); +`; +} + // The first paint waits on the 13 MB esbuild download and the toolchain boot; the preview then // compiles on a 2 s poll, so the whole chain is slower than the suite's default expect timeout. const PREVIEW_TIMEOUT = 60_000; @@ -386,6 +409,34 @@ test.describe('playground', () => { timeout: PREVIEW_TIMEOUT }); }); + + test('should show each instruction as its own paragraph, keeping its line breaks', async ({ page, uniqueId }) => { + const title = `Instructions ${uniqueId}`; + const playground = new PlaygroundPage(page); + await playground.goto( + generatePlaygroundURL({ + baseURL: playgroundURL, + files: [{ content: instructionsInstrumentSource(title), name: 'index.ts' }], + label: title + }) + ); + + // Without `exact`, a substring match would also accept the old single paragraph joined with ", ". + const first = playground.preview.getByText('Read each question.', { exact: true }); + const second = playground.preview.getByText('Answer honestly.', { exact: true }); + await expect(first).toBeVisible({ timeout: PREVIEW_TIMEOUT }); + await expect(second).toBeVisible(); + // A string would be whitespace-normalized; the regex checks innerText keeps the line break. + await expect(second).toHaveText(/^Answer\nhonestly\.$/, { useInnerText: true }); + + await playground.preview.getByRole('button', { name: 'Begin' }).click(); + await playground.preview.getByRole('button', { name: 'Instructions' }).click(); + const dialog = playground.preview.getByRole('dialog'); + await expect(dialog.getByText('Read each question.', { exact: true })).toBeVisible(); + const dialogSecond = dialog.getByText('Answer honestly.', { exact: true }); + await expect(dialogSecond).toBeVisible(); + await expect(dialogSecond).toHaveText(/^Answer\nhonestly\.$/, { useInnerText: true }); + }); }); test.describe('playground interactive examples', () => {