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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@
"dependencies": {
"@base-ui/react": "^1.6.0",
"@douglasneuroinformatics/libjs": "^3.2.1",
"@douglasneuroinformatics/libui-form-types": "^1.5.0",
"@douglasneuroinformatics/libui-form-types": "^1.6.0",
"@radix-ui/react-accordion": "^1.2.3",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions src/components/Form/Form.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,4 +362,41 @@ describe('Form', () => {
expect(screen.queryByTestId('datetime-time-input')).not.toBeInTheDocument();
});
});

describe('set field exclusive options', () => {
afterEach(() => {
vi.clearAllMocks();
});

it('should pass exclusiveOptions from the content to the rendered field', async () => {
render(
<Form
content={{
symptoms: {
exclusiveOptions: ['none'],
kind: 'set',
label: 'Symptoms',
options: { headache: 'Headache', nausea: 'Nausea', none: 'None of the above' },
variant: 'listbox'
}
}}
data-testid={testid}
validationSchema={z.object({ symptoms: z.set(z.enum(['headache', 'nausea', 'none'])) })}
onError={onError}
onSubmit={onSubmit}
/>
);
const headache = screen.getByRole('checkbox', { name: 'Headache' });
const none = screen.getByRole('checkbox', { name: 'None of the above' });
await userEvent.click(headache);
await userEvent.click(none);
expect(headache).toHaveAttribute('data-state', 'unchecked');
expect(headache).toBeDisabled();
expect(screen.getByRole('checkbox', { name: 'Nausea' })).toBeDisabled();
expect(none).toBeEnabled();
fireEvent.submit(screen.getByTestId(testid));
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
expect(onSubmit).toHaveBeenCalledWith({ symptoms: new Set(['none']) });
});
});
});
214 changes: 214 additions & 0 deletions src/components/Form/SetField/SetField.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useState } from 'react';

import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';

import { SetField } from './SetField.tsx';

import type { SetFieldProps } from './SetField.tsx';

type Option = 'a' | 'b' | 'c' | 'd';

type TestSetFieldProps = Pick<SetFieldProps<Option>, 'disabled' | 'exclusiveOptions' | 'readOnly' | 'variant'> & {
initialValue?: Option[];
};

const options: { [K in Option]: string } = { a: 'Option A', b: 'Option B', c: 'Option C', d: 'Option D' };

const TestSetField = ({ initialValue, ...props }: TestSetFieldProps) => {
const [error, setError] = useState<string[] | undefined>();
const [value, setValue] = useState<Set<Option> | undefined>(initialValue ? new Set(initialValue) : undefined);
return (
<>
<SetField
error={error}
kind="set"
label="set-field"
name="set-field"
options={options}
setError={setError}
setValue={setValue}
value={value}
{...props}
/>
<output data-testid="value">
{Array.from(value ?? [])
.sort()
.join(',')}
</output>
</>
);
};

const expectValue = (expected: Option[]) => {
expect(screen.getByTestId('value')).toHaveTextContent(new RegExp(`^${expected.join(',')}$`));
};

describe('SetField', () => {
describe('listbox', () => {
const getOption = (option: Option) => screen.getByRole('checkbox', { name: options[option] });

it('should render a checkbox for each option', () => {
render(<TestSetField variant="listbox" />);
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});

it('should allow selecting all options together if there are no exclusive options', async () => {
render(<TestSetField variant="listbox" />);
for (const option of ['a', 'b', 'c', 'd'] as const) {
await userEvent.click(getOption(option));
expect(getOption(option)).toBeEnabled();
}
expectValue(['a', 'b', 'c', 'd']);
});

it('should disable the other options when an exclusive option is selected', async () => {
render(<TestSetField exclusiveOptions={['c']} variant="listbox" />);
await userEvent.click(getOption('c'));
expect(getOption('c')).toBeEnabled();
expect(getOption('a')).toBeDisabled();
expect(getOption('b')).toBeDisabled();
expect(getOption('d')).toBeDisabled();
await userEvent.click(getOption('a'));
expectValue(['c']);
});

it('should enable the other options when the exclusive option is deselected', async () => {
render(<TestSetField exclusiveOptions={['c']} variant="listbox" />);
await userEvent.click(getOption('c'));
await userEvent.click(getOption('c'));
expect(getOption('a')).toBeEnabled();
await userEvent.click(getOption('a'));
expectValue(['a']);
});

it('should deselect the other options when an exclusive option is selected', async () => {
render(<TestSetField exclusiveOptions={['c']} variant="listbox" />);
await userEvent.click(getOption('a'));
await userEvent.click(getOption('b'));
expectValue(['a', 'b']);
await userEvent.click(getOption('c'));
expectValue(['c']);
expect(getOption('a')).toBeDisabled();
});

it('should make each exclusive option exclusive of the other exclusive options', async () => {
render(<TestSetField exclusiveOptions={['c', 'd']} variant="listbox" />);
await userEvent.click(getOption('c'));
expect(getOption('d')).toBeDisabled();
await userEvent.click(getOption('c'));
await userEvent.click(getOption('d'));
expectValue(['d']);
expect(getOption('c')).toBeDisabled();
});

it('should not modify a value that already breaks the rule', async () => {
render(<TestSetField exclusiveOptions={['c']} initialValue={['a', 'c']} variant="listbox" />);
expectValue(['a', 'c']);
expect(getOption('a')).toBeDisabled();
expect(getOption('c')).toBeEnabled();
await userEvent.click(getOption('c'));
expectValue(['a']);
expect(getOption('b')).toBeEnabled();
});

it('should allow deselecting exclusive options that were selected together', async () => {
render(<TestSetField exclusiveOptions={['c', 'd']} initialValue={['c', 'd']} variant="listbox" />);
expect(getOption('c')).toBeEnabled();
expect(getOption('d')).toBeEnabled();
await userEvent.click(getOption('c'));
expectValue(['d']);
});

it.each(['disabled', 'readOnly'] as const)('should disable all options if the field is %s', (prop) => {
render(<TestSetField exclusiveOptions={['c']} initialValue={['c']} variant="listbox" {...{ [prop]: true }} />);
for (const checkbox of screen.getAllByRole('checkbox')) {
expect(checkbox).toBeDisabled();
}
});
});

describe('select', () => {
const openMenu = async () => {
await userEvent.click(screen.getByRole('button'));
return screen.findByRole('menu');
};
const getOption = (option: Option) => screen.getByRole('menuitemcheckbox', { name: options[option] });

it('should render a menu item for each option', async () => {
render(<TestSetField variant="select" />);
await openMenu();
expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(4);
});

it('should allow selecting all options together if there are no exclusive options', async () => {
render(<TestSetField variant="select" />);
await openMenu();
for (const option of ['a', 'b', 'c', 'd'] as const) {
await userEvent.click(getOption(option));
expect(getOption(option)).not.toHaveAttribute('aria-disabled', 'true');
}
expectValue(['a', 'b', 'c', 'd']);
});

it('should disable the other options when an exclusive option is selected', async () => {
render(<TestSetField exclusiveOptions={['c']} variant="select" />);
await openMenu();
await userEvent.click(getOption('c'));
expect(getOption('c')).not.toHaveAttribute('aria-disabled', 'true');
expect(getOption('a')).toHaveAttribute('aria-disabled', 'true');
expect(getOption('b')).toHaveAttribute('aria-disabled', 'true');
expect(getOption('d')).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(getOption('a'), { pointerEventsCheck: 0 });
expectValue(['c']);
});

it('should enable the other options when the exclusive option is deselected', async () => {
render(<TestSetField exclusiveOptions={['c']} variant="select" />);
await openMenu();
await userEvent.click(getOption('c'));
await userEvent.click(getOption('c'));
expect(getOption('a')).not.toHaveAttribute('aria-disabled', 'true');
await userEvent.click(getOption('a'));
expectValue(['a']);
});

it('should deselect the other options when an exclusive option is selected', async () => {
render(<TestSetField exclusiveOptions={['c']} variant="select" />);
await openMenu();
await userEvent.click(getOption('a'));
await userEvent.click(getOption('b'));
expectValue(['a', 'b']);
await userEvent.click(getOption('c'));
expectValue(['c']);
expect(getOption('a')).toHaveAttribute('aria-disabled', 'true');
});

it('should make each exclusive option exclusive of the other exclusive options', async () => {
render(<TestSetField exclusiveOptions={['c', 'd']} variant="select" />);
await openMenu();
await userEvent.click(getOption('c'));
expect(getOption('d')).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(getOption('c'));
await userEvent.click(getOption('d'));
expectValue(['d']);
expect(getOption('c')).toHaveAttribute('aria-disabled', 'true');
});

it('should not modify a value that already breaks the rule', async () => {
render(<TestSetField exclusiveOptions={['c']} initialValue={['a', 'c']} variant="select" />);
await openMenu();
expectValue(['a', 'c']);
expect(getOption('a')).toHaveAttribute('aria-disabled', 'true');
await userEvent.click(getOption('c'));
expectValue(['a']);
expect(getOption('b')).not.toHaveAttribute('aria-disabled', 'true');
});

it.each(['disabled', 'readOnly'] as const)('should disable the trigger if the field is %s', (prop) => {
render(<TestSetField exclusiveOptions={['c']} initialValue={['c']} variant="select" {...{ [prop]: true }} />);
expect(screen.getByRole('button')).toBeDisabled();
});
});
});
58 changes: 58 additions & 0 deletions src/components/Form/SetField/SetField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { SetField } from './SetField.tsx';

type Fruit = 'apple' | 'banana' | 'blueberry' | 'mango';

type FruitOrNone = 'none' | Fruit;

type Story = StoryObj<typeof SetField<Fruit>>;

export default { component: SetField } as Meta<typeof SetField>;
Expand Down Expand Up @@ -61,3 +63,59 @@ export const Listbox: Story = {
}
]
};

export const WithExclusiveOption: StoryObj<typeof SetField<FruitOrNone>> = {
decorators: [
(Story) => {
const [value, setValue] = useState<Set<FruitOrNone> | undefined>();
return (
<Story
args={{
description: 'When "None of the above" is selected, all other options are disabled.',
exclusiveOptions: ['none'],
label: 'Favorite Fruit',
name: 'fruit',
options: {
apple: 'Apple',
banana: 'Banana',
blueberry: 'Blueberry',
mango: 'Mango',
none: 'None of the above'
},
setValue,
value,
variant: 'listbox'
}}
/>
);
}
]
};

export const SelectWithExclusiveOption: StoryObj<typeof SetField<FruitOrNone>> = {
decorators: [
(Story) => {
const [value, setValue] = useState<Set<FruitOrNone> | undefined>();
return (
<Story
args={{
description: 'When "None of the above" is selected, all other options are disabled.',
exclusiveOptions: ['none'],
label: 'Favorite Fruit',
name: 'fruit',
options: {
apple: 'Apple',
banana: 'Banana',
blueberry: 'Blueberry',
mango: 'Mango',
none: 'None of the above'
},
setValue,
value,
variant: 'select'
}}
/>
);
}
]
};
Loading
Loading