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
59 changes: 18 additions & 41 deletions apps/shade/src/components/features/filters/filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2234,15 +2234,7 @@ export function Filters<T = unknown>({
// For select and multiselect types, show the options popover
if (field.type === 'select' || field.type === 'multiselect') {
setSelectedFieldKeyForOptions(field.key);

// When editing an existing filter (single-filter mode), pre-populate with its values
if (!allowMultiple && field.type === 'multiselect') {
const existingFilter = filters.find(f => f.field === fieldKey);
setTempSelectedValues(existingFilter ? existingFilter.values : []);
} else {
setTempSelectedValues([]);
}

setTempSelectedValues([]);
return;
}

Expand Down Expand Up @@ -2271,37 +2263,19 @@ export function Filters<T = unknown>({
);

const addFilterWithOption = useCallback(
(field: FilterFieldConfig<T>, values: unknown[], closePopover: boolean = true) => {
(field: FilterFieldConfig<T>, values: unknown[]) => {
if (!field.key) {
return;
}

// In single-filter mode, update the existing filter for this field if one exists
if (!allowMultiple) {
const existingFilter = filters.find(f => f.field === field.key);
if (existingFilter) {
onChange(filters.map(f => (f.id === existingFilter.id ? {...f, values: values as T[]} : f)));
setTempSelectedValues(values as T[]);

if (closePopover) {
closeFilterPopover();
}
return;
}
}

// Create a new filter
// Every commit creates a new filter. Multi-value editing of an existing
// filter happens through the filter row's own picker, not here.
const defaultOperator = field.defaultOperator || (field.type === 'multiselect' ? 'is_any_of' : 'is');
const newFilter = createFilter<T>(field.key, defaultOperator, values as T[]);
onChange([...filters, newFilter]);

if (closePopover) {
closeFilterPopover();
} else {
setTempSelectedValues(values as unknown[]);
}
closeFilterPopover();
},
[allowMultiple, closeFilterPopover, filters, onChange]
[closeFilterPopover, filters, onChange]
);

const selectableFields = useMemo(() => {
Expand Down Expand Up @@ -2422,18 +2396,21 @@ export function Filters<T = unknown>({
)}
>
{selectedFieldForOptions ? (
// Show original select/multiselect rendering without back button
// SelectOptionsPopover renders its own Command component when inline={true}
// The inline "add filter" picker always commits one filter per
// pick and closes β€” for both `select` and `multiselect` fields.
// We override `multiselect` β†’ `select` so SelectOptionsPopover
// renders the single-pick UI (one click β†’ onChange + onClose).
// Multi-value editing of an existing filter happens through the
// filter row's own picker, not here.
<SelectOptionsPopover<T>
field={selectedFieldForOptions}
field={
selectedFieldForOptions.type === 'multiselect'
? {...selectedFieldForOptions, type: 'select'}
: selectedFieldForOptions
}
inline={true}
values={tempSelectedValues as T[]}
onChange={(values) => {
// For multiselect, create filter immediately but keep popover open
// For single select, create filter and close popover
const shouldClosePopover = selectedFieldForOptions.type === 'select';
addFilterWithOption(selectedFieldForOptions, values as unknown[], shouldClosePopover);
}}
onChange={values => addFilterWithOption(selectedFieldForOptions, values as unknown[])}
onClose={closeFilterPopover}
/>
) : (
Expand Down
87 changes: 86 additions & 1 deletion apps/shade/test/unit/components/ui/filters.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {useMemo, useState} from 'react';
import {act, fireEvent, render, screen, waitFor} from '../../utils/test-utils';
import {afterEach, beforeAll, describe, expect, it, vi} from 'vitest';
import {createFilter, FilterFieldConfig, Filters, ValueSource} from '../../../../src/components/features/filters/filters';
import {createFilter, Filter, FilterFieldConfig, Filters, ValueSource} from '../../../../src/components/features/filters/filters';

type TestOption = {
value: string;
Expand Down Expand Up @@ -214,3 +214,88 @@ describe('Filters ValueSource', () => {
expect(document.querySelector('.animate-spin')).toBeTruthy();
});
});

describe('Filters allowMultiple multiselect', () => {
beforeAll(() => {
global.ResizeObserver = class {
observe() {
return undefined;
}

unobserve() {
return undefined;
}

disconnect() {
return undefined;
}
} as unknown as typeof ResizeObserver;
HTMLElement.prototype.scrollIntoView = vi.fn();
});

function MultiselectTestFilters({initialFilters, onChangeSpy}: Readonly<{
initialFilters: Filter<string>[];
// eslint-disable-next-line no-unused-vars
onChangeSpy: (filters: Filter<string>[]) => void;
}>) {
const [filters, setFilters] = useState<Filter<string>[]>(initialFilters);
const fields = useMemo<FilterFieldConfig<string>[]>(() => ([
{
key: 'label',
label: 'Label',
type: 'multiselect',
searchable: false,
operators: [{value: 'is-any', label: 'is any of'}],
defaultOperator: 'is-any',
options: [
{value: 'vip', label: 'VIP'},
{value: 'premium', label: 'Premium'},
{value: 'gold', label: 'Gold'}
]
}
]), []);

return (
<Filters
addButtonText="Add filter"
allowMultiple={true}
fields={fields}
filters={filters}
showSearchInput={false}
onChange={(next) => {
onChangeSpy(next);
setFilters(next);
}}
/>
);
}

it('commits a new single-value label filter and closes the picker after one selection', async () => {
const onChangeSpy = vi.fn();
const initial = [createFilter<string>('label', 'is-any', ['vip'])];

render(<MultiselectTestFilters initialFilters={initial} onChangeSpy={onChangeSpy} />);

fireEvent.click(screen.getByRole('button', {name: 'Add filter'}));

const labelMenuItem = await screen.findByRole('option', {name: 'Label'});
fireEvent.click(labelMenuItem);

const premiumOption = await screen.findByRole('option', {name: 'Premium'});
fireEvent.click(premiumOption);

await waitFor(() => {
const lastCall = onChangeSpy.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const finalFilters = lastCall![0] as Filter<string>[];
expect(finalFilters).toHaveLength(2);
expect(finalFilters[0].field).toBe('label');
expect(finalFilters[0].values).toEqual(['vip']);
expect(finalFilters[1].field).toBe('label');
expect(finalFilters[1].values).toEqual(['premium']);
});

// Picker should have closed β€” no more option role elements visible.
expect(screen.queryByRole('option', {name: 'Gold'})).toBeNull();
});
});
Loading