Skip to content

Commit 7ae9624

Browse files
Add an improved Multi Select Filter component to history page filters (cadence-workflow#1032)
* Create new MultiSelectFilter component with overridden selection menu, with checkboxes and Select All * Use component in History Page filters (type & status) * (misc) Fix styling in Page Filters so that non-minified filters share space evenly regardless of content * (misc) Delete unused styles from page-filters.styles.ts
1 parent 559d9cb commit 7ae9624

16 files changed

+734
-220
lines changed
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React from 'react';
2+
3+
import { render, screen, userEvent, within } from '@/test-utils/rtl';
4+
5+
import MultiSelectFilter from '../multi-select-filter';
6+
7+
jest.mock('../multi-select-menu/multi-select-menu', () =>
8+
jest.fn(() => (
9+
<div data-testid="multi-select-menu">Multi Select Menu Placeholder</div>
10+
))
11+
);
12+
13+
const MOCK_OPTIONS_LABEL_MAP = {
14+
opt1: 'Option 1',
15+
opt2: 'Option 2',
16+
opt3: 'Option 3',
17+
} as const;
18+
19+
type MockOption = keyof typeof MOCK_OPTIONS_LABEL_MAP;
20+
21+
describe(MultiSelectFilter.name, () => {
22+
it('renders without errors', () => {
23+
setup({});
24+
25+
expect(screen.getByText('Mock Label')).toBeInTheDocument();
26+
expect(screen.getByRole('combobox')).toBeInTheDocument();
27+
});
28+
29+
it('shows multi select menu when dropdown is opened', async () => {
30+
const { user } = setup({});
31+
32+
const selectFilter = screen.getByRole('combobox');
33+
await user.click(selectFilter);
34+
35+
expect(screen.getByTestId('multi-select-menu')).toBeInTheDocument();
36+
});
37+
38+
it('displays selected values correctly', () => {
39+
setup({
40+
values: ['opt1', 'opt3'],
41+
});
42+
43+
const buttons = screen.getAllByRole('button');
44+
expect(buttons).toHaveLength(3);
45+
46+
expect(within(buttons[0]).getByText('Option 1')).toBeDefined();
47+
expect(within(buttons[1]).getByText('Option 3')).toBeDefined();
48+
expect(within(buttons[2]).getByText('Clear all')).toBeDefined();
49+
});
50+
51+
it('calls onChangeValues when selected values are modified', async () => {
52+
const { user, mockOnChangeValues } = setup({
53+
values: ['opt1', 'opt3'],
54+
});
55+
56+
const buttons = screen.getAllByRole('button');
57+
expect(buttons).toHaveLength(3);
58+
59+
// Delete Option 1
60+
await user.click(within(buttons[0]).getByText('Delete'));
61+
62+
expect(mockOnChangeValues).toHaveBeenCalledWith(['opt3']);
63+
});
64+
65+
it('calls onChangeValues when Clear All is called', async () => {
66+
const { user, mockOnChangeValues } = setup({
67+
values: ['opt1', 'opt3'],
68+
});
69+
70+
const buttons = screen.getAllByRole('button');
71+
expect(buttons).toHaveLength(3);
72+
73+
await user.click(buttons[2]);
74+
75+
expect(mockOnChangeValues).toHaveBeenCalledWith([]);
76+
});
77+
78+
it('handles empty values array', () => {
79+
setup({
80+
values: [],
81+
});
82+
83+
expect(screen.getByRole('combobox')).toBeInTheDocument();
84+
expect(screen.getByText('Mock Placeholder')).toBeInTheDocument();
85+
});
86+
});
87+
88+
function setup({
89+
label = 'Mock Label',
90+
placeholder = 'Mock Placeholder',
91+
values = [],
92+
optionsLabelMap = MOCK_OPTIONS_LABEL_MAP,
93+
}: {
94+
label?: string;
95+
placeholder?: string;
96+
values?: Array<MockOption>;
97+
optionsLabelMap?: Record<MockOption, string>;
98+
} = {}) {
99+
const mockOnChangeValues = jest.fn();
100+
const user = userEvent.setup();
101+
102+
render(
103+
<MultiSelectFilter
104+
label={label}
105+
placeholder={placeholder}
106+
values={values}
107+
onChangeValues={mockOnChangeValues}
108+
optionsLabelMap={optionsLabelMap}
109+
/>
110+
);
111+
112+
return { user, mockOnChangeValues };
113+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { type Theme } from 'baseui';
2+
import type { FormControlOverrides } from 'baseui/form-control/types';
3+
import { type SelectOverrides } from 'baseui/select';
4+
import { type TagOverrides } from 'baseui/tag';
5+
import { type StyleObject } from 'styletron-react';
6+
7+
export const overrides = {
8+
selectFormControl: {
9+
Label: {
10+
style: ({ $theme }: { $theme: Theme }): StyleObject => ({
11+
...$theme.typography.LabelXSmall,
12+
}),
13+
},
14+
ControlContainer: {
15+
style: (): StyleObject => ({
16+
margin: '0px',
17+
}),
18+
},
19+
} satisfies FormControlOverrides,
20+
select: {
21+
Tag: {
22+
props: {
23+
overrides: {
24+
Root: {
25+
style: ({ $theme }: { $theme: Theme }) => ({
26+
color: $theme.colors.contentPrimary,
27+
border: `1px solid ${$theme.colors.contentPrimary}`,
28+
backgroundColor: $theme.colors.backgroundSecondary,
29+
marginTop: $theme.sizing.scale0,
30+
marginLeft: $theme.sizing.scale0,
31+
marginRight: $theme.sizing.scale0,
32+
marginBottom: $theme.sizing.scale0,
33+
}),
34+
},
35+
Action: {
36+
style: ({ $theme }: { $theme: Theme }) => ({
37+
marginLeft: $theme.sizing.scale100,
38+
}),
39+
},
40+
} satisfies TagOverrides,
41+
},
42+
},
43+
} satisfies SelectOverrides,
44+
};
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { useMemo, useRef } from 'react';
2+
3+
import { FormControl } from 'baseui/form-control';
4+
import { mergeOverrides } from 'baseui/helpers/overrides';
5+
import {
6+
type ImperativeMethods,
7+
Select,
8+
type SelectOverrides,
9+
} from 'baseui/select';
10+
11+
import { overrides } from './multi-select-filter.styles';
12+
import { type Props } from './multi-select-filter.types';
13+
import MultiSelectMenu from './multi-select-menu/multi-select-menu';
14+
import {
15+
type MultiSelectMenuOption,
16+
type Props as MenuProps,
17+
} from './multi-select-menu/multi-select-menu.types';
18+
19+
export default function MultiSelectFilter<T extends string>({
20+
label,
21+
placeholder,
22+
values,
23+
onChangeValues,
24+
optionsLabelMap,
25+
}: Props<T>) {
26+
const selectValue = useMemo(
27+
() => values.map((v) => ({ id: v, label: optionsLabelMap[v] })),
28+
[values, optionsLabelMap]
29+
);
30+
31+
const options = useMemo(
32+
() =>
33+
Object.entries<string>(optionsLabelMap).map(
34+
([id, label]) =>
35+
({
36+
id,
37+
label,
38+
}) as MultiSelectMenuOption<T>
39+
),
40+
[optionsLabelMap]
41+
);
42+
43+
const controlRef = useRef<ImperativeMethods>(null);
44+
45+
return (
46+
<FormControl label={label} overrides={overrides.selectFormControl}>
47+
<Select
48+
controlRef={controlRef}
49+
multi
50+
size="compact"
51+
value={selectValue}
52+
options={options}
53+
onChange={(params) =>
54+
onChangeValues(params.value.map((v) => v.id as T))
55+
}
56+
overrides={mergeOverrides(
57+
{
58+
Dropdown: {
59+
component: MultiSelectMenu,
60+
props: {
61+
values,
62+
options,
63+
onChangeValues,
64+
onCloseMenu: () =>
65+
controlRef.current &&
66+
controlRef.current.setDropdownOpen(false),
67+
} satisfies MenuProps<T>,
68+
},
69+
} satisfies SelectOverrides,
70+
overrides.select
71+
)}
72+
placeholder={placeholder}
73+
/>
74+
</FormControl>
75+
);
76+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type Props<T extends string> = {
2+
label: string;
3+
placeholder: string;
4+
values: Array<T>;
5+
onChangeValues: (newValues: Array<T>) => void;
6+
optionsLabelMap: Record<T, string>;
7+
};

0 commit comments

Comments
 (0)