Skip to content

Commit 3e97589

Browse files
authored
improvement(url-state): migrate list search/sort/filter view-state to nuqs (#5648)
* improvement(url-state): migrate list search/sort/filter view-state to nuqs * fix(url-state): clear mcpServerId selection when the selected workflow MCP server is deleted * fix(url-state): resolve-gated MCP server detail view + replace-on-close for detail back navigation * fix(url-state): explicit Suspense boundary for account settings sections
1 parent f64be3c commit 3e97589

19 files changed

Lines changed: 497 additions & 101 deletions

File tree

apps/sim/app/account/settings/[section]/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { notFound, redirect } from 'next/navigation'
34
import { AccountSettingsRenderer } from '@/components/settings/account-settings-renderer'
@@ -50,5 +51,15 @@ export default async function AccountSettingsSectionPage({
5051
if (!isSuperUser) notFound()
5152
}
5253

53-
return <AccountSettingsRenderer section={parsed} />
54+
/**
55+
* Sections read URL query params via nuqs (which uses `useSearchParams`
56+
* internally), so the renderer must sit under a Suspense boundary. The
57+
* `null` fallback matches the existing visual behavior — the sections are
58+
* `next/dynamic` components that render nothing while their chunk loads.
59+
*/
60+
return (
61+
<Suspense fallback={null}>
62+
<AccountSettingsRenderer section={parsed} />
63+
</Suspense>
64+
)
5465
}

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

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { Download, Send } from '@sim/emcn/icons'
2222
import { createLogger } from '@sim/logger'
2323
import { getErrorMessage, toError } from '@sim/utils/errors'
2424
import { useParams, useRouter } from 'next/navigation'
25-
import { useQueryStates } from 'nuqs'
25+
import { debounce, useQueryStates } from 'nuqs'
2626
import { usePostHog } from 'posthog-js/react'
2727
import { getDocumentIcon } from '@/components/icons/document-icons'
2828
import { useLimitUpgradeToast } from '@/lib/billing/client'
@@ -75,7 +75,14 @@ import {
7575
import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu'
7676
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal'
7777
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
78-
import { filesParsers, filesUrlKeys } from '@/app/workspace/[workspaceId]/files/search-params'
78+
import {
79+
FILE_SORT_COLUMNS,
80+
type FileSortColumn,
81+
filesFilterParsers,
82+
filesFilterUrlKeys,
83+
filesParsers,
84+
filesUrlKeys,
85+
} from '@/app/workspace/[workspaceId]/files/search-params'
7986
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
8087
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
8188
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
@@ -104,6 +111,9 @@ type FileResourceItem =
104111

105112
const logger = createLogger('Files')
106113

114+
/** Debounce window for `search` URL writes and filtering; the input itself stays instant. */
115+
const SEARCH_DEBOUNCE_MS = 200 as const
116+
107117
const SUPPORTED_EXTENSIONS = [
108118
...SUPPORTED_DOCUMENT_EXTENSIONS,
109119
...SUPPORTED_CODE_EXTENSIONS,
@@ -244,15 +254,61 @@ export function Files() {
244254
})
245255
const [isDraggingOver, setIsDraggingOver] = useState(false)
246256
const dragCounterRef = useRef(0)
247-
const [inputValue, setInputValue] = useState('')
248-
const debouncedSearchTerm = useDebounce(inputValue, 200)
249-
const [activeSort, setActiveSort] = useState<{
250-
column: string
251-
direction: 'asc' | 'desc'
252-
} | null>(null)
253-
const [typeFilter, setTypeFilter] = useState<string[]>([])
254-
const [sizeFilter, setSizeFilter] = useState<string[]>([])
255-
const [uploadedByFilter, setUploadedByFilter] = useState<string[]>([])
257+
const [
258+
{
259+
search: urlSearchTerm,
260+
sort: sortColumn,
261+
dir: sortDirection,
262+
type: typeFilter,
263+
size: sizeFilter,
264+
uploadedBy: uploadedByFilter,
265+
},
266+
setFileFilters,
267+
] = useQueryStates(filesFilterParsers, filesFilterUrlKeys)
268+
269+
/**
270+
* The input is controlled directly by the instant nuqs value; only the URL
271+
* write is debounced. The in-memory filter below still reads a debounced value
272+
* so it doesn't recompute on every keystroke.
273+
*/
274+
const setSearchTerm = useCallback(
275+
(value: string) => {
276+
const next = value.length > 0 ? value : null
277+
setFileFilters(
278+
{ search: next },
279+
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
280+
)
281+
},
282+
[setFileFilters]
283+
)
284+
const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS)
285+
286+
/**
287+
* `sort`/`dir` are nullable in the URL because "no active sort" is distinct
288+
* from an explicit updated/desc selection: with no sort, files fall back to
289+
* updated/desc but folders to name/asc, while an explicit sort orders both
290+
* sections by the chosen column.
291+
*/
292+
const activeSort = useMemo(
293+
() =>
294+
sortColumn !== null && sortDirection !== null
295+
? { column: sortColumn, direction: sortDirection }
296+
: null,
297+
[sortColumn, sortDirection]
298+
)
299+
300+
const setTypeFilter = useCallback(
301+
(next: string[]) => setFileFilters({ type: next }),
302+
[setFileFilters]
303+
)
304+
const setSizeFilter = useCallback(
305+
(next: string[]) => setFileFilters({ size: next }),
306+
[setFileFilters]
307+
)
308+
const setUploadedByFilter = useCallback(
309+
(next: string[]) => setFileFilters({ uploadedBy: next }),
310+
[setFileFilters]
311+
)
256312

257313
const [creatingFile, setCreatingFile] = useState(false)
258314
const [isDirty, setIsDirty] = useState(false)
@@ -1523,9 +1579,9 @@ export function Files() {
15231579
}, [canEdit, uploading])
15241580

15251581
const searchConfig: SearchConfig = {
1526-
value: inputValue,
1527-
onChange: setInputValue,
1528-
onClearAll: () => setInputValue(''),
1582+
value: urlSearchTerm,
1583+
onChange: setSearchTerm,
1584+
onClearAll: () => setSearchTerm(''),
15291585
placeholder: 'Search files...',
15301586
}
15311587

@@ -1689,10 +1745,13 @@ export function Files() {
16891745
{ id: 'owner', label: 'Owner' },
16901746
],
16911747
active: activeSort,
1692-
onSort: (column, direction) => setActiveSort({ column, direction }),
1693-
onClear: () => setActiveSort(null),
1748+
onSort: (column, direction) => {
1749+
if (!(FILE_SORT_COLUMNS as readonly string[]).includes(column)) return
1750+
setFileFilters({ sort: column as FileSortColumn, dir: direction })
1751+
},
1752+
onClear: () => setFileFilters({ sort: null, dir: null }),
16941753
}),
1695-
[activeSort]
1754+
[activeSort, setFileFilters]
16961755
)
16971756

16981757
const hasActiveFilters =

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

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { createParser, parseAsString } from 'nuqs/server'
1+
import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
2+
3+
/** Sortable list columns, matching the `Resource.Options` sort menu. */
4+
export const FILE_SORT_COLUMNS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const
5+
6+
export type FileSortColumn = (typeof FILE_SORT_COLUMNS)[number]
7+
8+
const SORT_DIRECTIONS = ['asc', 'desc'] as const
29

310
/**
411
* Parser for the `new` flag. Preserves the prior `?new=1` wire format on
@@ -47,3 +54,39 @@ export const filesUrlKeys = {
4754
history: 'push',
4855
clearOnDefault: true,
4956
} as const
57+
58+
/**
59+
* Co-located, typed URL query-param definitions for the Files list's
60+
* filter/search/sort view-state, grouped separately from the navigation params
61+
* above because filter writes must never land in the browser history.
62+
*
63+
* - `search` is the file/folder name filter. The input is controlled directly
64+
* by the nuqs value; only its URL write is debounced via `limitUrlUpdates`
65+
* (`debounce`) on the setter — never written on every keystroke.
66+
* - `sort` / `dir` follow the shared sort convention (two scalar params). They
67+
* are intentionally nullable (no `.withDefault`) because "no active sort" is
68+
* behaviorally distinct from explicitly sorting by the fallback column: with
69+
* no sort, files order by updated/desc but folders by name/asc, while an
70+
* explicit updated/desc sorts both sections by updatedAt. Collapsing the
71+
* explicit selection into a clean URL would make that folder ordering
72+
* unreachable. Clearing the sort writes `null`, which strips both params.
73+
* - `type` filters by file kind (document/image/audio/video); `size` filters by
74+
* size bucket (small/medium/large); `uploadedBy` filters by uploader user id
75+
* (URL key `uploaded-by`). All three are multi-select arrays.
76+
*/
77+
export const filesFilterParsers = {
78+
search: parseAsString.withDefault(''),
79+
sort: parseAsStringLiteral(FILE_SORT_COLUMNS),
80+
dir: parseAsStringLiteral(SORT_DIRECTIONS),
81+
type: parseAsArrayOf(parseAsString).withDefault([]),
82+
size: parseAsArrayOf(parseAsString).withDefault([]),
83+
uploadedBy: parseAsArrayOf(parseAsString).withDefault([]),
84+
} as const
85+
86+
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
87+
export const filesFilterUrlKeys = {
88+
history: 'replace',
89+
shallow: true,
90+
clearOnDefault: true,
91+
urlKeys: { uploadedBy: 'uploaded-by' },
92+
} as const

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

Lines changed: 81 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
66
import { Database } from '@sim/emcn/icons'
77
import { createLogger } from '@sim/logger'
88
import { useParams, useRouter } from 'next/navigation'
9+
import { debounce, useQueryStates } from 'nuqs'
910
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
1011
import type {
1112
FilterTag,
@@ -30,6 +31,14 @@ import {
3031
KnowledgeBaseContextMenu,
3132
KnowledgeListContextMenu,
3233
} from '@/app/workspace/[workspaceId]/knowledge/components'
34+
import {
35+
DEFAULT_KNOWLEDGE_SORT_COLUMN,
36+
DEFAULT_KNOWLEDGE_SORT_DIRECTION,
37+
KNOWLEDGE_SORT_COLUMNS,
38+
type KnowledgeSortColumn,
39+
knowledgeParsers,
40+
knowledgeUrlKeys,
41+
} from '@/app/workspace/[workspaceId]/knowledge/search-params'
3342
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
3443
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
3544
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
@@ -42,6 +51,9 @@ import { usePermissionConfig } from '@/hooks/use-permission-config'
4251

4352
const logger = createLogger('Knowledge')
4453

54+
/** Debounce window for `search` URL writes; the input itself stays instant. */
55+
const SEARCH_DEBOUNCE_MS = 300 as const
56+
4557
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
4658
docCount?: number
4759
}
@@ -143,16 +155,60 @@ export function Knowledge() {
143155
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
144156
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
145157

146-
const [activeSort, setActiveSort] = useState<{
147-
column: string
148-
direction: 'asc' | 'desc'
149-
} | null>(null)
150-
const [connectorFilter, setConnectorFilter] = useState<string[]>([])
151-
const [contentFilter, setContentFilter] = useState<string[]>([])
152-
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
158+
const [
159+
{
160+
search: urlSearchQuery,
161+
sort: sortColumn,
162+
dir: sortDirection,
163+
connector: connectorFilter,
164+
content: contentFilter,
165+
owner: ownerFilter,
166+
},
167+
setKnowledgeFilters,
168+
] = useQueryStates(knowledgeParsers, knowledgeUrlKeys)
169+
170+
/**
171+
* The input is controlled directly by the instant nuqs value; only the URL
172+
* write is debounced. The in-memory filter below still reads a debounced
173+
* value so it doesn't recompute on every keystroke.
174+
*/
175+
const setSearchQuery = useCallback(
176+
(value: string) => {
177+
const next = value.length > 0 ? value : null
178+
setKnowledgeFilters(
179+
{ search: next },
180+
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
181+
)
182+
},
183+
[setKnowledgeFilters]
184+
)
185+
const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS)
186+
187+
/**
188+
* The resolved sort is exposed to the sort menu only when it differs from the
189+
* default, mirroring the prior `null`-means-default semantics.
190+
*/
191+
const activeSort = useMemo(
192+
() =>
193+
sortColumn === DEFAULT_KNOWLEDGE_SORT_COLUMN &&
194+
sortDirection === DEFAULT_KNOWLEDGE_SORT_DIRECTION
195+
? null
196+
: { column: sortColumn, direction: sortDirection },
197+
[sortColumn, sortDirection]
198+
)
153199

154-
const [searchInputValue, setSearchInputValue] = useState('')
155-
const debouncedSearchQuery = useDebounce(searchInputValue, 300)
200+
const setConnectorFilter = useCallback(
201+
(next: string[]) => setKnowledgeFilters({ connector: next }),
202+
[setKnowledgeFilters]
203+
)
204+
const setContentFilter = useCallback(
205+
(next: string[]) => setKnowledgeFilters({ content: next }),
206+
[setKnowledgeFilters]
207+
)
208+
const setOwnerFilter = useCallback(
209+
(next: string[]) => setKnowledgeFilters({ owner: next }),
210+
[setKnowledgeFilters]
211+
)
156212

157213
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
158214

@@ -402,12 +458,12 @@ export function Knowledge() {
402458

403459
const searchConfig: SearchConfig = useMemo(
404460
() => ({
405-
value: searchInputValue,
406-
onChange: setSearchInputValue,
407-
onClearAll: () => setSearchInputValue(''),
461+
value: urlSearchQuery,
462+
onChange: setSearchQuery,
463+
onClearAll: () => setSearchQuery(''),
408464
placeholder: 'Search knowledge bases...',
409465
}),
410-
[searchInputValue]
466+
[urlSearchQuery, setSearchQuery]
411467
)
412468

413469
const sortConfig: SortConfig = useMemo(
@@ -422,10 +478,19 @@ export function Knowledge() {
422478
{ id: 'owner', label: 'Owner' },
423479
],
424480
active: activeSort,
425-
onSort: (column, direction) => setActiveSort({ column, direction }),
426-
onClear: () => setActiveSort(null),
481+
onSort: (column, direction) => {
482+
const sort = (KNOWLEDGE_SORT_COLUMNS as readonly string[]).includes(column)
483+
? (column as KnowledgeSortColumn)
484+
: DEFAULT_KNOWLEDGE_SORT_COLUMN
485+
setKnowledgeFilters({ sort, dir: direction })
486+
},
487+
onClear: () =>
488+
setKnowledgeFilters({
489+
sort: DEFAULT_KNOWLEDGE_SORT_COLUMN,
490+
dir: DEFAULT_KNOWLEDGE_SORT_DIRECTION,
491+
}),
427492
}),
428-
[activeSort]
493+
[activeSort, setKnowledgeFilters]
429494
)
430495

431496
const memberOptions: ChipDropdownOption[] = useMemo(

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
1+
import { Suspense } from 'react'
12
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
23
import type { Metadata } from 'next'
34
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
5+
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
46
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
57
import { Knowledge } from './knowledge'
68

79
export const metadata: Metadata = {
810
title: 'Knowledge Base',
911
}
1012

13+
/**
14+
* Knowledge Base page entry. `Knowledge` reads URL query params via nuqs (which
15+
* uses `useSearchParams` internally), so it must sit under a Suspense boundary.
16+
* The fallback renders the real chrome so a suspend never shows a blank frame;
17+
* the route-level `loading.tsx` covers the navigation/chunk-load transition.
18+
*/
1119
export default async function KnowledgePage({
1220
params,
1321
}: {
@@ -20,7 +28,9 @@ export default async function KnowledgePage({
2028

2129
return (
2230
<HydrationBoundary state={dehydrate(queryClient)}>
23-
<Knowledge />
31+
<Suspense fallback={<KnowledgeLoading />}>
32+
<Knowledge />
33+
</Suspense>
2434
</HydrationBoundary>
2535
)
2636
}

0 commit comments

Comments
 (0)