From 542efa6533f98f0e8757b1c309ca3f56f0ca2ea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Sun, 5 Jul 2026 22:30:09 -0700 Subject: [PATCH] feat(app-shell,plugin-list): persist list filters per-user across navigation (#2294) List search term and advanced FilterBuilder conditions were component-local ListView state, destroyed on unmount when navigating away via a sidebar link and lost on return (uf_* URL params only survive reload/back, not in-app nav). Cache the runtime filter (advanced filter + search) in localStorage keyed by user + object + view, and restore it into ListView at mount: - add listFilterStorage.ts (build/read/write helpers, 300ms debounced write, key removed when state empty) - key embeds userId so two accounts on one browser never read each other's filter values - ObjectView writes on onFilterChange/onSearchChange, restores via new ListView initialFilters/initialSearchTerm props (one-shot lazy init; ListView remounts by key on view switch so it re-reads per view) - server-side shared view definition (persistViewPatch) untouched; this is a purely per-user, device-local layer --- packages/app-shell/src/views/ObjectView.tsx | 17 ++- .../app-shell/src/views/listFilterStorage.ts | 115 ++++++++++++++++++ packages/plugin-list/src/ListView.tsx | 27 +++- 3 files changed, 152 insertions(+), 7 deletions(-) create mode 100644 packages/app-shell/src/views/listFilterStorage.ts diff --git a/packages/app-shell/src/views/ObjectView.tsx b/packages/app-shell/src/views/ObjectView.tsx index 12da50740..bc9456eae 100644 --- a/packages/app-shell/src/views/ObjectView.tsx +++ b/packages/app-shell/src/views/ObjectView.tsx @@ -12,6 +12,7 @@ import { useMemo, useState, useCallback, useEffect, useRef, lazy, Suspense, type ComponentType } from 'react'; import { useParams, useSearchParams, useNavigate, useLocation } from 'react-router-dom'; import { parseUserFilterParams, applyUserFilterParams } from './userFilterUrlState'; +import { buildListFilterKey, readListFilterState, writeListFilterState } from './listFilterStorage'; const ObjectChart = lazy(() => import('@object-ui/plugin-charts').then((m) => ({ default: m.ObjectChart })), ); @@ -1124,6 +1125,14 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an const key = `${objectName}-${activeView.id}-${combinedRefreshKey}`; const viewDef = activeView; + // Per-user, per-view runtime-filter cache (advanced filter + search). + // Restored into ListView at mount so leaving via a nav link and coming + // back keeps what the user was filtering — URL params alone don't + // survive that (the nav link has no query string). Keyed on the signed-in + // user so two accounts on one browser never share filter values. + const listFilterKey = buildListFilterKey(user?.id, objectName, activeView.id); + const storedListFilters = readListFilterState(listFilterKey); + // Warn in dev mode if flat properties are used instead of nested spec format if (process.env.NODE_ENV === 'development') { const flatKeys = ['startDateField', 'endDateField', 'dateField', 'groupBy', 'groupField', @@ -1460,6 +1469,10 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an }} onFilterChange={(filter: any) => { persistViewPatch(viewDef.id, viewDef, { filter }); + writeListFilterState(listFilterKey, { filters: filter }); + }} + onSearchChange={(search: string) => { + writeListFilterState(listFilterKey, { search }); }} onHiddenFieldsChange={(hidden: string[]) => { persistViewPatch(viewDef.id, viewDef, { hiddenFields: hidden }); @@ -1472,10 +1485,12 @@ function ObjectViewInner({ dataSource, objects, onEdit, externalRefreshKey }: an }} userFilterSelections={initialUfSelections} onUserFilterSelectionsChange={handleUserFilterSelectionsChange} + initialFilters={storedListFilters?.filters as any} + initialSearchTerm={storedListFilters?.search} dataSource={ds} /> ); - }, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters, initialUfSelections, handleUserFilterSelectionsChange]); + }, [activeView, objectDef, objectName, refreshKey, navOverlay, actions, persistViewPatch, urlFilters, initialUfSelections, handleUserFilterSelectionsChange, user?.id]); // Memoize the merged views array so PluginObjectView doesn't get a new // reference on every render (which would trigger unnecessary data refetches). diff --git a/packages/app-shell/src/views/listFilterStorage.ts b/packages/app-shell/src/views/listFilterStorage.ts new file mode 100644 index 000000000..6fa968215 --- /dev/null +++ b/packages/app-shell/src/views/listFilterStorage.ts @@ -0,0 +1,115 @@ +/** + * 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. + */ + +/** + * Per-user, per-view persistence for a list's transient runtime filters — + * the advanced FilterBuilder group and the search term. + * + * Unlike `userFilterUrlState` (which mirrors end-user quick-filter selections + * into `uf_*` URL params so a filtered list is shareable), this cache lives in + * `localStorage`. URL params only survive a reload or the browser Back button; + * they are lost the moment the user leaves via an in-app nav link (that link + * carries no query string) and returns. Mirroring the runtime filter into + * `localStorage` makes it survive full SPA navigation too — the user's own + * "remember what I was filtering" state, NOT a change to the shared view. + * + * The key embeds the user id so two accounts on the same browser never read + * each other's filters (a filter value can be sensitive). Column state + * (`grid-columns-*`) predates this and is deliberately NOT user-scoped — + * column widths are cosmetic; filter values are not. + */ + +const PREFIX = 'list-filters'; + +/** Loose shape of a FilterBuilder group — `{ id, logic, conditions[] }`. */ +export interface ListFilterState { + filters?: { id?: string; logic?: string; conditions?: unknown[] } | null; + search?: string; +} + +/** + * Build the storage key for a given user + object + view. Returns undefined + * when the caller can't scope it (no object/view yet) so we never write a key + * that would collide across views. `userId` falls back to `anon` — a signed-out + * or still-loading session gets its own bucket rather than leaking into a real + * user's key. + */ +export function buildListFilterKey( + userId: string | undefined, + objectName: string | undefined, + viewId: string | undefined, +): string | undefined { + if (!objectName || !viewId) return undefined; + return `${PREFIX}:${userId || 'anon'}:${objectName}:${viewId}`; +} + +/** True when a filter group carries no active conditions. */ +function isEmptyFilters(f: ListFilterState['filters']): boolean { + return !f || !Array.isArray(f.conditions) || f.conditions.length === 0; +} + +/** True when a whole state is effectively empty (nothing worth persisting). */ +function isEmptyState(s: ListFilterState): boolean { + return isEmptyFilters(s.filters) && !(s.search && s.search.length > 0); +} + +/** Read the cached filter state for a key. Returns undefined when absent or unparsable. */ +export function readListFilterState(key: string | undefined): ListFilterState | undefined { + if (!key || typeof localStorage === 'undefined') return undefined; + try { + const raw = localStorage.getItem(key); + if (!raw) return undefined; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return undefined; + return parsed as ListFilterState; + } catch { + return undefined; + } +} + +// Debounce writes so a burst of FilterBuilder edits / search keystrokes hits +// localStorage once, not on every character. Pending patches merge per key so +// a filter change and a search change in the same window both land. +const pending: Record = {}; +const timers: Record> = {}; + +/** + * Merge a patch into the cached state for a key and flush (debounced). Only the + * keys present in `patch` are touched, so callers can persist `filters` and + * `search` independently. When the merged result is empty the key is removed to + * keep storage from filling with dead buckets. + */ +export function writeListFilterState(key: string | undefined, patch: ListFilterState): void { + if (!key || typeof localStorage === 'undefined') return; + const base = pending[key] ?? readListFilterState(key) ?? {}; + pending[key] = { ...base, ...patch }; + if (timers[key]) clearTimeout(timers[key]); + timers[key] = setTimeout(() => { + const next = pending[key] ?? {}; + delete pending[key]; + delete timers[key]; + try { + if (isEmptyState(next)) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(next)); + } catch { + /* quota / private-mode — restoring filters is best-effort, never fatal */ + } + }, 300); +} + +/** Drop any cached (and pending) state for a key. */ +export function clearListFilterState(key: string | undefined): void { + if (!key) return; + if (timers[key]) { clearTimeout(timers[key]); delete timers[key]; } + delete pending[key]; + try { + if (typeof localStorage !== 'undefined') localStorage.removeItem(key); + } catch { + /* ignore */ + } +} diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index 62373f9f9..91bf90ba2 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -43,6 +43,15 @@ export interface ListViewProps { userFilterSelections?: Record>; /** Fires with the raw user-filter selections whenever the user changes them. */ onUserFilterSelectionsChange?: (selections: Record>) => void; + /** + * Initial advanced-filter (FilterBuilder) group to restore at mount, e.g. + * from a per-user localStorage cache. Read once by the lazy initializer — + * later prop changes don't override in-flight user edits, so hosts remount + * (via `key`) when they need to swap the restored value (view switch). + */ + initialFilters?: FilterGroup; + /** Initial search term to restore at mount (same one-shot semantics as `initialFilters`). */ + initialSearchTerm?: string; [key: string]: any; } @@ -330,6 +339,8 @@ export const ListView = React.forwardRef(({ showViewSwitcher: showViewSwitcherProp, userFilterSelections, onUserFilterSelectionsChange, + initialFilters, + initialSearchTerm, ...props }, ref) => { // The switcher can be enabled either by the host component (prop) or by @@ -400,7 +411,7 @@ export const ListView = React.forwardRef(({ const [currentView, setCurrentView] = React.useState( (schema.viewType as ViewType) ); - const [searchTerm, setSearchTerm] = React.useState(''); + const [searchTerm, setSearchTerm] = React.useState(() => initialSearchTerm ?? ''); const [showSearchPopover, setShowSearchPopover] = React.useState(false); // Sort State @@ -460,11 +471,15 @@ export const ListView = React.forwardRef(({ const [showFilters, setShowFilters] = React.useState(false); - const [currentFilters, setCurrentFilters] = React.useState({ - id: 'root', - logic: 'and', - conditions: [] - }); + const [currentFilters, setCurrentFilters] = React.useState(() => + initialFilters && Array.isArray(initialFilters.conditions) + ? initialFilters + : { + id: 'root', + logic: 'and', + conditions: [] + } + ); // Data State const dataSource = props.dataSource;