Skip to content
Open
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
17 changes: 16 additions & 1 deletion packages/app-shell/src/views/ObjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 })),
);
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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 });
Expand All @@ -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).
Expand Down
115 changes: 115 additions & 0 deletions packages/app-shell/src/views/listFilterStorage.ts
Original file line number Diff line number Diff line change
@@ -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<string, ListFilterState> = {};
const timers: Record<string, ReturnType<typeof setTimeout>> = {};

/**
* 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 */
}
}
27 changes: 21 additions & 6 deletions packages/plugin-list/src/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ export interface ListViewProps {
userFilterSelections?: Record<string, Array<string | number | boolean>>;
/** Fires with the raw user-filter selections whenever the user changes them. */
onUserFilterSelectionsChange?: (selections: Record<string, Array<string | number | boolean>>) => 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;
}

Expand Down Expand Up @@ -330,6 +339,8 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
showViewSwitcher: showViewSwitcherProp,
userFilterSelections,
onUserFilterSelectionsChange,
initialFilters,
initialSearchTerm,
...props
}, ref) => {
// The switcher can be enabled either by the host component (prop) or by
Expand Down Expand Up @@ -400,7 +411,7 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({
const [currentView, setCurrentView] = React.useState<ViewType>(
(schema.viewType as ViewType)
);
const [searchTerm, setSearchTerm] = React.useState('');
const [searchTerm, setSearchTerm] = React.useState(() => initialSearchTerm ?? '');
const [showSearchPopover, setShowSearchPopover] = React.useState(false);

// Sort State
Expand Down Expand Up @@ -460,11 +471,15 @@ export const ListView = React.forwardRef<ListViewHandle, ListViewProps>(({

const [showFilters, setShowFilters] = React.useState(false);

const [currentFilters, setCurrentFilters] = React.useState<FilterGroup>({
id: 'root',
logic: 'and',
conditions: []
});
const [currentFilters, setCurrentFilters] = React.useState<FilterGroup>(() =>
initialFilters && Array.isArray(initialFilters.conditions)
? initialFilters
: {
id: 'root',
logic: 'and',
conditions: []
}
);

// Data State
const dataSource = props.dataSource;
Expand Down
Loading