Skip to content

Commit 452e542

Browse files
authored
feat(resources): remember list preferences (#7371)
* feat(resources): remember list preferences * fix(resources): honor explicit default list params * fix(resources): harden list preference checks * improvement(resources): simplify list preference adapters
1 parent 4f42c61 commit 452e542

14 files changed

Lines changed: 1313 additions & 48 deletions

File tree

.claude/rules/sim-url-state.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ These reads/mutations are **not** anti-patterns and stay as-is:
4646
- **Route navigations**`router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`.
4747
- **Read-once auth / redirect signals**`token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal.
4848

49+
### Remembered list-preference exception
50+
51+
Files, Tables, and Knowledge may persist their last-used filter/sort snapshot through
52+
`useResourceListPreferences`. This is a fallback preference, not a second live source of truth:
53+
54+
- nuqs remains authoritative while the module is open.
55+
- Zustand is consulted once on a clean module entry, after persisted state hydrates.
56+
- An explicit URL filter/sort parameter wins even when it resolves to the module default. The
57+
complete resolved URL snapshot becomes the remembered value; omitted fields use URL defaults
58+
rather than merging with storage.
59+
- Explicit filter/sort gestures commit the same complete snapshot to nuqs and Zustand together.
60+
- Never mirror subsequent URL changes with a synchronization effect or `popstate` listener.
61+
- Search and folder navigation remain URL-only and are excluded from the persisted snapshot.
62+
4963
## Per-feature `search-params.ts` — single source of truth
5064

5165
Co-locate a `search-params.ts` next to the feature. Export the parser map (and shared options). Both the client (`useQueryStates`/`useQueryState`) and any server component (`createSearchParamsCache` from `nuqs/server`) import from this one file. Import parsers from `nuqs/server` so the module is safe to import in both client and server contexts.

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 77 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,11 @@ import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/compone
115115
import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu'
116116
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal'
117117
import { useWorkspaceFilesRoom } from '@/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room'
118+
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
118119
import {
119120
filesFilterParsers,
120121
filesFilterUrlKeys,
122+
filesListPreferenceConfig,
121123
filesParsers,
122124
filesSortParams,
123125
filesUrlKeys,
@@ -152,8 +154,10 @@ import { useContextMenu } from '@/hooks/use-context-menu'
152154
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
153155
import { useInlineRename } from '@/hooks/use-inline-rename'
154156
import { usePermissionConfig } from '@/hooks/use-permission-config'
157+
import { useResourceListPreferences } from '@/hooks/use-resource-list-preferences'
155158
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
156159
import { useUrlSort } from '@/hooks/use-url-sort'
160+
import type { ResourceListPreference } from '@/stores/resource-list-preferences'
157161

158162
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
159163
type FileResourceItem =
@@ -420,21 +424,58 @@ export function Files() {
420424
sort: sortColumn,
421425
dir: sortDirection,
422426
activeSort,
423-
onSort,
424-
onClear,
427+
onSort: applyUrlSort,
425428
} = useUrlSort(filesSortParams, filesFilterUrlKeys)
426429

430+
const currentListPreference = useMemo<ResourceListPreference>(
431+
() => ({
432+
sort: { column: sortColumn, direction: sortDirection },
433+
filters: {
434+
type: typeFilter,
435+
size: sizeFilter,
436+
uploadedBy: uploadedByFilter,
437+
},
438+
}),
439+
[sortColumn, sortDirection, typeFilter, sizeFilter, uploadedByFilter]
440+
)
441+
442+
const applyListPreference = useCallback(
443+
(preference: ResourceListPreference) => {
444+
void setFileFilters({
445+
type: [...preference.filters.type],
446+
size: [...preference.filters.size],
447+
uploadedBy: [...preference.filters.uploadedBy],
448+
})
449+
applyUrlSort(preference.sort.column, preference.sort.direction)
450+
},
451+
[applyUrlSort, setFileFilters]
452+
)
453+
454+
const {
455+
isReady: isListPreferenceReady,
456+
setFilter: setListFilter,
457+
clearFilters: clearFileFilters,
458+
setSort: setListSort,
459+
clearSort: clearListSort,
460+
} = useResourceListPreferences({
461+
workspaceId,
462+
config: filesListPreferenceConfig,
463+
preference: currentListPreference,
464+
applyPreference: applyListPreference,
465+
enabled: fileIdFromRoute === null,
466+
})
467+
427468
const setTypeFilter = useCallback(
428-
(next: string[]) => setFileFilters({ type: next }),
429-
[setFileFilters]
469+
(next: string[]) => setListFilter('type', next),
470+
[setListFilter]
430471
)
431472
const setSizeFilter = useCallback(
432-
(next: string[]) => setFileFilters({ size: next }),
433-
[setFileFilters]
473+
(next: string[]) => setListFilter('size', next),
474+
[setListFilter]
434475
)
435476
const setUploadedByFilter = useCallback(
436-
(next: string[]) => setFileFilters({ uploadedBy: next }),
437-
[setFileFilters]
477+
(next: string[]) => setListFilter('uploadedBy', next),
478+
[setListFilter]
438479
)
439480

440481
const [creatingFile, setCreatingFile] = useState(false)
@@ -1904,10 +1945,10 @@ export function Files() {
19041945
{ id: 'owner', label: 'Owner' },
19051946
],
19061947
active: activeSort,
1907-
onSort,
1908-
onClear,
1948+
onSort: setListSort,
1949+
onClear: clearListSort,
19091950
}),
1910-
[activeSort, onSort, onClear]
1951+
[activeSort, setListSort, clearListSort]
19111952
)
19121953

19131954
const hasActiveFilters =
@@ -2004,19 +2045,26 @@ export function Files() {
20042045
{hasActiveFilters && (
20052046
<Button
20062047
variant='ghost'
2007-
onClick={() => {
2008-
setTypeFilter([])
2009-
setSizeFilter([])
2010-
setUploadedByFilter([])
2011-
}}
2048+
onClick={clearFileFilters}
20122049
className='h-[32px] w-full text-caption hover-hover:bg-[var(--surface-active)]'
20132050
>
20142051
Clear all filters
20152052
</Button>
20162053
)}
20172054
</div>
20182055
)
2019-
}, [typeFilter, sizeFilter, uploadedByFilter, memberOptions, membersById, hasActiveFilters])
2056+
}, [
2057+
typeFilter,
2058+
sizeFilter,
2059+
uploadedByFilter,
2060+
memberOptions,
2061+
membersById,
2062+
hasActiveFilters,
2063+
setTypeFilter,
2064+
setSizeFilter,
2065+
setUploadedByFilter,
2066+
clearFileFilters,
2067+
])
20202068

20212069
/** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */
20222070
const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent])
@@ -2056,7 +2104,15 @@ export function Files() {
20562104
tags.push({ label, onRemove: () => setUploadedByFilter([]) })
20572105
}
20582106
return tags
2059-
}, [typeFilter, sizeFilter, uploadedByFilter, membersById])
2107+
}, [
2108+
typeFilter,
2109+
sizeFilter,
2110+
uploadedByFilter,
2111+
membersById,
2112+
setTypeFilter,
2113+
setSizeFilter,
2114+
setUploadedByFilter,
2115+
])
20602116

20612117
const listState = resourceListState({
20622118
rowCount: rows.length,
@@ -2071,9 +2127,11 @@ export function Files() {
20712127

20722128
const clearSearchAndFilters = () => {
20732129
setSearchTerm('')
2074-
void setFileFilters({ type: null, size: null, uploadedBy: null })
2130+
clearFileFilters()
20752131
}
20762132

2133+
if (!isListPreferenceReady) return <FilesLoading />
2134+
20772135
if (fileIdFromRoute && !selectedFile && isLoading) {
20782136
return (
20792137
<Resource>

apps/sim/app/workspace/[workspaceId]/files/search-params.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createParser, parseAsArrayOf, parseAsString } from 'nuqs/server'
22
import { createSortParams } from '@/lib/url-state'
3+
import type { ResourceListPreferenceConfig } from '@/stores/resource-list-preferences'
34

45
/** Sortable list columns, matching the `Resource.Options` sort menu. */
56
export const FILE_SORT_COLUMNS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const
@@ -82,10 +83,23 @@ export const filesSortParams = createSortParams(FILE_SORT_COLUMNS, {
8283
direction: 'desc',
8384
})
8485

86+
const filesFilterUrlKeyMap = { uploadedBy: 'uploaded-by' } as const
87+
88+
export const filesListPreferenceConfig = {
89+
module: 'files',
90+
sortColumns: FILE_SORT_COLUMNS,
91+
filterKeys: ['type', 'size', 'uploadedBy'],
92+
preferenceUrlKeys: filesFilterUrlKeyMap,
93+
defaultPreference: {
94+
sort: filesSortParams.default,
95+
filters: { type: [], size: [], uploadedBy: [] },
96+
},
97+
} as const satisfies ResourceListPreferenceConfig
98+
8599
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
86100
export const filesFilterUrlKeys = {
87101
history: 'replace',
88102
shallow: true,
89103
clearOnDefault: true,
90-
urlKeys: { uploadedBy: 'uploaded-by' },
104+
urlKeys: filesFilterUrlKeyMap,
91105
} as const

apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,9 @@ import {
7272
KnowledgeBaseContextMenu,
7373
KnowledgeListContextMenu,
7474
} from '@/app/workspace/[workspaceId]/knowledge/components'
75+
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
7576
import {
77+
knowledgeListPreferenceConfig,
7678
knowledgeParsers,
7779
knowledgeSortParams,
7880
knowledgeUrlKeys,
@@ -95,9 +97,11 @@ import { useContextMenu } from '@/hooks/use-context-menu'
9597
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
9698
import { useInlineRename } from '@/hooks/use-inline-rename'
9799
import { usePermissionConfig } from '@/hooks/use-permission-config'
100+
import { useResourceListPreferences } from '@/hooks/use-resource-list-preferences'
98101
import { useSearchFilterValue } from '@/hooks/use-search-filter-value'
99102
import { useUrlSort } from '@/hooks/use-url-sort'
100103
import type { WorkflowFolder } from '@/stores/folders/types'
104+
import type { ResourceListPreference } from '@/stores/resource-list-preferences'
101105

102106
const logger = createLogger('Knowledge')
103107

@@ -280,21 +284,57 @@ export function Knowledge() {
280284
sort: sortColumn,
281285
dir: sortDirection,
282286
activeSort,
283-
onSort: onSortColumn,
284-
onClear: onClearSort,
287+
onSort: applyUrlSort,
285288
} = useUrlSort(knowledgeSortParams, knowledgeUrlKeys)
286289

290+
const currentListPreference = useMemo<ResourceListPreference>(
291+
() => ({
292+
sort: { column: sortColumn, direction: sortDirection },
293+
filters: {
294+
connector: connectorFilter,
295+
content: contentFilter,
296+
owner: ownerFilter,
297+
},
298+
}),
299+
[sortColumn, sortDirection, connectorFilter, contentFilter, ownerFilter]
300+
)
301+
302+
const applyListPreference = useCallback(
303+
(preference: ResourceListPreference) => {
304+
void setKnowledgeFilters({
305+
connector: [...preference.filters.connector],
306+
content: [...preference.filters.content],
307+
owner: [...preference.filters.owner],
308+
})
309+
applyUrlSort(preference.sort.column, preference.sort.direction)
310+
},
311+
[applyUrlSort, setKnowledgeFilters]
312+
)
313+
314+
const {
315+
isReady: isListPreferenceReady,
316+
setFilter: setListFilter,
317+
clearFilters: clearKnowledgeFilters,
318+
setSort: setListSort,
319+
clearSort: clearListSort,
320+
} = useResourceListPreferences({
321+
workspaceId,
322+
config: knowledgeListPreferenceConfig,
323+
preference: currentListPreference,
324+
applyPreference: applyListPreference,
325+
})
326+
287327
const setConnectorFilter = useCallback(
288-
(next: string[]) => setKnowledgeFilters({ connector: next }),
289-
[setKnowledgeFilters]
328+
(next: string[]) => setListFilter('connector', next),
329+
[setListFilter]
290330
)
291331
const setContentFilter = useCallback(
292-
(next: string[]) => setKnowledgeFilters({ content: next }),
293-
[setKnowledgeFilters]
332+
(next: string[]) => setListFilter('content', next),
333+
[setListFilter]
294334
)
295335
const setOwnerFilter = useCallback(
296-
(next: string[]) => setKnowledgeFilters({ owner: next }),
297-
[setKnowledgeFilters]
336+
(next: string[]) => setListFilter('owner', next),
337+
[setListFilter]
298338
)
299339

300340
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
@@ -1229,10 +1269,10 @@ export function Knowledge() {
12291269
{ id: 'updated', label: 'Last Updated' },
12301270
],
12311271
active: activeSort,
1232-
onSort: onSortColumn,
1233-
onClear: onClearSort,
1272+
onSort: setListSort,
1273+
onClear: clearListSort,
12341274
}),
1235-
[activeSort, onSortColumn, onClearSort]
1275+
[activeSort, setListSort, clearListSort]
12361276
)
12371277

12381278
const memberOptions: ChipDropdownOption[] = useMemo(
@@ -1319,7 +1359,15 @@ export function Knowledge() {
13191359
)}
13201360
</div>
13211361
),
1322-
[connectorFilter, contentFilter, ownerFilter, memberOptions]
1362+
[
1363+
connectorFilter,
1364+
contentFilter,
1365+
ownerFilter,
1366+
memberOptions,
1367+
setConnectorFilter,
1368+
setContentFilter,
1369+
setOwnerFilter,
1370+
]
13231371
)
13241372

13251373
/** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */
@@ -1376,7 +1424,15 @@ export function Knowledge() {
13761424
tags.push({ label, onRemove: () => setOwnerFilter([]) })
13771425
}
13781426
return tags
1379-
}, [connectorFilter, contentFilter, ownerFilter, members])
1427+
}, [
1428+
connectorFilter,
1429+
contentFilter,
1430+
ownerFilter,
1431+
members,
1432+
setConnectorFilter,
1433+
setContentFilter,
1434+
setOwnerFilter,
1435+
])
13801436

13811437
const listState = resourceListState({
13821438
rowCount: rows.length,
@@ -1391,9 +1447,11 @@ export function Knowledge() {
13911447

13921448
const clearSearchAndFilters = () => {
13931449
setSearchQuery('')
1394-
void setKnowledgeFilters({ connector: null, content: null, owner: null })
1450+
clearKnowledgeFilters()
13951451
}
13961452

1453+
if (!isListPreferenceReady) return <KnowledgeLoading />
1454+
13971455
return (
13981456
<>
13991457
<Resource onContextMenu={handleContentContextMenu}>

apps/sim/app/workspace/[workspaceId]/knowledge/search-params.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { parseAsArrayOf, parseAsString } from 'nuqs/server'
22
import { createSortParams } from '@/lib/url-state'
3+
import type { ResourceListPreferenceConfig } from '@/stores/resource-list-preferences'
34

45
/** Sortable knowledge base columns, matching the `Resource.Options` sort menu. */
56
export const KNOWLEDGE_SORT_COLUMNS = [
@@ -43,6 +44,16 @@ export const knowledgeParsers = {
4344
owner: parseAsArrayOf(parseAsString).withDefault([]),
4445
} as const
4546

47+
export const knowledgeListPreferenceConfig = {
48+
module: 'knowledge',
49+
sortColumns: KNOWLEDGE_SORT_COLUMNS,
50+
filterKeys: ['connector', 'content', 'owner'],
51+
defaultPreference: {
52+
sort: knowledgeSortParams.default,
53+
filters: { connector: [], content: [], owner: [] },
54+
},
55+
} as const satisfies ResourceListPreferenceConfig
56+
4657
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
4758
export const knowledgeUrlKeys = {
4859
history: 'replace',

0 commit comments

Comments
 (0)