Skip to content

Commit bfe412a

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat: ADR-0047 — persist user-filter selections in the URL (#1681)
* feat: ADR-0047 — persist user-filter selections in the URL End-user filter selections (dropdown values + active filter tab) now mirror into uf_* search params and restore on load, so filtered lists survive a reload and are shareable as links (Airtable Interfaces parity): - UserFilters: initialSelections prop restores dropdown values (typed coercion against resolved option values) and the active tab preset (_tab key); onSelectionsChange reports raw selections on every change. - ListView: forwards both props; the TabBar path (metadata view tabs) restores the active tab and applies its filter to the first fetch. - ObjectView/InterfaceListPage: parse uf_* params once at mount, write back with history replace. The url-filters memo now keys off the filter[...] entries only so uf_* writes don't rebuild the schema. Browser-verified on app-showcase: workbench dropdown (?uf_priority=urgent) and data-mode tab (?uf__tab=urgent) both survive reload with correct badge/tab state and filtered results. plugin-list: 135/135. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: InterfaceListPage infinite refetch loop + ADR-0047 live e2e suite The hollow-view hydration effect keyed on objectDef/resolvedView object identities, which are rebuilt every render (useMetadata().objects is a fresh array each time). Any re-render after hydration settled — e.g. the new uf_* URL write — made the unconditional setHydratedView(null) ping-pong with the async setHydratedView(full): an infinite render + refetch loop (~60 req/s). Deps are now scalars (object name, view key, hollow flag) and the schema memo keys on the serialized view config, so parent re-renders no longer refetch. Adds e2e/live/user-filters.spec.ts locking the ADR-0047 surfaces: tabs XOR dropdowns, uf__tab/uf_<field> URL persistence across reload, visualization dropdown whitelist, interface-page lockdown, and the counts snapshot — this suite is what caught the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 74ef2ed commit bfe412a

7 files changed

Lines changed: 470 additions & 25 deletions

File tree

e2e/live/user-filters.spec.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* ADR-0047 — end-user filter surfaces, locked end-to-end.
5+
*
6+
* Covers the behaviors hand-verified during the ADR-0047 rounds so they
7+
* cannot silently regress:
8+
*
9+
* 1. Data mode (showcase_task "All Tasks"): metadata filter TABS render and
10+
* dropdowns are suppressed (tabs XOR dropdowns); picking a tab filters
11+
* the grid and persists as `uf__tab` in the URL; reload restores it.
12+
* 2. Data mode visualization switcher: a compact dropdown in the toolbar's
13+
* right cluster (single row), offering only the whitelist.
14+
* 3. Interface mode (Task Workbench page): author-enabled dropdown filters
15+
* only — no view tabs, no visualization switcher (single-entry
16+
* whitelist = locked); selecting a value filters rows, persists as
17+
* `uf_<field>`, survives reload; per-option counts do NOT zero out
18+
* while the selection is active (counts snapshot).
19+
*/
20+
21+
test.describe('ADR-0047 data mode — filter tabs + viz dropdown', () => {
22+
test('filter tabs render exclusively, persist to URL, and restore on reload', async ({ page }) => {
23+
await page.goto('/apps/showcase_app/showcase_task');
24+
25+
// The default "All Tasks" view configures metadata tabs — they render…
26+
// (TabBar renders role="tab" with stable view-tab-<name> testids)
27+
const urgentTab = page.getByTestId('view-tab-urgent');
28+
await expect(urgentTab).toBeVisible();
29+
// …and dropdown filter badges do not (tabs XOR dropdowns).
30+
await expect(page.getByTestId('filter-badge-status')).toHaveCount(0);
31+
32+
// Picking a tab filters the grid and mirrors into the URL.
33+
await urgentTab.click();
34+
await expect(page).toHaveURL(/uf__tab=urgent/);
35+
await expect(page.getByTestId('record-count-bar')).toContainText(/^2 /, { timeout: 15000 });
36+
37+
// Reload: the tab selection and its filter survive.
38+
await page.reload();
39+
await expect(page).toHaveURL(/uf__tab=urgent/);
40+
await expect(page.getByTestId('record-count-bar')).toContainText(/^2 /, { timeout: 15000 });
41+
});
42+
43+
test('visualization switcher is a single dropdown offering the whitelist', async ({ page }) => {
44+
await page.goto('/apps/showcase_app/showcase_task');
45+
46+
const trigger = page.getByTestId('view-switcher-dropdown');
47+
await expect(trigger).toBeVisible();
48+
await trigger.click();
49+
50+
// Whitelist: grid, kanban, gallery, calendar — and nothing else.
51+
await expect(page.getByRole('button', { name: 'Kanban' })).toBeVisible();
52+
await expect(page.getByRole('button', { name: 'Gallery' })).toBeVisible();
53+
await expect(page.getByRole('button', { name: 'Calendar' })).toBeVisible();
54+
await expect(page.getByRole('button', { name: 'Timeline' })).toHaveCount(0);
55+
56+
// Switching actually swaps the renderer (kanban board appears).
57+
await page.getByRole('button', { name: 'Kanban' }).click();
58+
await expect(trigger).toContainText('Kanban');
59+
});
60+
});
61+
62+
test.describe('ADR-0047 interface mode — Task Workbench', () => {
63+
test('locked surface: dropdowns only, no view tabs, no switcher', async ({ page }) => {
64+
await page.goto('/apps/showcase_app/page/showcase_task_workbench');
65+
66+
await expect(page.getByTestId('interface-list-page')).toBeVisible();
67+
await expect(page.getByTestId('filter-badge-status')).toBeVisible();
68+
await expect(page.getByTestId('filter-badge-priority')).toBeVisible();
69+
// Single-entry visualization whitelist renders no switcher.
70+
await expect(page.getByTestId('view-switcher-dropdown')).toHaveCount(0);
71+
});
72+
73+
test('dropdown selection filters, keeps counts, persists, and restores', async ({ page }) => {
74+
await page.goto('/apps/showcase_app/page/showcase_task_workbench');
75+
76+
// Open the priority dropdown (showCount: true) and capture pre-selection state.
77+
await page.getByTestId('filter-badge-priority').click();
78+
const options = page.getByTestId('filter-options-priority');
79+
await expect(options).toBeVisible();
80+
await expect(options.getByText('Urgent')).toBeVisible();
81+
82+
// Select Urgent: the grid narrows and the URL picks up the selection.
83+
await options.getByText('Urgent').click();
84+
await expect(page).toHaveURL(/uf_priority=urgent/);
85+
await expect(page.getByTestId('record-count-bar')).toContainText(/^2 /, { timeout: 15000 });
86+
87+
// Counts snapshot: with Urgent active the OTHER options keep their
88+
// pre-selection counts instead of collapsing to 0 (the server already
89+
// filtered the rows; the popover replays the snapshot).
90+
const optionRows = options.locator('label');
91+
const texts = await optionRows.allTextContents();
92+
const nonUrgent = texts.filter(t => !/Urgent/.test(t));
93+
expect(nonUrgent.length).toBeGreaterThan(0);
94+
// Every non-selected option still shows a non-zero count.
95+
for (const t of nonUrgent) {
96+
expect(t).not.toMatch(/\b0\b/);
97+
}
98+
99+
// Reload: badge selection state and the filtered result set survive.
100+
await page.reload();
101+
await expect(page).toHaveURL(/uf_priority=urgent/);
102+
await expect(page.getByTestId('filter-badge-priority')).toContainText('1');
103+
await expect(page.getByTestId('record-count-bar')).toContainText(/^2 /, { timeout: 15000 });
104+
});
105+
});

packages/app-shell/src/views/InterfaceListPage.tsx

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@
1616
*/
1717

1818
import * as React from 'react';
19+
import { useSearchParams } from 'react-router-dom';
1920
import { ListView } from '@object-ui/plugin-list';
2021
import { useAdapter } from '@object-ui/react';
2122
import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components';
2223
import { Database } from 'lucide-react';
2324
import { useObjectTranslation } from '@object-ui/i18n';
2425
import { useMetadata } from '../providers/MetadataProvider';
26+
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
2527

2628
interface InterfaceListPageProps {
2729
page: any;
@@ -53,6 +55,19 @@ export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
5355
const { t } = useObjectTranslation();
5456
const { objects } = useMetadata();
5557
const dataSource = useAdapter();
58+
const [, setSearchParams] = useSearchParams();
59+
60+
// ADR-0047 filter persistence: restore `uf_*` URL params once at mount,
61+
// mirror every selection change back (replace — no history spam).
62+
const [initialUfSelections] = React.useState<Record<string, string[]> | undefined>(
63+
() => parseUserFilterParams(new URLSearchParams(window.location.search)),
64+
);
65+
const handleUserFilterSelectionsChange = React.useCallback(
66+
(selections: Record<string, Array<string | number | boolean>>) => {
67+
setSearchParams(prev => applyUserFilterParams(prev, selections), { replace: true });
68+
},
69+
[setSearchParams],
70+
);
5671

5772
const cfg = page?.interfaceConfig || {};
5873
const objectDef = React.useMemo(
@@ -68,36 +83,48 @@ export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
6883
// the full body lives behind the per-view overlay API — the same
6984
// hydration ObjectView performs. Only fetch when the resolution came up
7085
// hollow.
86+
//
87+
// IMPORTANT: the deps are SCALARS, not the objectDef/resolvedView object
88+
// identities. `useMetadata().objects` is rebuilt per render, so identity
89+
// deps re-fire this effect on every render — and the unconditional
90+
// `setHydratedView(null)` then ping-pongs with the async `setHydratedView
91+
// (full)` into an infinite render/refetch loop the moment anything (e.g.
92+
// a `uf_*` URL write) re-renders this component after hydration settled.
93+
const objectDefName: string | undefined = objectDef?.name;
94+
const resolvedViewHollow = !!resolvedView && !resolvedView.columns;
95+
const resolvedViewKey = resolvedView?.name
96+
?? (cfg.sourceView ? `${cfg.source}.${cfg.sourceView}` : undefined);
7197
const [hydratedView, setHydratedView] = React.useState<any>(null);
7298
React.useEffect(() => {
7399
let cancelled = false;
74100
setHydratedView(null);
75-
if (!objectDef || !cfg.source || resolvedView?.columns) return;
76-
const viewKey = resolvedView?.name
77-
?? (cfg.sourceView ? `${cfg.source}.${cfg.sourceView}` : undefined);
78-
if (!viewKey) return;
101+
if (!objectDefName || !cfg.source || !resolvedViewHollow || !resolvedViewKey) return;
79102
(async () => {
80103
try {
81104
const ds: any = dataSource;
82105
let full: any = null;
83106
if (typeof ds?.listViewOverrides === 'function') {
84107
const all = await ds.listViewOverrides(cfg.source);
85-
full = all?.[viewKey] ?? null;
108+
full = all?.[resolvedViewKey] ?? null;
86109
}
87110
if (!full?.columns && typeof ds?.getView === 'function') {
88-
full = await ds.getView(cfg.source, viewKey);
111+
full = await ds.getView(cfg.source, resolvedViewKey);
89112
}
90113
if (!cancelled && full && typeof full === 'object') setHydratedView(full);
91114
} catch { /* hollow view stays hollow — renderer falls back to defaults */ }
92115
})();
93116
return () => { cancelled = true; };
94-
}, [objectDef, cfg.source, cfg.sourceView, resolvedView, dataSource]);
117+
}, [objectDefName, cfg.source, cfg.sourceView, resolvedViewHollow, resolvedViewKey, dataSource]);
95118

96119
const viewDef = React.useMemo(
97120
() => (hydratedView ? { ...resolvedView, ...hydratedView } : resolvedView),
98121
[resolvedView, hydratedView],
99122
);
100123

124+
// Key the schema on CONTENT, not object identity — `objects` (and thus
125+
// objectDef/resolvedView) are rebuilt per render, and a new schema
126+
// identity makes ListView refetch. The serialized view config is small.
127+
const viewDefJson = JSON.stringify(viewDef ?? null);
101128
const schema = React.useMemo(() => {
102129
if (!objectDef) return undefined;
103130
const view = viewDef || {};
@@ -149,7 +176,8 @@ export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
149176
allowExport: false,
150177
inlineEdit: false,
151178
};
152-
}, [objectDef, viewDef, cfg]);
179+
// eslint-disable-next-line react-hooks/exhaustive-deps
180+
}, [objectDefName, viewDefJson, cfg]);
153181

154182
if (!objectDef || !schema) {
155183
return (
@@ -181,7 +209,12 @@ export function InterfaceListPage({ page, className }: InterfaceListPageProps) {
181209
)}
182210
</div>
183211
<div className="flex-1 min-h-0 overflow-auto">
184-
<ListView schema={schema} dataSource={dataSource} />
212+
<ListView
213+
schema={schema}
214+
dataSource={dataSource}
215+
userFilterSelections={initialUfSelections}
216+
onUserFilterSelectionsChange={handleUserFilterSelectionsChange}
217+
/>
185218
</div>
186219
</div>
187220
);

packages/app-shell/src/views/ObjectView.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react';
1313
import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom';
14+
import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState';
1415
const ObjectChart = lazy(() =>
1516
import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })),
1617
);
@@ -919,17 +920,39 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
919920
* which matches the shape consumed by the list view's data fetcher when
920921
* merging base filters.
921922
*/
923+
// Dep on the serialized `filter[...]` entries only — `uf_*` user-filter
924+
// params also live in the URL and must not invalidate this memo (a new
925+
// array identity here rebuilds the whole list schema and refetches).
926+
const filterParamsKey = Array.from(searchParams.entries())
927+
.filter(([k]) => k.startsWith('filter['))
928+
.map(([k, v]) => `${k}=${v}`)
929+
.join('&');
922930
const urlFilters = useMemo(() => {
923931
const out: Array<[string, string, any]> = [];
924-
searchParams.forEach((value, key) => {
932+
new URLSearchParams(filterParamsKey).forEach((value, key) => {
925933
const m = /^filter\[(.+)\]$/.exec(key);
926934
if (m && m[1] && value !== '') {
927935
out.push([m[1], '=', value]);
928936
}
929937
});
930938
return out;
931-
// eslint-disable-next-line react-hooks/exhaustive-deps
932-
}, [searchParams.toString()]);
939+
}, [filterParamsKey]);
940+
941+
/**
942+
* End-user filter selections restored from `uf_*` URL params (ADR-0047
943+
* persistence). Captured once per ObjectView mount — UserFilters only
944+
* reads them at its own mount, and later URL writes must not churn the
945+
* schema memo.
946+
*/
947+
const [initialUfSelections] = useState<Record<string, string[]> | undefined>(
948+
() => parseUserFilterParams(new URLSearchParams(window.location.search)),
949+
);
950+
const handleUserFilterSelectionsChange = useCallback(
951+
(selections: Record<string, Array<string | number | boolean>>) => {
952+
setSearchParams(prev => applyUserFilterParams(prev, selections), { replace: true });
953+
},
954+
[setSearchParams],
955+
);
933956
// Memoize onNavigate to prevent stale closure in useNavigationOverlay's handleClick
934957
const handleNavOverlayNavigate = useCallback(
935958
(recordId: string | number, action?: string) => {
@@ -1331,10 +1354,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an
13311354
onColumnStateChange={(state: { order?: string[]; widths?: Record<string, number> }) => {
13321355
persistViewPatch(viewDef.id, viewDef, { columnState: state });
13331356
}}
1357+
userFilterSelections={initialUfSelections}
1358+
onUserFilterSelectionsChange={handleUserFilterSelectionsChange}
13341359
dataSource={ds}
13351360
/>
13361361
);
1337-
}, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters]);
1362+
}, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters, initialUfSelections, handleUserFilterSelectionsChange]);
13381363

13391364
// Memoize the merged views array so PluginObjectView doesn't get a new
13401365
// reference on every render (which would trigger unnecessary data refetches).
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* URL persistence for end-user filter selections (ADR-0047).
11+
*
12+
* Selections live in `uf_<field>` search params (comma-joined values,
13+
* each URI-encoded so literal commas survive); the active tab preset is
14+
* carried as `uf__tab=<tabId>` via the reserved `_tab` key. Mirroring the
15+
* state into the URL makes filter selections survive a reload and makes
16+
* filtered lists shareable as links — Airtable Interfaces parity.
17+
*/
18+
19+
const PREFIX = 'uf_';
20+
21+
/** Read `uf_*` params into the UserFilters `initialSelections` shape. */
22+
export function parseUserFilterParams(
23+
searchParams: URLSearchParams,
24+
): Record<string, string[]> | undefined {
25+
const out: Record<string, string[]> = {};
26+
searchParams.forEach((value, key) => {
27+
if (key.startsWith(PREFIX) && value !== '') {
28+
const field = key.slice(PREFIX.length);
29+
out[field] = value.split(',').map(v => {
30+
try { return decodeURIComponent(v); } catch { return v; }
31+
});
32+
}
33+
});
34+
return Object.keys(out).length > 0 ? out : undefined;
35+
}
36+
37+
/**
38+
* Mirror a selections payload into a copy of the given params. Only the
39+
* fields present in `selections` are touched — empty value lists delete
40+
* their param. Returns the next URLSearchParams (caller decides replace).
41+
*/
42+
export function applyUserFilterParams(
43+
prev: URLSearchParams,
44+
selections: Record<string, Array<string | number | boolean>>,
45+
): URLSearchParams {
46+
const next = new URLSearchParams(prev);
47+
for (const [field, values] of Object.entries(selections)) {
48+
const key = PREFIX + field;
49+
if (values && values.length > 0) {
50+
next.set(key, values.map(v => encodeURIComponent(String(v))).join(','));
51+
} else {
52+
next.delete(key);
53+
}
54+
}
55+
return next;
56+
}

0 commit comments

Comments
 (0)