Skip to content
Open
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
15 changes: 13 additions & 2 deletions packages/react-core/src/components/FormContent/FormContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,7 +54,16 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon
<Heading variant="h4">{instrument.clientDetails?.title ?? instrument.details.title}</Heading>
<Dialog>
<Dialog.Trigger asChild>
<Button disabled={!instructions?.length} size="icon" variant="ghost">
<Button
aria-label={t({
en: 'Instructions',
es: 'Instrucciones',
fr: 'Instructions'
})}
disabled={!instructions?.length}
size="icon"
variant="ghost"
>
<InfoIcon />
</Button>
</Dialog.Trigger>
Expand All @@ -67,7 +78,7 @@ export const FormContent = ({ instrument, onSubmit, submitButtonLabel }: FormCon
</Dialog.Title>
</Dialog.Header>
<Dialog.Body className="py-2">
<p className="text-muted-foreground text-sm">{instructions?.join(', ')}</p>
<InstrumentInstructions instructions={instructions ?? []} />
</Dialog.Body>
</Dialog.Content>
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const renderFormContent = (props: Partial<FormContentProps> = {}) => {
return onSubmit;
};

const getInfoButton = () => screen.getByRole('heading').parentElement!.querySelector('button')!;
const getInfoButton = () => screen.getByRole<HTMLButtonElement>('button', { name: 'Instructions' });

describe('FormContent', () => {
beforeAll(() => {
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
<p className="text-muted-foreground text-sm whitespace-pre-line" key={i}>
{instruction}
</p>
))}
</>
);
};
Original file line number Diff line number Diff line change
@@ -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(<InstrumentInstructions instructions={['Step one', 'Step two']} />);
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(<InstrumentInstructions instructions={['Repeat me', 'Repeat me']} />);
expect(screen.getAllByText('Repeat me')).toHaveLength(2);
});

it('should keep the line breaks within an instruction', () => {
const { container } = render(<InstrumentInstructions instructions={['Answer\nhonestly.']} />);
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(<InstrumentInstructions instructions={[]} />);
expect(container.childElementCount).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './InstrumentInstructions';
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,7 +50,7 @@ export const InstrumentOverview = ({ beforeBegin, disableBegin, instrument, onNe
fr: 'Instructions'
})}
</Heading>
<p className="text-muted-foreground text-sm">{instructions!.join(', ')}</p>
<InstrumentInstructions instructions={instructions!} />
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,20 @@ describe('InstrumentOverview', () => {
render(<InstrumentOverview instrument={instrument} onNext={vi.fn()} />);
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(<InstrumentOverview instrument={instrument} onNext={vi.fn()} />);
expect(screen.getAllByText('Repeat me')).toHaveLength(2);
});

it('should omit the duration and instructions sections when the instrument declares neither', () => {
Expand Down
51 changes: 51 additions & 0 deletions testing/src/specs/playground.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading