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
1 change: 1 addition & 0 deletions apps/storybook/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import type { Preview } from '@storybook/react-vite';
import { initialize, mswLoader } from 'msw-storybook-addon';
import { mswHandlers } from './msw-handlers';
Expand Down
9 changes: 7 additions & 2 deletions apps/storybook/fixtures/fieldMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ const VALUE_TYPE_BY_WIDGET: Record<WidgetKind, ValueType> = {
time: 'TIME',
age: 'AGE',
select: 'TEXT',
multiSelect: 'MULTI_TEXT',
orgUnit: 'ORGANISATION_UNIT',
coordinate: 'COORDINATE',
file: 'FILE_RESOURCE',
image: 'IMAGE',
unsupported: 'REFERENCE',
};

const OPTION_SET_WIDGETS = new Set<WidgetKind>(['select', 'multiSelect']);

export const makeFieldPsde = (
widgetKind: WidgetKind,
overrides: Partial<ProgramStageDataElement> = {}
Expand All @@ -45,7 +48,7 @@ export const makeFieldPsde = (
},
};

if (widgetKind === 'select' && base.dataElement) {
if (OPTION_SET_WIDGETS.has(widgetKind) && base.dataElement) {
base.dataElement = {
...base.dataElement,
optionSet: {
Expand Down Expand Up @@ -94,9 +97,11 @@ export const TIER1_WIDGET_KINDS = [
'boolean',
'trueOnly',
'select',
'multiSelect',
'date',
'time',
'datetime',
'age',
] as const satisfies readonly WidgetKind[];

export const STUB_WIDGET_KINDS = ['datetime', 'orgUnit'] as const satisfies readonly WidgetKind[];
export const STUB_WIDGET_KINDS = ['orgUnit'] as const satisfies readonly WidgetKind[];
34 changes: 7 additions & 27 deletions apps/storybook/interactions/eventProgramRulesInteractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,23 +66,6 @@ async function typeTriggerText(canvasElement: HTMLElement, value: string) {
}
}

const MANTINE_BOOLEAN_SEGMENT_VALUE: Record<'Yes' | 'No', string> = {
Yes: 'true',
No: 'false',
};

async function clickMantineBooleanSegment(control: HTMLElement, optionLabel: 'Yes' | 'No') {
await userEvent.click(within(control).getByText(optionLabel));
const input = control.querySelector(
`input[type="radio"][value="${MANTINE_BOOLEAN_SEGMENT_VALUE[optionLabel]}"]`
);
if (input instanceof HTMLInputElement && !input.checked) {
input.click();
}
}

// Branches dispatch on the 3 UI adapters' distinct boolean-widget DOM shapes.
// fallow-ignore-next-line complexity
async function pickBooleanOption(
adapter: EventProgramRulesAdapter,
canvasElement: HTMLElement,
Expand All @@ -92,27 +75,24 @@ async function pickBooleanOption(
const canvas = canvasOf(canvasElement);

if (adapter === 'dhis2-ui') {
const field = Array.from(
canvasElement.querySelectorAll('[data-test="dhis2-uiwidgets-singleselectfield"]')
).find((element) => fieldLabel.test(element.textContent ?? ''));
const field = Array.from(canvasElement.querySelectorAll('fieldset')).find((element) =>
fieldLabel.test(element.querySelector('legend')?.textContent?.trim() ?? '')
);
if (!field) {
throw new Error(`DHIS2 boolean field matching ${fieldLabel} not found`);
}
const trigger = field.querySelector('[data-test="dhis2-uicore-select-input"]');
if (!trigger) throw new Error('DHIS2 single select trigger not found');
await userEvent.click(trigger);
await userEvent.click(await screen.findByText(optionLabel));
await userEvent.click(within(field).getByRole('radio', { name: optionLabel }));
return;
}

if (adapter === 'mantine') {
const control = canvas.getByLabelText(fieldLabel);
await clickMantineBooleanSegment(control, optionLabel);
const control = canvas.getByRole('radiogroup', { name: fieldLabel });
await userEvent.click(within(control).getByRole('radio', { name: optionLabel }));
return;
}

const group = canvas.getByRole('group', { name: fieldLabel });
await userEvent.click(within(group).getByRole('button', { name: optionLabel }));
await userEvent.click(within(group).getByRole('radio', { name: optionLabel }));
}

function isColourMenuOpen(adapter: EventProgramRulesAdapter): boolean {
Expand Down
119 changes: 95 additions & 24 deletions apps/storybook/interactions/fieldStoryInteractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,39 @@ function queryNumericInput(canvas: Canvas, widgetKind: WidgetKind) {
}
}

const NUMERIC_WIDGET_KINDS = new Set<WidgetKind>(['integer', 'number', 'percentage']);
const TEMPORAL_WIDGET_KINDS = new Set<WidgetKind>(['date', 'time', 'datetime', 'age']);

// fallow-ignore-next-line complexity
function queryTemporalInput(canvas: Canvas, widgetKind: WidgetKind) {
const labeled = canvas.queryAllByLabelText(labelPattern(widgetKind));
if (labeled[0]) return labeled[0];

for (const role of ['textbox', 'combobox', 'group'] as const) {
const matches = canvas.queryAllByRole(role, { name: labelPattern(widgetKind) });
if (matches[0]) return matches[0];
}

// MUI X pickers sometimes expose the visible label as plain text only.
const byText = canvas.queryByText(labelPattern(widgetKind));
if (!byText) {
throw new Error(`Unable to locate input for widgetKind=${widgetKind}`);
}

const container = byText.closest('.MuiFormControl-root') ?? byText.parentElement;
const input = container?.querySelector('input');
if (!input) {
throw new Error(`Unable to locate input for widgetKind=${widgetKind}`);
}
return input;
}

function queryFieldInput(canvas: Canvas, widgetKind: WidgetKind) {
if (widgetKind === 'integer' || widgetKind === 'number' || widgetKind === 'percentage') {
if (NUMERIC_WIDGET_KINDS.has(widgetKind)) {
return queryNumericInput(canvas, widgetKind);
}
if (widgetKind === 'date' || widgetKind === 'time' || widgetKind === 'age') {
try {
return canvas.getByLabelText(labelPattern(widgetKind));
} catch {
return queryTextInput(canvas, widgetKind);
}
if (TEMPORAL_WIDGET_KINDS.has(widgetKind)) {
return queryTemporalInput(canvas, widgetKind);
}
return queryTextInput(canvas, widgetKind);
}
Expand Down Expand Up @@ -72,6 +95,13 @@ async function openDhis2SingleSelect(canvasElement: HTMLElement) {
await userEvent.click(trigger);
}

async function openDhis2MultiSelect(canvasElement: HTMLElement) {
const field = getByDataTest(canvasElement, 'dhis2-uiwidgets-multiselectfield');
const trigger = field.querySelector('[data-test="dhis2-uicore-select-input"]');
if (!trigger) throw new Error('DHIS2 multi select trigger not found');
await userEvent.click(trigger);
}

async function pickSelectOption(
adapter: FieldStoryAdapter,
canvasElement: HTMLElement,
Expand All @@ -97,35 +127,45 @@ async function pickSelectOption(
await userEvent.click(await screen.findByRole('option', { name: optionLabel }));
}

async function clickMantineBooleanSegment(control: HTMLElement, optionLabel: 'Yes' | 'No') {
await userEvent.click(within(control).getByText(optionLabel));
const value = optionLabel === 'Yes' ? 'true' : 'false';
const input = control.querySelector(`input[type="radio"][value="${value}"]`);
if (input instanceof HTMLInputElement && !input.checked) {
input.click();
}
}

async function pickBooleanYes(
// fallow-ignore-next-line complexity
async function pickMultiSelectOption(
adapter: FieldStoryAdapter,
canvasElement: HTMLElement,
widgetKind: WidgetKind = 'boolean'
widgetKind: WidgetKind,
optionLabel: string
) {
const canvas = canvasOf(canvasElement);

if (adapter === 'dhis2-ui') {
await openDhis2SingleSelect(canvasElement);
await userEvent.click(await screen.findByText('Yes'));
await openDhis2MultiSelect(canvasElement);
const option = await screen.findByText(optionLabel);
await userEvent.click(option);
await expect(
getByDataTest(canvasElement, 'dhis2-uiwidgets-multiselectfield')
).toBeInTheDocument();
return;
}

if (adapter === 'mantine') {
const control = canvas.getByLabelText(labelPattern(widgetKind));
await clickMantineBooleanSegment(control, 'Yes');
const input = canvas.getByRole('textbox', { name: labelPattern(widgetKind) });
const target = input.closest('.mantine-MultiSelect-input') ?? input.parentElement ?? input;
await userEvent.click(target);
await expect(await screen.findByRole('option', { name: optionLabel })).toBeInTheDocument();
await userEvent.click(screen.getByRole('option', { name: optionLabel }));
return;
}

await userEvent.click(canvas.getByRole('button', { name: 'Yes' }));
await userEvent.click(canvas.getByRole('combobox', { name: labelPattern(widgetKind) }));
await userEvent.click(await screen.findByRole('option', { name: optionLabel }));
}

async function pickBooleanYes(
_adapter: FieldStoryAdapter,
canvasElement: HTMLElement,
_widgetKind: WidgetKind = 'boolean'
) {
const canvas = canvasOf(canvasElement);
await userEvent.click(canvas.getByRole('radio', { name: 'Yes' }));
}

export function fieldStoryPlays(adapter: FieldStoryAdapter) {
Expand Down Expand Up @@ -196,12 +236,40 @@ export function fieldStoryPlays(adapter: FieldStoryAdapter) {
await pickSelectOption(adapter, canvasElement, 'select', 'Option A');
};

const multiSelectOption: PlayFunction<FieldStoryArgs> = async ({ canvasElement }) => {
await pickMultiSelectOption(adapter, canvasElement, 'multiSelect', 'Option A');
};

const booleanYes: PlayFunction<FieldStoryArgs> = async ({ canvasElement, args }) => {
await pickBooleanYes(adapter, canvasElement, args?.widgetKind ?? 'boolean');
};

const dateInput: PlayFunction<FieldStoryArgs> = async ({ canvasElement }) => {
await typeIntoField(canvasElement, 'date', '2024-06-15');
const canvas = canvasOf(canvasElement);
const input = queryFieldInput(canvas, 'date');
await expect(input).toBeInTheDocument();
if (input instanceof HTMLInputElement && !input.readOnly) {
await userEvent.clear(input);
await userEvent.type(input, '2024-06-15');
await assertInputValue(input, '2024-06-15');
}
};

const timeInput: PlayFunction<FieldStoryArgs> = async ({ canvasElement }) => {
const canvas = canvasOf(canvasElement);
const input = queryFieldInput(canvas, 'time');
await expect(input).toBeInTheDocument();
if (input instanceof HTMLInputElement && !input.readOnly) {
await userEvent.clear(input);
await userEvent.type(input, '14:30');
await assertInputValue(input, '14:30');
}
};

const datetimeInput: PlayFunction<FieldStoryArgs> = async ({ canvasElement }) => {
const canvas = canvasOf(canvasElement);
const matches = canvas.queryAllByLabelText(labelPattern('datetime'));
await expect(matches.length).toBeGreaterThan(0);
};
Comment on lines 247 to 273

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Exercise temporal value changes in these plays.

dateInput does nothing when the adapter exposes a read-only picker input. datetimeInput only checks that a labeled control exists. These plays can pass while date or datetime selection does not update the field value.

Use adapter-specific picker interactions. Assert the resulting serialized date and datetime values after selection. As per coding guidelines, “Use Storybook in apps/storybook as the primary component documentation and integration/interaction test surface.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/storybook/interactions/fieldStoryInteractions.ts` around lines 247 -
273, Update the dateInput and datetimeInput play functions to exercise the
adapter-specific picker controls rather than skipping read-only inputs or only
checking control presence. Select representative date and datetime values
through those picker interactions, then assert the fields’ serialized values;
keep timeInput unchanged and use the existing field-query and assertion helpers
where applicable.

Source: Coding guidelines


const ageShowsComputedAge: PlayFunction<FieldStoryArgs> = async ({ canvasElement }) => {
Expand All @@ -228,8 +296,11 @@ export function fieldStoryPlays(adapter: FieldStoryAdapter) {
generatedFieldDisabled,
integerInput,
selectOption,
multiSelectOption,
booleanYes,
dateInput,
timeInput,
datetimeInput,
ageShowsComputedAge,
stubWidget,
};
Expand Down
34 changes: 7 additions & 27 deletions apps/storybook/interactions/trackerProgramRulesInteractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,23 +65,6 @@ async function typeAge(canvasElement: HTMLElement, value: string) {
await userEvent.type(input, value);
}

const MANTINE_BOOLEAN_SEGMENT_VALUE: Record<'Yes' | 'No', string> = {
Yes: 'true',
No: 'false',
};

async function clickMantineBooleanSegment(control: HTMLElement, optionLabel: 'Yes' | 'No') {
await userEvent.click(within(control).getByText(optionLabel));
const input = control.querySelector(
`input[type="radio"][value="${MANTINE_BOOLEAN_SEGMENT_VALUE[optionLabel]}"]`
);
if (input instanceof HTMLInputElement && !input.checked) {
input.click();
}
}

// Branches dispatch on the 3 UI adapters' distinct boolean-widget DOM shapes.
// fallow-ignore-next-line complexity
async function pickBooleanOption(
adapter: TrackerProgramRulesAdapter,
canvasElement: HTMLElement,
Expand All @@ -91,27 +74,24 @@ async function pickBooleanOption(
const canvas = canvasOf(canvasElement);

if (adapter === 'dhis2-ui') {
const field = Array.from(
canvasElement.querySelectorAll('[data-test="dhis2-uiwidgets-singleselectfield"]')
).find((element) => fieldLabel.test(element.textContent ?? ''));
const field = Array.from(canvasElement.querySelectorAll('fieldset')).find((element) =>
fieldLabel.test(element.querySelector('legend')?.textContent?.trim() ?? '')
);
if (!field) {
throw new Error(`DHIS2 boolean field matching ${fieldLabel} not found`);
}
const trigger = field.querySelector('[data-test="dhis2-uicore-select-input"]');
if (!trigger) throw new Error('DHIS2 single select trigger not found');
await userEvent.click(trigger);
await userEvent.click(await within(document.body).findByText(optionLabel));
await userEvent.click(within(field).getByRole('radio', { name: optionLabel }));
return;
}

if (adapter === 'mantine') {
const control = canvas.getByLabelText(fieldLabel);
await clickMantineBooleanSegment(control, optionLabel);
const control = canvas.getByRole('radiogroup', { name: fieldLabel });
await userEvent.click(within(control).getByRole('radio', { name: optionLabel }));
return;
}

const group = canvas.getByRole('group', { name: fieldLabel });
await userEvent.click(within(group).getByRole('button', { name: optionLabel }));
await userEvent.click(within(group).getByRole('radio', { name: optionLabel }));
}

export function trackerProgramRulesPlays(adapter: TrackerProgramRulesAdapter) {
Expand Down
7 changes: 5 additions & 2 deletions apps/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mantine/core": "^7.17.8",
"@mantine/dates": "^7.17.8",
"@mantine/hooks": "^7.17.8",
"@mui/material": "^9.0.1",
"@mui/x-date-pickers": "^9.10.1",
"dayjs": "^1.11.21",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.76.0"
Expand All @@ -37,14 +40,14 @@
"@types/react-dom": "^18.3.7",
"@vitest/browser": "^4.1.10",
"@vitest/browser-playwright": "^4.1.7",
"eslint-plugin-storybook": "10.4.6",
"msw": "^2.14.6",
"msw-storybook-addon": "^2.0.7",
"playwright": "^1.60.0",
"storybook": "^10.4.6",
"typescript": "^5.9.3",
"vite": "^8.1.0",
"vitest": "^4.1.7",
"eslint-plugin-storybook": "10.4.6"
"vitest": "^4.1.7"
},
"msw": {
"workerDirectory": [
Expand Down
Loading
Loading