Skip to content

Commit 7a221ac

Browse files
feat(plugin-list): the empty-state "Clear filters" also clears the user-filter chips (#29189)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb7Lo3k5A85iWVZxQVoCv6Ht)_ Fixes #29052 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:列表只被用户筛选标签(列表上方的快速筛选 chips)筛空时,空状态现在也给出「清除筛选」主按钮,和被筛选面板、「在队列中」开关筛空时一致;点一下连同面板条件、队列开关、搜索词一起把标签清掉,记录回来,地址栏里的 `uf_*` 参数也一并清掉,刷新后不会再回来。`UserFilters` 新增一个可选的 `ref`(导出类型 `UserFiltersHandle`,只有 `clear()` 一个方法),不传 ref 的宿主行为完全不变。预设标签页(tabs)不算读者的筛选,不清。 - **为什么改**:#28908 落地后,被标签单独筛空的列表文案仍说「试试调整或清除筛选」,按钮却只有「添加记录」;原因是标签的选择状态藏在组件内部,列表无法从外部清除,又不允许用 key 重挂载。 - **风险与回滚**:低。选择状态仍归 `UserFilters` 自己,不重挂载;`clear()` 等同于逐个点每个标签的 ×,走同一条 `onFilterChange`/`onSelectionsChange` 路径,宿主按字段删除 URL 参数,宿主代码未改。回滚即回退本 PR。 - **先看**:`ui/packages/plugin-list/src/UserFilters.tsx`(`UserFiltersHandle` 与 `DropdownFilters` 里的 `commitSelection`/`useImperativeHandle`);`ui/packages/plugin-list/src/ListView.tsx`(`handleClearReaderQuery` 与 `userFilterChipsClearable`);新测试 `ListView.clearFiltersChips-29052.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-list/src/__tests__/ListView.clearFiltersChips-29052.test.tsx`,预期 4 个用例全部通过;第一个用例断言主按钮是「Clear filters」、点击后标签的 × 消失、空状态消失、宿主收到 `{ status: [], rating: [] }`。 - **需要你做的**:无,席位落地。 ## What changes **`UserFilters` can be cleared from outside, without a remount.** It takes an optional `ref` (React 19 ref-as-prop) that receives a `UserFiltersHandle` while a dropdown or toggle bar is mounted; the type is exported from `@object-ui/plugin-list` beside `UserFiltersProps`. Its one method, `clear()`, empties every chip, author `defaultValues` included, exactly as pressing each chip's × would: - dropdown: the selection becomes empty for every chip field, `onFilterChange` receives no conditions, and `onSelectionsChange` reports **every chip field** with an empty selection, held or not. The per-chip × path and `clear()` now share one commit helper (`commitSelection`), so typing finality, the emit and the host report cannot drift apart; - toggle (stored, spec-deprecated): every toggle turns off and no conditions are emitted; - tabs: no handle is set. A preset tab is one of the view's presets, not a filter the reader added. The selection stays the bar's own state. A host that passes no ref gets today's bar unchanged (pinned below). No key remount (ui/AGENTS.md #8), no new metadata key or option. **`ListView`'s "Clear filters" covers the chips.** `handleClearReaderQuery` (from #28908) now also calls the bar's `clear()`. The empty state offers the action when the chip bar is on screen, is not in tabs mode, and its conditions are part of the applied query (`userFilterChipsClearable`), beside the existing panel and queue-toggle arms. So a list emptied by a chip alone now shows "Clear filters" as the primary action and "Add record" as the secondary, and the existing copy ("No records match your current filters or search. Try adjusting or clearing them.") now matches the action offered. No copy changed. **What the host persists.** Measured by reading the hosts on `origin/main` `2af0ae2a`: all three hosts (`objectViewListUrlState.ts` `handleUserFilterSelectionsChange`, `InterfaceListPage`, `ObjectDataPage`) write the report through `applyUserFilterParams`, which deletes `uf_FIELD` only for a field the report names with an empty selection; a field the report omits keeps its param. That is why `clear()` reports every chip field rather than `{}`: a `{}` report would leave every `uf_*` param in place, and the chips would come back on the next seed. With every field named, the existing host path drops them all, so no host file changed. ## Tests - `ui/packages/plugin-list/src/__tests__/ListView.clearFiltersChips-29052.test.tsx` (new), 4 cases: a chip restored from the host empties the list, "Clear filters" leads, the click clears the chip, the next fetch carries no miss, the rows come back and the host receives every chip field empty; an author-defaulted chip clears the same way; a chip and a panel condition together clear in one click; a list emptied by a preset tab offers no clear action. - `ui/packages/plugin-list/src/__tests__/UserFilters.clearHandle-29052.test.tsx` (new), 5 cases: dropdown `clear()` (defaults included, unheld fields reported); a chip picked after a clear applies alone; toggle `clear()`; tabs set no handle; without a ref the bar restores its selection and the chip × clears in place as before. - `ListView.emptyStateClearFilters-28908.test.tsx`: its last case pinned the chip-only list offering no clear action, the behaviour this card changes; it is removed and the header points at the new file, which pins the replacement. ## Acceptance notes - **Preset tabs stay out of the clear.** A list emptied by a preset tab still reads "your current filters or search" with no clear action, as before this PR. Clearing a preset has no empty state of its own (with `showAllRecords: false` there is no unfiltered tab), so it is left to the view's presets. Seat 1's #28950 (branch `claude/project-thread-o05ndx`, unmerged at writing) counts the tab's conditions among its "filters" personalization part and resets them through its own `uf_*` strip and remount; the two paths do not touch each other. - **Mechanism: a handle, not a controlled `selections` prop.** The card allowed either. A controlled selection would need the host to know the bar's effective selection, which it does not: author defaults, chips derived after the definition loads, and the late type coercion all settle inside the bar and are deliberately not echoed to the host. A host clearing to `{}` would then miss a default-only chip. The handle clears whatever the bar holds, and the declaration face still widens (one optional prop, one exported type), so the claim's `Clause-②: yes (widening)` stands. - No docs page or README describes `UserFilters`' host props (`git grep` of `onSelectionsChange`, `initialSelections` and `UserFiltersProps` over `content/` and the package README: zero hits), so none is updated. ## Verification Readings on `809862b9` (the change). `77aa59cd` merges `origin/main` (clean; the incoming commits touch no plugin-list file and none of the `uf_*` host files, so no re-check was owed by AGENTS.md multi-agent §10). <details><summary>Tests</summary> - New files and the touched #28908 file plus `UserFilters.test.tsx`: 4 files, 31/31 passed. - `packages/plugin-list` in full, in two locked batches at `--maxWorkers=2`: 92 files 659 tests, and 92 files 1098 tests, all passed (184 files). - Host consumers (`git grep -l` of `UserFilters`, `ListEmptyState`, `empty-state-clear-filters`, `filter-clear-`, `userFilterSelections`, `onUserFilterSelectionsChange` outside plugin-list: app-shell `ObjectDataPage.relationUserFilters-13310`, `ObjectView.ufViewSwitch-11992`, `userFilterUrlState.listState-11860`, plugin-grid's two empty-state files, plugin-form, plugin-view, types' two parity files): 9 files, 387 tests passed, after building the app-shell closure (64/64 tasks). - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed. - Ablation (one-time, not kept): `scripts/ablation-replace.mjs` replaced `userFiltersRef.current?.clear();` in `handleClearReaderQuery` with a no-op (`void userFiltersRef;`). The new ListView file went 3 failed / 1 passed (the three chip-clear cases red; the preset-tab case green, as it pins no clear). The restore leg read blob == HEAD and an empty `git diff HEAD`. </details> <details><summary>Type-check, lint</summary> - `turbo run type-check --filter=@object-ui/plugin-list`: 50/50 tasks (it covers the test files through `tsconfig.test.json`). - eslint on every changed source and test file: 0 errors (warnings only: two `as any` data-source casts in the new ListView test, the pattern the #28908 file uses, and pre-existing ones). - `node scripts/check-sdui-manifest.mjs`: exit 0 (no component config changed). - `pnpm check:new-line-citations` under `ui/`: 0 new citations. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --ran`: 34 derived families accounted for, 34 run, 0 NOT MEASURED, every one exit 0 (`check:type-check-debt` under the verify lock). The PR-body gates (`check-changeset-no-major`, `check-closing-keyword-parity`, `check-partof-closing-keyword`, `check-adr-0087-registration`, `check-empty-changeset`) ran on this body: exit 0 on each. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jULUUCcok2FGYwt6Gh --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jULUUCcok2FGYwt6Gh)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 817cfa4 commit 7a221ac

8 files changed

Lines changed: 443 additions & 32 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-list': patch
3+
---
4+
5+
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
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
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.
11+
12+
- **`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.
13+
- **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.
14+
- No metadata key, option or copy changes.

‎ui/packages/plugin-list/src/ListEmptyState.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,8 @@ export interface ListEmptyStateProps {
128128
/**
129129
* Draws "Clear filters" as the primary action when supplied
130130
* (objectstack#28908): the caller hands it only while a filter the READER
131-
* applied (the filter panel's conditions, the "In a queue" toggle) is part of
132-
* the query that came back empty. It removes the reader's whole query, the
131+
* applied (the filter panel's conditions, the user-filter chips, the "In a
132+
* queue" toggle) is part of the query that came back empty. It removes the reader's whole query, the
133133
* search included, and never the view's declared filter. It is the one clear
134134
* action: supplied, it replaces "Clear search", and the add-record action
135135
* steps down to the secondary style.

‎ui/packages/plugin-list/src/ListView.tsx‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { ViewSwitcherDropdown } from './ViewSwitcher';
1616
import { ListEmptyState, hasDeclaredFilter, filterNamesCurrentUser } from './ListEmptyState';
1717
import { ListViewCompactHeader, ListViewCompactRows, ListViewCappedRows, CAPPED_LIST_DEFAULT_LIMIT } from './ListViewCompact';
1818
import { ViewSettingsPopover, type ViewSettingsRecordOpen } from './components/ViewSettingsPopover';
19-
import { UserFilters } from './UserFilters';
19+
import { UserFilters, type UserFiltersHandle } from './UserFilters';
2020
import {
2121
calendarWindowCovers,
2222
calendarWindowFilter,
@@ -2174,6 +2174,9 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
21742174

21752175
// User Filters State (Airtable Interfaces-style)
21762176
const [userFilterConditions, setUserFilterConditions] = React.useState<any[]>([]);
2177+
// The mounted chip bar's handle: the empty state's "Clear filters" clears
2178+
// the chips through it (objectstack#29052). The selection stays the bar's.
2179+
const userFiltersRef = React.useRef<UserFiltersHandle>(null);
21772180

21782181
// The user filter this component APPLIES — its fetch, the export, the node of
21792182
// a view that queries for itself, the empty-state copy. Empty while a
@@ -3927,16 +3930,17 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
39273930
}, [onSearchChange]);
39283931

39293932
// objectstack#28908 — the empty state's "Clear filters": removes the
3930-
// reader's whole query (the panel's conditions, the "In a queue" toggle and
3931-
// the search) and leaves the view's declared filter alone, which is not the
3932-
// reader's. The panel's group goes through the same emit as a panel edit, so
3933-
// the toolbar badge clears and the host stores the empty group. User-filter
3934-
// chips are not reset here: `UserFilters` holds its own selection and each
3935-
// chip clears in place.
3933+
// reader's whole query (the panel's conditions, the user-filter chips, the
3934+
// "In a queue" toggle and the search) and leaves the view's declared filter
3935+
// alone, which is not the reader's. The panel's group goes through the same
3936+
// emit as a panel edit, so the toolbar badge clears and the host stores the
3937+
// empty group. The chips clear through the bar's own handle, which reports
3938+
// the cleared selection to the host as a chip's × does (objectstack#29052).
39363939
const handleClearReaderQuery = React.useCallback(() => {
39373940
const cleared: FilterBuilderRowGroup = { ...currentFilters, conditions: [] };
39383941
setCurrentFilters(cleared);
39393942
onFilterChange?.(cleared);
3943+
userFiltersRef.current?.clear();
39403944
setSittingInQueueOnly(false);
39413945
if (searchInput) handleSearchChange('');
39423946
}, [currentFilters, onFilterChange, searchInput, handleSearchChange]);
@@ -5676,6 +5680,12 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
56765680
const cappedPosture = cappedWindow && surfaceDrawsFetchedRows;
56775681
// Either short posture draws none of the object list's chrome.
56785682
const listChromeShown = !compactPosture && !cappedPosture;
5683+
// The user-filter bar is on screen (the toolbar draws it below). Its chips
5684+
// are the reader's filter, so the empty state's "Clear filters" counts them;
5685+
// preset tabs are the view's presets and are not cleared.
5686+
const userFilterBarShown = listChromeShown && !!filterElements && toolbarFlags.showUserFilters;
5687+
const userFilterChipsClearable =
5688+
userFilterBarShown && filterElements?.element !== 'tabs' && appliedUserFilterConditions.length > 0;
56795689
// The records the query matched, once a load settles: the server's total, or
56805690
// the rows in hand when the whole result is in memory. The capped list's
56815691
// 「View all N」 and the tab badge (`onTotalChange`) both say this number.
@@ -5901,9 +5911,10 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
59015911
them visible (single line, scrollable) to match the Airtable
59025912
Interface pattern. Withheld on a dataset-bound chart, which they
59035913
cannot reach (`datasetChartOnScreen`, objectui#10327). */}
5904-
{filterElements && toolbarFlags.showUserFilters && (
5914+
{filterElements && userFilterBarShown && (
59055915
<div className="shrink-0 min-w-0 overflow-x-auto" data-testid="user-filters">
59065916
<UserFilters
5917+
ref={userFiltersRef}
59075918
config={filterElements}
59085919
objectDef={objectDef}
59095920
objectDefPending={!objectDefLoaded}
@@ -6817,9 +6828,11 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
68176828
// emptied: the empty state offers the way out.
68186829
onClearSearch={searchTerm && searchTerm.trim() ? () => handleSearchChange('') : undefined}
68196830
// objectstack#28908 — a filter the reader applied (the panel's
6820-
// finished conditions, the queue toggle) is part of the empty
6821-
// query: the one clear action removes it, the search with it.
6831+
// finished conditions, the user-filter chips, the queue toggle) is
6832+
// part of the empty query: the one clear action removes it, the
6833+
// search with it.
68226834
onClearFilters={
6835+
userFilterChipsClearable ||
68236836
appliedQueueSittingConditions.length > 0 ||
68246837
(Array.isArray(appliedFilters?.conditions) && appliedFilterGroup(appliedFilters).conditions.length > 0)
68256838
? handleClearReaderQuery

‎ui/packages/plugin-list/src/UserFilters.tsx‎

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,26 @@ export interface UserFiltersProps {
200200
* ObjectView/InterfaceListPage mirror them into `uf_*` URL params.
201201
*/
202202
onSelectionsChange?: (selections: Record<string, Array<string | number | boolean>>) => void;
203+
/**
204+
* Receives the bar's {@link UserFiltersHandle} while a dropdown or toggle
205+
* bar is mounted (objectstack#29052). The selection stays the bar's own
206+
* state; the handle only lets a host clear it, e.g. the list's empty-state
207+
* "Clear filters". A host that passes no ref gets today's bar unchanged.
208+
* Tabs mode sets no handle: a preset tab is a choice among the view's
209+
* presets, not a filter the reader added.
210+
*/
211+
ref?: React.Ref<UserFiltersHandle>;
212+
}
213+
214+
/** What a host can ask of a mounted user-filter bar (see `UserFiltersProps.ref`). */
215+
export interface UserFiltersHandle {
216+
/**
217+
* Clears every chip, author defaults included, exactly as pressing each
218+
* chip's × would: `onFilterChange` receives no conditions, and
219+
* `onSelectionsChange` reports every chip field with an empty selection, so
220+
* a host that persists selections (the `uf_*` URL params) drops them all.
221+
*/
222+
clear(): void;
203223
}
204224

205225
/**
@@ -295,6 +315,7 @@ export function UserFilters({
295315
className,
296316
initialSelections,
297317
onSelectionsChange,
318+
ref,
298319
}: UserFiltersProps) {
299320
// The AUTHORING type (ADR-0053) only admits dropdown/tabs; stored metadata
300321
// still carries the spec-deprecated `toggle`, which must keep rendering
@@ -312,6 +333,7 @@ export function UserFilters({
312333
className={className}
313334
initialSelections={initialSelections}
314335
onSelectionsChange={onSelectionsChange}
336+
handleRef={ref}
315337
/>
316338
);
317339
case 'tabs':
@@ -339,6 +361,7 @@ export function UserFilters({
339361
objectDefPending={objectDefPending}
340362
onFilterChange={onFilterChange}
341363
className={className}
364+
handleRef={ref}
342365
/>
343366
);
344367
default:
@@ -507,6 +530,7 @@ interface DropdownFiltersProps {
507530
className?: string;
508531
initialSelections?: Record<string, Array<string | number | boolean>>;
509532
onSelectionsChange?: (selections: Record<string, Array<string | number | boolean>>) => void;
533+
handleRef?: React.Ref<UserFiltersHandle>;
510534
}
511535

512536
/** Selected values per field name, the dropdown bar's whole filter state. */
@@ -592,7 +616,7 @@ function typingOf(field: ResolvedField): string {
592616
return JSON.stringify([field.type, field.options.map(o => o.value)]);
593617
}
594618

595-
function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange }: DropdownFiltersProps) {
619+
function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterChange, maxVisible, className, initialSelections, onSelectionsChange, handleRef }: DropdownFiltersProps) {
596620
const { fieldLabel, translateOptions } = useSafeFieldLabel();
597621
const moreLabel = useMoreLabel();
598622
const tt = useSafeTranslate();
@@ -680,14 +704,29 @@ function DropdownFilters({ fields, objectDef, objectDefPending, data, onFilterCh
680704
[onFilterChange, controlKinds],
681705
);
682706

683-
const handleChange = (field: string, values: (string | number | boolean)[]) => {
684-
typingRef.current.set(field, null);
685-
const next = { ...selectedValues, [field]: values };
707+
// A selection the reader made: final for typing, applied, and reported to
708+
// the host for persistence.
709+
const commitSelection = (next: DropdownSelection, changed: string[]) => {
710+
changed.forEach(field => typingRef.current.set(field, null));
686711
setSelectedValues(next);
687712
emitFilters(next);
688713
onSelectionsChange?.(next);
689714
};
690715

716+
const handleChange = (field: string, values: (string | number | boolean)[]) => {
717+
commitSelection({ ...selectedValues, [field]: values }, [field]);
718+
};
719+
720+
// `clear()` names every chip field, held or not: a host that persists the
721+
// selection per field drops only the fields a report names.
722+
React.useImperativeHandle(handleRef, () => ({
723+
clear: () => {
724+
const cleared: DropdownSelection = {};
725+
for (const field of [...fields.map(f => f.field), ...Object.keys(selectedValues)]) cleared[field] = [];
726+
commitSelection(cleared, Object.keys(cleared));
727+
},
728+
}));
729+
691730
// Emit default/restored filters on mount, coerced to the option value types
692731
// (see `coerceToOptionTypes`) so the checkbox state and the query condition
693732
// read the same typed value.
@@ -1288,9 +1327,10 @@ interface ToggleFiltersProps {
12881327
objectDefPending: boolean;
12891328
onFilterChange: (filters: any[]) => void;
12901329
className?: string;
1330+
handleRef?: React.Ref<UserFiltersHandle>;
12911331
}
12921332

1293-
function ToggleFilters({ fields, objectDefPending, onFilterChange, className }: ToggleFiltersProps) {
1333+
function ToggleFilters({ fields, objectDefPending, onFilterChange, className, handleRef }: ToggleFiltersProps) {
12941334
const [activeToggles, setActiveToggles] = React.useState<Set<string>>(() => {
12951335
const defaults = new Set<string>();
12961336
fields.forEach(f => {
@@ -1322,6 +1362,14 @@ function ToggleFilters({ fields, objectDefPending, onFilterChange, className }:
13221362
});
13231363
};
13241364

1365+
React.useImperativeHandle(handleRef, () => ({
1366+
clear: () => {
1367+
const none = new Set<string>();
1368+
setActiveToggles(none);
1369+
emitFilters(none);
1370+
},
1371+
}));
1372+
13251373
// Emit default filters on mount
13261374
React.useEffect(() => {
13271375
if (activeToggles.size > 0) emitFilters(activeToggles);

0 commit comments

Comments
 (0)