From 3141353a902c3230f8e697a3390160686868ea47 Mon Sep 17 00:00:00 2001 From: Sanilas-ai Date: Sat, 31 Oct 2026 11:59:33 +0300 Subject: [PATCH 1/2] fix(react-core): render each instruction in its own paragraph Instrument instructions were joined with ', ' into a single paragraph in InstrumentOverview and the FormContent info dialog. Render one whitespace-pre-line

per entry instead, keyed by index since entries may repeat. Fixes #1812 --- .../src/components/FormContent/FormContent.tsx | 7 ++++++- .../FormContent/__tests__/FormContent.test.tsx | 4 +++- .../InstrumentOverview/InstrumentOverview.tsx | 7 ++++++- .../__tests__/InstrumentOverview.test.tsx | 15 ++++++++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/react-core/src/components/FormContent/FormContent.tsx b/packages/react-core/src/components/FormContent/FormContent.tsx index 3dc24b059..82a22ef2a 100644 --- a/packages/react-core/src/components/FormContent/FormContent.tsx +++ b/packages/react-core/src/components/FormContent/FormContent.tsx @@ -67,7 +67,12 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon -

{instructions?.join(', ')}

+ {instructions?.map((instruction, i) => ( + // entries may repeat, so the index is the only stable key +

+ {instruction} +

+ ))} 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..3763652ac 100644 --- a/packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx +++ b/packages/react-core/src/components/FormContent/__tests__/FormContent.test.tsx @@ -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/InstrumentOverview/InstrumentOverview.tsx b/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx index 8d3d1a642..1f8839997 100644 --- a/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx +++ b/packages/react-core/src/components/InstrumentOverview/InstrumentOverview.tsx @@ -48,7 +48,12 @@ export const InstrumentOverview = ({ beforeBegin, disableBegin, instrument, onNe fr: 'Instructions' })} -

{instructions!.join(', ')}

+ {instructions!.map((instruction, i) => ( + // entries may repeat, so the index is the only stable key +

+ {instruction} +

+ ))} )} 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', () => { From 6689ddb075e39b7e53f0cab0406f933227ec3eb0 Mon Sep 17 00:00:00 2001 From: Sanilas-ai Date: Mon, 2 Nov 2026 21:21:44 +0300 Subject: [PATCH 2/2] fix(react-core): render instructions through one shared component Move the per-entry paragraphs into an internal InstrumentInstructions component used by both InstrumentOverview and FormContent, and give the instructions button an accessible name so it can be found by role. Add a playground end-to-end test: happy-dom has no layout engine, so only a real browser can check that whitespace-pre-line keeps an entry's line breaks, in the overview and in the instructions dialog. --- .../components/FormContent/FormContent.tsx | 20 +++++--- .../__tests__/FormContent.test.tsx | 2 +- .../InstrumentInstructions.tsx | 17 +++++++ .../__tests__/InstrumentInstructions.test.tsx | 32 ++++++++++++ .../InstrumentInstructions/index.ts | 1 + .../InstrumentOverview/InstrumentOverview.tsx | 9 ++-- testing/src/specs/playground.spec.ts | 51 +++++++++++++++++++ 7 files changed, 118 insertions(+), 14 deletions(-) create mode 100644 packages/react-core/src/components/InstrumentInstructions/InstrumentInstructions.tsx create mode 100644 packages/react-core/src/components/InstrumentInstructions/__tests__/InstrumentInstructions.test.tsx create mode 100644 packages/react-core/src/components/InstrumentInstructions/index.ts diff --git a/packages/react-core/src/components/FormContent/FormContent.tsx b/packages/react-core/src/components/FormContent/FormContent.tsx index 82a22ef2a..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,12 +78,7 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon - {instructions?.map((instruction, i) => ( - // entries may repeat, so the index is the only stable key -

- {instruction} -

- ))} +
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 3763652ac..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(() => { 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 1f8839997..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,12 +50,7 @@ export const InstrumentOverview = ({ beforeBegin, disableBegin, instrument, onNe fr: 'Instructions' })} - {instructions!.map((instruction, i) => ( - // entries may repeat, so the index is the only stable key -

- {instruction} -

- ))} + )} 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', () => {