Skip to content

Commit b543cf9

Browse files
committed
feat(resources): remember list preferences
1 parent 23e1663 commit b543cf9

14 files changed

Lines changed: 1272 additions & 47 deletions

File tree

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ 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+
- Effective URL filter/sort state wins as one complete snapshot and becomes the remembered value;
57+
omitted fields use URL defaults rather than merging with storage.
58+
- Explicit filter/sort gestures commit the same complete snapshot to nuqs and Zustand together.
59+
- Never mirror subsequent URL changes with a synchronization effect or `popstate` listener.
60+
- Search and folder navigation remain URL-only and are excluded from the persisted snapshot.
61+
4962
## Per-feature `search-params.ts` — single source of truth
5063

5164
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: 86 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,67 @@ export function Files() {
420424
sort: sortColumn,
421425
dir: sortDirection,
422426
activeSort,
423-
onSort,
424-
onClear,
427+
onSort: applyUrlSort,
428+
onClear: clearUrlSort,
425429
} = useUrlSort(filesSortParams, filesFilterUrlKeys)
426430

431+
const currentListPreference = useMemo<ResourceListPreference>(
432+
() => ({
433+
sort: { column: sortColumn, direction: sortDirection },
434+
filters: {
435+
type: typeFilter,
436+
size: sizeFilter,
437+
uploadedBy: uploadedByFilter,
438+
},
439+
}),
440+
[sortColumn, sortDirection, typeFilter, sizeFilter, uploadedByFilter]
441+
)
442+
443+
const applyListPreference = useCallback(
444+
(preference: ResourceListPreference) => {
445+
void setFileFilters({
446+
type: [...preference.filters.type],
447+
size: [...preference.filters.size],
448+
uploadedBy: [...preference.filters.uploadedBy],
449+
})
450+
const defaultSort = filesListPreferenceConfig.defaultPreference.sort
451+
if (
452+
preference.sort.column === defaultSort.column &&
453+
preference.sort.direction === defaultSort.direction
454+
) {
455+
clearUrlSort()
456+
} else {
457+
applyUrlSort(preference.sort.column, preference.sort.direction)
458+
}
459+
},
460+
[applyUrlSort, clearUrlSort, setFileFilters]
461+
)
462+
463+
const {
464+
isReady: isListPreferenceReady,
465+
setFilter: setListFilter,
466+
clearFilters: clearFileFilters,
467+
setSort: setListSort,
468+
clearSort: clearListSort,
469+
} = useResourceListPreferences({
470+
workspaceId,
471+
config: filesListPreferenceConfig,
472+
preference: currentListPreference,
473+
applyPreference: applyListPreference,
474+
enabled: fileIdFromRoute === null,
475+
})
476+
427477
const setTypeFilter = useCallback(
428-
(next: string[]) => setFileFilters({ type: next }),
429-
[setFileFilters]
478+
(next: string[]) => setListFilter('type', next),
479+
[setListFilter]
430480
)
431481
const setSizeFilter = useCallback(
432-
(next: string[]) => setFileFilters({ size: next }),
433-
[setFileFilters]
482+
(next: string[]) => setListFilter('size', next),
483+
[setListFilter]
434484
)
435485
const setUploadedByFilter = useCallback(
436-
(next: string[]) => setFileFilters({ uploadedBy: next }),
437-
[setFileFilters]
486+
(next: string[]) => setListFilter('uploadedBy', next),
487+
[setListFilter]
438488
)
439489

440490
const [creatingFile, setCreatingFile] = useState(false)
@@ -1904,10 +1954,10 @@ export function Files() {
19041954
{ id: 'owner', label: 'Owner' },
19051955
],
19061956
active: activeSort,
1907-
onSort,
1908-
onClear,
1957+
onSort: setListSort,
1958+
onClear: clearListSort,
19091959
}),
1910-
[activeSort, onSort, onClear]
1960+
[activeSort, setListSort, clearListSort]
19111961
)
19121962

19131963
const hasActiveFilters =
@@ -2004,19 +2054,26 @@ export function Files() {
20042054
{hasActiveFilters && (
20052055
<Button
20062056
variant='ghost'
2007-
onClick={() => {
2008-
setTypeFilter([])
2009-
setSizeFilter([])
2010-
setUploadedByFilter([])
2011-
}}
2057+
onClick={clearFileFilters}
20122058
className='h-[32px] w-full text-caption hover-hover:bg-[var(--surface-active)]'
20132059
>
20142060
Clear all filters
20152061
</Button>
20162062
)}
20172063
</div>
20182064
)
2019-
}, [typeFilter, sizeFilter, uploadedByFilter, memberOptions, membersById, hasActiveFilters])
2065+
}, [
2066+
typeFilter,
2067+
sizeFilter,
2068+
uploadedByFilter,
2069+
memberOptions,
2070+
membersById,
2071+
hasActiveFilters,
2072+
setTypeFilter,
2073+
setSizeFilter,
2074+
setUploadedByFilter,
2075+
clearFileFilters,
2076+
])
20202077

20212078
/** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */
20222079
const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent])
@@ -2056,7 +2113,15 @@ export function Files() {
20562113
tags.push({ label, onRemove: () => setUploadedByFilter([]) })
20572114
}
20582115
return tags
2059-
}, [typeFilter, sizeFilter, uploadedByFilter, membersById])
2116+
}, [
2117+
typeFilter,
2118+
sizeFilter,
2119+
uploadedByFilter,
2120+
membersById,
2121+
setTypeFilter,
2122+
setSizeFilter,
2123+
setUploadedByFilter,
2124+
])
20602125

20612126
const listState = resourceListState({
20622127
rowCount: rows.length,
@@ -2071,9 +2136,11 @@ export function Files() {
20712136

20722137
const clearSearchAndFilters = () => {
20732138
setSearchTerm('')
2074-
void setFileFilters({ type: null, size: null, uploadedBy: null })
2139+
clearFileFilters()
20752140
}
20762141

2142+
if (!isListPreferenceReady) return <FilesLoading />
2143+
20772144
if (fileIdFromRoute && !selectedFile && isLoading) {
20782145
return (
20792146
<Resource>

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

Lines changed: 11 additions & 0 deletions
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,6 +83,16 @@ export const filesSortParams = createSortParams(FILE_SORT_COLUMNS, {
8283
direction: 'desc',
8384
})
8485

86+
export const filesListPreferenceConfig = {
87+
module: 'files',
88+
sortColumns: FILE_SORT_COLUMNS,
89+
filterKeys: ['type', 'size', 'uploadedBy'],
90+
defaultPreference: {
91+
sort: filesSortParams.default,
92+
filters: { type: [], size: [], uploadedBy: [] },
93+
},
94+
} satisfies ResourceListPreferenceConfig
95+
8596
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
8697
export const filesFilterUrlKeys = {
8798
history: 'replace',

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

Lines changed: 81 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,66 @@ export function Knowledge() {
280284
sort: sortColumn,
281285
dir: sortDirection,
282286
activeSort,
283-
onSort: onSortColumn,
284-
onClear: onClearSort,
287+
onSort: applyUrlSort,
288+
onClear: clearUrlSort,
285289
} = useUrlSort(knowledgeSortParams, knowledgeUrlKeys)
286290

291+
const currentListPreference = useMemo<ResourceListPreference>(
292+
() => ({
293+
sort: { column: sortColumn, direction: sortDirection },
294+
filters: {
295+
connector: connectorFilter,
296+
content: contentFilter,
297+
owner: ownerFilter,
298+
},
299+
}),
300+
[sortColumn, sortDirection, connectorFilter, contentFilter, ownerFilter]
301+
)
302+
303+
const applyListPreference = useCallback(
304+
(preference: ResourceListPreference) => {
305+
void setKnowledgeFilters({
306+
connector: [...preference.filters.connector],
307+
content: [...preference.filters.content],
308+
owner: [...preference.filters.owner],
309+
})
310+
const defaultSort = knowledgeListPreferenceConfig.defaultPreference.sort
311+
if (
312+
preference.sort.column === defaultSort.column &&
313+
preference.sort.direction === defaultSort.direction
314+
) {
315+
clearUrlSort()
316+
} else {
317+
applyUrlSort(preference.sort.column, preference.sort.direction)
318+
}
319+
},
320+
[applyUrlSort, clearUrlSort, setKnowledgeFilters]
321+
)
322+
323+
const {
324+
isReady: isListPreferenceReady,
325+
setFilter: setListFilter,
326+
clearFilters: clearKnowledgeFilters,
327+
setSort: setListSort,
328+
clearSort: clearListSort,
329+
} = useResourceListPreferences({
330+
workspaceId,
331+
config: knowledgeListPreferenceConfig,
332+
preference: currentListPreference,
333+
applyPreference: applyListPreference,
334+
})
335+
287336
const setConnectorFilter = useCallback(
288-
(next: string[]) => setKnowledgeFilters({ connector: next }),
289-
[setKnowledgeFilters]
337+
(next: string[]) => setListFilter('connector', next),
338+
[setListFilter]
290339
)
291340
const setContentFilter = useCallback(
292-
(next: string[]) => setKnowledgeFilters({ content: next }),
293-
[setKnowledgeFilters]
341+
(next: string[]) => setListFilter('content', next),
342+
[setListFilter]
294343
)
295344
const setOwnerFilter = useCallback(
296-
(next: string[]) => setKnowledgeFilters({ owner: next }),
297-
[setKnowledgeFilters]
345+
(next: string[]) => setListFilter('owner', next),
346+
[setListFilter]
298347
)
299348

300349
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
@@ -1229,10 +1278,10 @@ export function Knowledge() {
12291278
{ id: 'updated', label: 'Last Updated' },
12301279
],
12311280
active: activeSort,
1232-
onSort: onSortColumn,
1233-
onClear: onClearSort,
1281+
onSort: setListSort,
1282+
onClear: clearListSort,
12341283
}),
1235-
[activeSort, onSortColumn, onClearSort]
1284+
[activeSort, setListSort, clearListSort]
12361285
)
12371286

12381287
const memberOptions: ChipDropdownOption[] = useMemo(
@@ -1319,7 +1368,15 @@ export function Knowledge() {
13191368
)}
13201369
</div>
13211370
),
1322-
[connectorFilter, contentFilter, ownerFilter, memberOptions]
1371+
[
1372+
connectorFilter,
1373+
contentFilter,
1374+
ownerFilter,
1375+
memberOptions,
1376+
setConnectorFilter,
1377+
setContentFilter,
1378+
setOwnerFilter,
1379+
]
13231380
)
13241381

13251382
/** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */
@@ -1376,7 +1433,15 @@ export function Knowledge() {
13761433
tags.push({ label, onRemove: () => setOwnerFilter([]) })
13771434
}
13781435
return tags
1379-
}, [connectorFilter, contentFilter, ownerFilter, members])
1436+
}, [
1437+
connectorFilter,
1438+
contentFilter,
1439+
ownerFilter,
1440+
members,
1441+
setConnectorFilter,
1442+
setContentFilter,
1443+
setOwnerFilter,
1444+
])
13801445

13811446
const listState = resourceListState({
13821447
rowCount: rows.length,
@@ -1391,9 +1456,11 @@ export function Knowledge() {
13911456

13921457
const clearSearchAndFilters = () => {
13931458
setSearchQuery('')
1394-
void setKnowledgeFilters({ connector: null, content: null, owner: null })
1459+
clearKnowledgeFilters()
13951460
}
13961461

1462+
if (!isListPreferenceReady) return <KnowledgeLoading />
1463+
13971464
return (
13981465
<>
13991466
<Resource onContextMenu={handleContentContextMenu}>

0 commit comments

Comments
 (0)