diff --git a/.changeset/29052-clear-filters-clears-chips.md b/.changeset/29052-clear-filters-clears-chips.md new file mode 100644 index 00000000000..538466173eb --- /dev/null +++ b/.changeset/29052-clear-filters-clears-chips.md @@ -0,0 +1,14 @@ +--- +'@object-ui/plugin-list': patch +--- + +feat(plugin-list): the list's empty-state "Clear filters" also clears the user-filter chips, so a list emptied by a chip alone offers it + +Clause-②: yes (widening) +Metadata-protocol: none + +A list emptied by a user-filter chip alone (the quick-filter chips above the grid) said "No records match your current filters or search" and offered only "Add record": the chip kept its selection inside `UserFilters`, out of the list's reach. It now offers "Clear filters" as its primary action, as a list emptied by the filter panel or the "In a queue" toggle does, and one click clears the chips with the rest of the reader's query. + +- **`UserFilters` takes an optional `ref`** that receives a `UserFiltersHandle` (exported from `@object-ui/plugin-list`) while a dropdown or toggle bar is mounted. Its `clear()` empties every chip, author defaults included, exactly as pressing each chip's × would: `onFilterChange` receives no conditions, and `onSelectionsChange` reports every chip field with an empty selection, so a host that persists selections per field (the console's `uf_*` URL params) drops each one. The selection stays the bar's own state, and the bar is not remounted. A host that passes no ref gets the bar unchanged. +- **Preset tabs are not cleared.** A tab is one of the view's presets, not a filter the reader added: tabs mode sets no handle, and a list a tab empties still offers no clear action. +- No metadata key, option or copy changes. diff --git a/ui/packages/plugin-list/src/ListEmptyState.tsx b/ui/packages/plugin-list/src/ListEmptyState.tsx index de6e9889de0..45817e76736 100644 --- a/ui/packages/plugin-list/src/ListEmptyState.tsx +++ b/ui/packages/plugin-list/src/ListEmptyState.tsx @@ -128,8 +128,8 @@ export interface ListEmptyStateProps { /** * Draws "Clear filters" as the primary action when supplied * (objectstack#28908): the caller hands it only while a filter the READER - * applied (the filter panel's conditions, the "In a queue" toggle) is part of - * the query that came back empty. It removes the reader's whole query, the + * applied (the filter panel's conditions, the user-filter chips, the "In a + * queue" toggle) is part of the query that came back empty. It removes the reader's whole query, the * search included, and never the view's declared filter. It is the one clear * action: supplied, it replaces "Clear search", and the add-record action * steps down to the secondary style. diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index cd90b22e97e..b08dd93f3ed 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -16,7 +16,7 @@ import { ViewSwitcherDropdown } from './ViewSwitcher'; import { ListEmptyState, hasDeclaredFilter, filterNamesCurrentUser } from './ListEmptyState'; import { ListViewCompactHeader, ListViewCompactRows, ListViewCappedRows, CAPPED_LIST_DEFAULT_LIMIT } from './ListViewCompact'; import { ViewSettingsPopover, type ViewSettingsRecordOpen } from './components/ViewSettingsPopover'; -import { UserFilters } from './UserFilters'; +import { UserFilters, type UserFiltersHandle } from './UserFilters'; import { calendarWindowCovers, calendarWindowFilter, @@ -2173,6 +2173,9 @@ export const ListView = React.forwardRef(({ // User Filters State (Airtable Interfaces-style) const [userFilterConditions, setUserFilterConditions] = React.useState([]); + // The mounted chip bar's handle: the empty state's "Clear filters" clears + // the chips through it (objectstack#29052). The selection stays the bar's. + const userFiltersRef = React.useRef(null); // The user filter this component APPLIES — its fetch, the export, the node of // a view that queries for itself, the empty-state copy. Empty while a @@ -3926,16 +3929,17 @@ export const ListView = React.forwardRef(({ }, [onSearchChange]); // objectstack#28908 — the empty state's "Clear filters": removes the - // reader's whole query (the panel's conditions, the "In a queue" toggle and - // the search) and leaves the view's declared filter alone, which is not the - // reader's. The panel's group goes through the same emit as a panel edit, so - // the toolbar badge clears and the host stores the empty group. User-filter - // chips are not reset here: `UserFilters` holds its own selection and each - // chip clears in place. + // reader's whole query (the panel's conditions, the user-filter chips, the + // "In a queue" toggle and the search) and leaves the view's declared filter + // alone, which is not the reader's. The panel's group goes through the same + // emit as a panel edit, so the toolbar badge clears and the host stores the + // empty group. The chips clear through the bar's own handle, which reports + // the cleared selection to the host as a chip's × does (objectstack#29052). const handleClearReaderQuery = React.useCallback(() => { const cleared: FilterBuilderRowGroup = { ...currentFilters, conditions: [] }; setCurrentFilters(cleared); onFilterChange?.(cleared); + userFiltersRef.current?.clear(); setSittingInQueueOnly(false); if (searchInput) handleSearchChange(''); }, [currentFilters, onFilterChange, searchInput, handleSearchChange]); @@ -5680,6 +5684,12 @@ export const ListView = React.forwardRef(({ const cappedPosture = cappedWindow && surfaceDrawsFetchedRows; // Either short posture draws none of the object list's chrome. const listChromeShown = !compactPosture && !cappedPosture; + // The user-filter bar is on screen (the toolbar draws it below). Its chips + // are the reader's filter, so the empty state's "Clear filters" counts them; + // preset tabs are the view's presets and are not cleared. + const userFilterBarShown = listChromeShown && !!filterElements && toolbarFlags.showUserFilters; + const userFilterChipsClearable = + userFilterBarShown && filterElements?.element !== 'tabs' && appliedUserFilterConditions.length > 0; // The records the query matched, once a load settles: the server's total, or // the rows in hand when the whole result is in memory. The capped list's // 「View all N」 and the tab badge (`onTotalChange`) both say this number. @@ -5905,9 +5915,10 @@ export const ListView = React.forwardRef(({ them visible (single line, scrollable) to match the Airtable Interface pattern. Withheld on a dataset-bound chart, which they cannot reach (`datasetChartOnScreen`, objectui#10327). */} - {filterElements && toolbarFlags.showUserFilters && ( + {filterElements && userFilterBarShown && (
(({ // emptied: the empty state offers the way out. onClearSearch={searchTerm && searchTerm.trim() ? () => handleSearchChange('') : undefined} // objectstack#28908 — a filter the reader applied (the panel's - // finished conditions, the queue toggle) is part of the empty - // query: the one clear action removes it, the search with it. + // finished conditions, the user-filter chips, the queue toggle) is + // part of the empty query: the one clear action removes it, the + // search with it. onClearFilters={ + userFilterChipsClearable || appliedQueueSittingConditions.length > 0 || (Array.isArray(appliedFilters?.conditions) && appliedFilterGroup(appliedFilters).conditions.length > 0) ? handleClearReaderQuery diff --git a/ui/packages/plugin-list/src/UserFilters.tsx b/ui/packages/plugin-list/src/UserFilters.tsx index e8a896622a1..aaba95c7dfe 100644 --- a/ui/packages/plugin-list/src/UserFilters.tsx +++ b/ui/packages/plugin-list/src/UserFilters.tsx @@ -200,6 +200,26 @@ export interface UserFiltersProps { * ObjectView/InterfaceListPage mirror them into `uf_*` URL params. */ onSelectionsChange?: (selections: Record>) => void; + /** + * Receives the bar's {@link UserFiltersHandle} while a dropdown or toggle + * bar is mounted (objectstack#29052). The selection stays the bar's own + * state; the handle only lets a host clear it, e.g. the list's empty-state + * "Clear filters". A host that passes no ref gets today's bar unchanged. + * Tabs mode sets no handle: a preset tab is a choice among the view's + * presets, not a filter the reader added. + */ + ref?: React.Ref; +} + +/** What a host can ask of a mounted user-filter bar (see `UserFiltersProps.ref`). */ +export interface UserFiltersHandle { + /** + * Clears every chip, author defaults included, exactly as pressing each + * chip's × would: `onFilterChange` receives no conditions, and + * `onSelectionsChange` reports every chip field with an empty selection, so + * a host that persists selections (the `uf_*` URL params) drops them all. + */ + clear(): void; } /** @@ -295,6 +315,7 @@ export function UserFilters({ className, initialSelections, onSelectionsChange, + ref, }: UserFiltersProps) { // The AUTHORING type (ADR-0053) only admits dropdown/tabs; stored metadata // still carries the spec-deprecated `toggle`, which must keep rendering @@ -312,6 +333,7 @@ export function UserFilters({ className={className} initialSelections={initialSelections} onSelectionsChange={onSelectionsChange} + handleRef={ref} /> ); case 'tabs': @@ -339,6 +361,7 @@ export function UserFilters({ objectDefPending={objectDefPending} onFilterChange={onFilterChange} className={className} + handleRef={ref} /> ); default: @@ -507,6 +530,7 @@ interface DropdownFiltersProps { className?: string; initialSelections?: Record>; onSelectionsChange?: (selections: Record>) => void; + handleRef?: React.Ref; } /** Selected values per field name, the dropdown bar's whole filter state. */ @@ -592,7 +616,7 @@ function typingOf(field: ResolvedField): string { return JSON.stringify([field.type, field.options.map(o => o.value)]); } -function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange }: DropdownFiltersProps) { +function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange, handleRef }: DropdownFiltersProps) { const { fieldLabel, translateOptions } = useSafeFieldLabel(); const moreLabel = useMoreLabel(); const tt = useSafeTranslate(); @@ -680,14 +704,29 @@ function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterCh [onFilterChange, controlKinds], ); - const handleChange = (field: string, values: (string | number | boolean)[]) => { - typingRef.current.set(field, null); - const next = { ...selectedValues, [field]: values }; + // A selection the reader made: final for typing, applied, and reported to + // the host for persistence. + const commitSelection = (next: DropdownSelection, changed: string[]) => { + changed.forEach(field => typingRef.current.set(field, null)); setSelectedValues(next); emitFilters(next); onSelectionsChange?.(next); }; + const handleChange = (field: string, values: (string | number | boolean)[]) => { + commitSelection({ ...selectedValues, [field]: values }, [field]); + }; + + // `clear()` names every chip field, held or not: a host that persists the + // selection per field drops only the fields a report names. + React.useImperativeHandle(handleRef, () => ({ + clear: () => { + const cleared: DropdownSelection = {}; + for (const field of [...fields.map(f => f.field), ...Object.keys(selectedValues)]) cleared[field] = []; + commitSelection(cleared, Object.keys(cleared)); + }, + })); + // Emit default/restored filters on mount, coerced to the option value types // (see `coerceToOptionTypes`) so the checkbox state and the query condition // read the same typed value. @@ -1288,9 +1327,10 @@ interface ToggleFiltersProps { objectDefPending: boolean; onFilterChange: (filters: any[]) => void; className?: string; + handleRef?: React.Ref; } -function ToggleFilters({ fields, objectDefPending, onFilterChange, className }: ToggleFiltersProps) { +function ToggleFilters({ fields, objectDefPending, onFilterChange, className, handleRef }: ToggleFiltersProps) { const [activeToggles, setActiveToggles] = React.useState>(() => { const defaults = new Set(); fields.forEach(f => { @@ -1322,6 +1362,14 @@ function ToggleFilters({ fields, objectDefPending, onFilterChange, className }: }); }; + React.useImperativeHandle(handleRef, () => ({ + clear: () => { + const none = new Set(); + setActiveToggles(none); + emitFilters(none); + }, + })); + // Emit default filters on mount React.useEffect(() => { if (activeToggles.size > 0) emitFilters(activeToggles); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.clearFiltersChips-29052.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.clearFiltersChips-29052.test.tsx new file mode 100644 index 00000000000..2b3b8c69cd5 --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ListView.clearFiltersChips-29052.test.tsx @@ -0,0 +1,179 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29052 — a list emptied by a user-filter chip offers the same + * "Clear filters" as one emptied by the filter panel, and the click clears + * the chip. + * + * Before, the chip-only empty list said "your current filters or search" and + * offered only "Add record": `UserFilters` kept its selection to itself, so the + * list could not clear it. + * + * Pinned: + * - a chip alone (restored from the host, or the author's default) empties + * the list: "Clear filters" leads; a click clears the chip, reports every + * chip field empty to the host (which drops its `uf_*` params), and the rows + * come back; + * - a chip and a panel condition together: one click clears both; + * - a preset tab is not the reader's filter: a list it empties offers no clear + * action. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup, fireEvent } from '@testing-library/react'; +import { ListView } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import type { ListViewSchema } from '@object-ui/types'; + +afterEach(cleanup); + +const OBJECT = 'crm_lead'; +const MISS = 'zzz'; + +/** One lead; any query carrying the reader's miss (`zzz`) answers zero rows. */ +function leadsDataSource() { + return { + find: vi.fn(async (_object: string, params: unknown) => + JSON.stringify(params ?? {}).includes(MISS) ? [] : [{ _id: 'l1', name: 'Acme', status: 'open' }], + ), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ + name: OBJECT, + fields: { + name: { name: 'name', type: 'text' }, + status: { name: 'status', type: 'text' }, + rating: { name: 'rating', type: 'text' }, + }, + }), + }; +} + +const session: MePermissionsResponse = { + authenticated: true, + userId: 'u1', + tenantId: 'org_1', + // No queue: the "In a queue" toggle stays off the toolbar. + hasAnyQueue: false, + positions: [], + permissionSets: ['member_default'], + objects: { [OBJECT]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + fields: {}, +}; + +const STATUS_CHIP = { field: 'status', options: [{ label: 'Miss', value: MISS }, { label: 'Open', value: 'open' }] }; +const RATING_CHIP = { field: 'rating', options: [{ label: 'Hot', value: 'hot' }] }; + +const authored = (node: Record = {}): ListViewSchema => + ({ type: 'list-view', objectName: OBJECT, fields: ['name'], addRecord: { enabled: true }, ...node }) as unknown as ListViewSchema; + +const chips = (...fields: unknown[]) => authored({ userFilters: { element: 'dropdown', fields } }); + +type HostProps = Pick< + React.ComponentProps, + 'initialFilters' | 'onFilterChange' | 'userFilterSelections' | 'onUserFilterSelectionsChange' +>; + +function mount(schema: ListViewSchema, props: HostProps = {}) { + const ds = leadsDataSource(); + const view = render( + + + {}} {...props} /> + + , + ); + return { ds, ...view }; +} + +const q = (root: ParentNode, id: string) => root.querySelector(`[data-testid="${id}"]`) as HTMLElement | null; +const isPrimary = (el: HTMLElement | null) => !!el && el.className.includes('bg-primary'); + +async function emptyPanel(container: HTMLElement): Promise { + await waitFor(() => expect(q(container, 'empty-state')).not.toBeNull()); + return q(container, 'empty-state')!; +} + +describe('ListView "Clear filters" clears the user-filter chips (objectstack#29052)', () => { + it('a restored chip alone: "Clear filters" leads, and a click clears the chip and brings the rows back', async () => { + const onUserFilterSelectionsChange = vi.fn(); + const { container, ds } = mount(chips(STATUS_CHIP, RATING_CHIP), { + userFilterSelections: { status: [MISS] }, + onUserFilterSelectionsChange, + }); + const panel = await emptyPanel(container); + + // The copy names filters, and now the page offers the action it names. + expect(panel.textContent).toMatch(/your current filters or search/i); + const clear = q(panel, 'empty-state-clear-filters'); + expect(clear?.textContent).toBe('Clear filters'); + expect(isPrimary(clear)).toBe(true); + expect(isPrimary(q(panel, 'empty-state-add-record'))).toBe(false); + expect(q(container, 'filter-clear-status')).not.toBeNull(); + + fireEvent.click(clear!); + + await waitFor(() => expect(q(container, 'empty-state')).toBeNull()); + expect(q(container, 'filter-clear-status')).toBeNull(); + expect(JSON.stringify(ds.find.mock.calls.at(-1))).not.toContain(MISS); + // Every chip field is reported empty, so the host drops each `uf_*` param. + expect(onUserFilterSelectionsChange).toHaveBeenLastCalledWith({ status: [], rating: [] }); + }); + + it('a chip the author defaulted is the reader\'s to clear too', async () => { + const { container } = mount(chips({ ...STATUS_CHIP, defaultValues: [MISS] })); + const panel = await emptyPanel(container); + + fireEvent.click(q(panel, 'empty-state-clear-filters')!); + + await waitFor(() => expect(q(container, 'empty-state')).toBeNull()); + expect(q(container, 'filter-clear-status')).toBeNull(); + }); + + it('a chip and a panel condition together: one click clears both', async () => { + const onFilterChange = vi.fn(); + const { container, ds } = mount(chips(STATUS_CHIP), { + userFilterSelections: { status: [MISS] }, + initialFilters: { + id: 'root', + logic: 'and', + conditions: [{ id: 'c1', field: 'rating', operator: 'equals', value: MISS }], + } as never, + onFilterChange, + }); + const panel = await emptyPanel(container); + + expect(panel.querySelectorAll('[data-testid^="empty-state-clear-"]')).toHaveLength(1); + fireEvent.click(q(panel, 'empty-state-clear-filters')!); + + await waitFor(() => expect(q(container, 'empty-state')).toBeNull()); + expect(onFilterChange).toHaveBeenLastCalledWith(expect.objectContaining({ conditions: [] })); + expect(q(container, 'filter-clear-status')).toBeNull(); + expect(JSON.stringify(ds.find.mock.calls.at(-1))).not.toContain(MISS); + }); + + it('a list emptied by a preset tab offers no clear action: the tab is the view\'s preset', async () => { + const { container } = mount( + authored({ + userFilters: { + element: 'tabs', + tabs: [{ id: 'miss', label: 'Miss', filters: [['status', '=', MISS]], default: true }], + }, + }), + ); + const panel = await emptyPanel(container); + + expect(q(container, 'filter-tab-miss')).not.toBeNull(); + expect(q(panel, 'empty-state-clear-filters')).toBeNull(); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.emptyStateClearFilters-28908.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.emptyStateClearFilters-28908.test.tsx index a48bfa73984..f60f344770c 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.emptyStateClearFilters-28908.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.emptyStateClearFilters-28908.test.tsx @@ -23,7 +23,8 @@ * "Add record"; * - a list emptied by the view's DECLARED filter alone shows no clear action: * that filter is not the reader's, and the action never removes it; - * - user-filter chips are not this action's: each chip clears in place. + * - user-filter chips are the reader's filter too since objectstack#29052: + * `ListView.clearFiltersChips-29052.test.tsx` pins them. */ import * as React from 'react'; @@ -88,7 +89,7 @@ const PANEL_MISS = { type Session = Pick< React.ComponentProps, - 'initialFilters' | 'initialSearchTerm' | 'onFilterChange' | 'onSearchChange' | 'userFilterSelections' + 'initialFilters' | 'initialSearchTerm' | 'onFilterChange' | 'onSearchChange' >; function mount(schema: ListViewSchema, props: Session = {}) { @@ -208,16 +209,4 @@ describe('ListView empty state — clear the reader\'s filters (objectstack#2890 expect(lastQuery).not.toContain(MISS); expect(lastQuery).toContain('closed'); }); - - it('a list emptied by a user-filter chip alone draws no clear action (the chip clears in place)', async () => { - const { container } = mount( - authored({ userFilters: { element: 'dropdown', fields: [{ field: 'status', options: [{ label: 'Miss', value: MISS }] }] } }), - { userFilterSelections: { status: [MISS] } }, - ); - const panel = await emptyPanel(container); - - expect(panel.textContent).toMatch(/your current filters or search/i); - expect(q(panel, 'empty-state-clear-filters')).toBeNull(); - expect(q(panel, 'empty-state-clear-search')).toBeNull(); - }); }); diff --git a/ui/packages/plugin-list/src/__tests__/UserFilters.clearHandle-29052.test.tsx b/ui/packages/plugin-list/src/__tests__/UserFilters.clearHandle-29052.test.tsx new file mode 100644 index 00000000000..3a587c5cd90 --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/UserFilters.clearHandle-29052.test.tsx @@ -0,0 +1,168 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#29052 — a host can clear the user-filter chips through the + * bar's handle, without remounting it. + * + * Pinned: + * - dropdown: `clear()` empties every chip, author defaults included; the bar + * emits no conditions and reports EVERY chip field empty, held or not, so a + * host that persists per field drops each one; + * - toggle (stored, spec-deprecated): `clear()` turns every toggle off; + * - tabs: no handle is set — a preset tab is not a filter the reader added; + * - a host that passes no ref gets today's bar: the restored selection, the + * chip's × clearing in place. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, act, cleanup } from '@testing-library/react'; +import { UserFilters, type UserFiltersHandle } from '../UserFilters'; + +afterEach(cleanup); + +const objectDef = { + name: 'tasks', + fields: { + status: { + type: 'select', + label: 'Status', + options: [ + { label: 'To Do', value: 'todo' }, + { label: 'Done', value: 'done' }, + ], + }, + priority: { + type: 'select', + label: 'Priority', + options: [ + { label: 'High', value: 'high' }, + { label: 'Low', value: 'low' }, + ], + }, + owner: { type: 'text', label: 'Owner' }, + }, +}; + +describe('UserFilters clear handle (objectstack#29052)', () => { + it('dropdown: clear() empties every chip and reports every chip field empty', () => { + const ref = React.createRef(); + const onFilterChange = vi.fn(); + const onSelectionsChange = vi.fn(); + render( + , + ); + expect(onFilterChange).toHaveBeenLastCalledWith([ + ['status', 'in', ['todo']], + ['priority', 'in', ['high']], + ]); + expect(screen.getByTestId('filter-clear-status')).toBeTruthy(); + expect(screen.getByTestId('filter-clear-priority')).toBeTruthy(); + + act(() => ref.current!.clear()); + + expect(onFilterChange).toHaveBeenLastCalledWith([]); + // `owner` held nothing and is reported all the same. + expect(onSelectionsChange).toHaveBeenLastCalledWith({ status: [], priority: [], owner: [] }); + expect(screen.queryByTestId('filter-clear-status')).toBeNull(); + expect(screen.queryByTestId('filter-clear-priority')).toBeNull(); + expect(screen.getByTestId('filter-badge-status').textContent).not.toMatch(/\d/); + }); + + it('dropdown: a chip picked after a clear applies on its own', () => { + const ref = React.createRef(); + const onFilterChange = vi.fn(); + render( + , + ); + + act(() => ref.current!.clear()); + fireEvent.click(screen.getByTestId('filter-badge-priority')); + fireEvent.click(screen.getByText('High')); + + expect(onFilterChange).toHaveBeenLastCalledWith([['priority', 'in', ['high']]]); + }); + + it('toggle: clear() turns every toggle off', () => { + const ref = React.createRef(); + const onFilterChange = vi.fn(); + render( + , + ); + expect(onFilterChange).toHaveBeenLastCalledWith([['status', 'in', ['todo']]]); + + act(() => ref.current!.clear()); + + expect(onFilterChange).toHaveBeenLastCalledWith([]); + }); + + it('tabs: no handle is set', () => { + const ref = React.createRef(); + render( + {}} + />, + ); + + expect(screen.getByTestId('filter-tab-mine')).toBeTruthy(); + expect(ref.current).toBeNull(); + }); + + it('without a ref the bar behaves as before: restored selection, the chip × clears in place', () => { + const onFilterChange = vi.fn(); + const onSelectionsChange = vi.fn(); + render( + , + ); + expect(onFilterChange).toHaveBeenLastCalledWith([['status', 'in', ['todo']]]); + expect(onSelectionsChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('filter-clear-status')); + + expect(onFilterChange).toHaveBeenLastCalledWith([]); + expect(onSelectionsChange).toHaveBeenLastCalledWith({ status: [] }); + }); +}); diff --git a/ui/packages/plugin-list/src/index.tsx b/ui/packages/plugin-list/src/index.tsx index ec081cb67f5..150cfb6066c 100644 --- a/ui/packages/plugin-list/src/index.tsx +++ b/ui/packages/plugin-list/src/index.tsx @@ -18,7 +18,7 @@ export { ViewSwitcherDropdown } from './ViewSwitcher'; export { TabBar, TabBarSelect } from './components/TabBar'; export type { TabBarProps, ViewTab } from './components/TabBar'; export { UserFilters } from './UserFilters'; -export type { UserFiltersProps } from './UserFilters'; +export type { UserFiltersProps, UserFiltersHandle } from './UserFilters'; export { evaluateConditionalFormatting, normalizeFilterCondition, normalizeFilters } from './ListView'; // The provider-less fallback table, exported so objectui#4401's mirror gate can // compare it against the `en` pack from a package that depends on both.