Skip to content

Commit 016b282

Browse files
committed
feat(nuqs): migrate table-detail sort, KB document filters, and calendar view to URL state
- Table detail: sort+dir to nuqs; Filter stays in useState (recursive/nested, too large for URL) - Knowledge base: search (debounced), enabled filter, sort+dir to nuqs; tagFilterEntries stays in useState (rich rule objects) - Scheduled-tasks calendar: scope + date-only anchor (parseAsIsoDate, nullable, derive-today) to nuqs - Add Suspense boundaries to table-detail and scheduled-tasks pages - Document parseAsIsoDate / nullable-dynamic-default pattern in sim-url-state.md
1 parent a3ca5b2 commit 016b282

9 files changed

Lines changed: 317 additions & 70 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ export const thingsParsers = {
164164

165165
Both carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). The defaults must match the list's existing default sort exactly. If a UI exposes "no active sort" as `null`, derive that in the component (`sort === DEFAULT && dir === DEFAULT ? null : { column, direction }`) — the URL still holds the resolved values. "Clear sort" writes the defaults back (which `clearOnDefault` strips from the URL); never write `null`/garbage columns.
166166

167+
## Dates in the URL (`parseAsIsoDate`)
168+
169+
A date-only param (a calendar anchor, a date filter) uses the built-in `parseAsIsoDate` (`yyyy-MM-dd`) — never serialize a full `Date`/timestamp when only the day matters. When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/search-params.ts` + `hooks/use-calendar.ts`.
170+
167171
## Selected-entity deep-link (store the id, derive the object)
168172

169173
To deep-link a row/modal/drawer to one entity, store **only its id** and look the object up in the already-loaded list — never serialize the object into the URL:

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

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { generateId } from '@sim/utils/id'
77
import { format } from 'date-fns'
88
import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react'
99
import { useParams, useRouter } from 'next/navigation'
10-
import { useQueryState } from 'nuqs'
10+
import { debounce, useQueryState, useQueryStates } from 'nuqs'
1111
import { usePostHog } from 'posthog-js/react'
1212
import {
1313
Badge,
@@ -60,6 +60,11 @@ import {
6060
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
6161
import {
6262
addConnectorParam,
63+
DEFAULT_KB_SORT_COLUMN,
64+
DEFAULT_KB_SORT_DIRECTION,
65+
documentFiltersParsers,
66+
documentFiltersUrlKeys,
67+
type KbSortColumn,
6368
pageParam,
6469
pageUrlKeys,
6570
} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params'
@@ -85,6 +90,7 @@ import {
8590
useUpdateDocument,
8691
useUpdateKnowledgeBase,
8792
} from '@/hooks/queries/kb/knowledge'
93+
import { useDebounce } from '@/hooks/use-debounce'
8894
import { useInlineRename } from '@/hooks/use-inline-rename'
8995
import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return'
9096

@@ -242,9 +248,7 @@ export function KnowledgeBase({
242248
})
243249
const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation()
244250

245-
const [searchQuery, setSearchQuery] = useState('')
246251
const [showTagsModal, setShowTagsModal] = useState(false)
247-
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all')
248252
const [tagFilterEntries, setTagFilterEntries] = useState<
249253
{
250254
id: string
@@ -271,11 +275,6 @@ export function KnowledgeBase({
271275
[tagFilterEntries]
272276
)
273277

274-
const handleSearchChange = useCallback((newQuery: string) => {
275-
setSearchQuery(newQuery)
276-
setCurrentPage(1)
277-
}, [])
278-
279278
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(() => new Set())
280279
const [isSelectAllMode, setIsSelectAllMode] = useState(false)
281280
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
@@ -288,10 +287,48 @@ export function KnowledgeBase({
288287
...pageParam.parser,
289288
...pageUrlKeys,
290289
})
291-
const [activeSort, setActiveSort] = useState<{
292-
column: string
293-
direction: 'asc' | 'desc'
294-
} | null>(null)
290+
291+
const [
292+
{ q: searchQuery, enabled: enabledFilter, sort: sortColumn, dir: sortDirection },
293+
setDocumentFilters,
294+
] = useQueryStates(documentFiltersParsers, documentFiltersUrlKeys)
295+
296+
// The input is controlled directly by the instant nuqs value; only the URL
297+
// write is debounced. The document query below reads a debounced value so it
298+
// doesn't refetch on every keystroke. Changing the search resets pagination.
299+
const handleSearchChange = useCallback(
300+
(newQuery: string) => {
301+
const next = newQuery.length > 0 ? newQuery : null
302+
setDocumentFilters(
303+
{ q: next },
304+
next === null ? undefined : { limitUrlUpdates: debounce(300) }
305+
)
306+
setCurrentPage(1)
307+
},
308+
[setDocumentFilters, setCurrentPage]
309+
)
310+
const debouncedSearchQuery = useDebounce(searchQuery, 300)
311+
312+
/**
313+
* The resolved sort is exposed to the sort menu only when it differs from the
314+
* default, mirroring the prior `null`-means-default semantics.
315+
*/
316+
const activeSort = useMemo(
317+
() =>
318+
sortColumn === DEFAULT_KB_SORT_COLUMN && sortDirection === DEFAULT_KB_SORT_DIRECTION
319+
? null
320+
: { column: sortColumn, direction: sortDirection },
321+
[sortColumn, sortDirection]
322+
)
323+
324+
const setEnabledFilter = useCallback(
325+
(value: 'all' | 'enabled' | 'disabled') => {
326+
setDocumentFilters({ enabled: value })
327+
setCurrentPage(1)
328+
},
329+
[setDocumentFilters, setCurrentPage]
330+
)
331+
295332
const [contextMenuDocument, setContextMenuDocument] = useState<DocumentData | null>(null)
296333
const [showRenameModal, setShowRenameModal] = useState(false)
297334
const [documentToRename, setDocumentToRename] = useState<DocumentData | null>(null)
@@ -335,11 +372,11 @@ export function KnowledgeBase({
335372
updateDocument,
336373
refreshDocuments,
337374
} = useKnowledgeBaseDocuments(id, {
338-
search: searchQuery || undefined,
375+
search: debouncedSearchQuery || undefined,
339376
limit: DOCUMENTS_PER_PAGE,
340377
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
341-
sortBy: (activeSort?.column ?? 'uploadedAt') as DocumentSortField,
342-
sortOrder: (activeSort?.direction ?? 'desc') as SortOrder,
378+
sortBy: sortColumn as DocumentSortField,
379+
sortOrder: sortDirection as SortOrder,
343380
refetchInterval: (data) => {
344381
if (isDeleting) return false
345382
const hasPending = data?.documents?.some(
@@ -857,15 +894,17 @@ export function KnowledgeBase({
857894
],
858895
active: activeSort,
859896
onSort: (column, direction) => {
860-
setActiveSort({ column, direction })
897+
setDocumentFilters({ sort: column as KbSortColumn, dir: direction })
861898
setCurrentPage(1)
862899
},
900+
// Clearing writes the defaults back (stripped by clearOnDefault), so the
901+
// sort menu reads "no active sort" again and the URL stays clean.
863902
onClear: () => {
864-
setActiveSort(null)
903+
setDocumentFilters({ sort: DEFAULT_KB_SORT_COLUMN, dir: DEFAULT_KB_SORT_DIRECTION })
865904
setCurrentPage(1)
866905
},
867906
}),
868-
[activeSort]
907+
[activeSort, setDocumentFilters, setCurrentPage]
869908
)
870909

871910
const filterContent = useMemo(
@@ -879,7 +918,6 @@ export function KnowledgeBase({
879918
variant='ghost'
880919
onClick={() => {
881920
setEnabledFilter('all')
882-
setCurrentPage(1)
883921
setSelectedDocuments(new Set())
884922
setIsSelectAllMode(false)
885923
}}
@@ -895,7 +933,6 @@ export function KnowledgeBase({
895933
onChange={(value) => {
896934
if (value !== 'all' && value !== 'enabled' && value !== 'disabled') return
897935
setEnabledFilter(value)
898-
setCurrentPage(1)
899936
setSelectedDocuments(new Set())
900937
setIsSelectAllMode(false)
901938
}}
@@ -968,7 +1005,6 @@ export function KnowledgeBase({
9681005
label: `Status: ${enabledFilter === 'enabled' ? 'Enabled' : 'Disabled'}`,
9691006
onRemove: () => {
9701007
setEnabledFilter('all')
971-
setCurrentPage(1)
9721008
setSelectedDocuments(new Set())
9731009
setIsSelectAllMode(false)
9741010
},

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { parseAsInteger, parseAsString } from 'nuqs/server'
1+
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
22
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
33

44
/**
@@ -30,3 +30,53 @@ export const pageUrlKeys = {
3030
history: 'replace',
3131
clearOnDefault: true,
3232
} as const
33+
34+
/** Document `enabled` filter buckets, matching the status filter dropdown. */
35+
const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const
36+
37+
/** Sortable document columns, matching the `Resource` sort menu / `DocumentSortField`. */
38+
export const KB_SORT_COLUMNS = [
39+
'filename',
40+
'fileSize',
41+
'tokenCount',
42+
'chunkCount',
43+
'uploadedAt',
44+
'enabled',
45+
] as const
46+
47+
export type KbSortColumn = (typeof KB_SORT_COLUMNS)[number]
48+
49+
const SORT_DIRECTIONS = ['asc', 'desc'] as const
50+
51+
/** Default sort: most-recently-uploaded first (matches the document query default). */
52+
export const DEFAULT_KB_SORT_COLUMN = 'uploadedAt'
53+
export const DEFAULT_KB_SORT_DIRECTION = 'desc'
54+
55+
/**
56+
* Grouped filter/search/sort URL state for the document list.
57+
*
58+
* - `q` is the document name search. The input is controlled directly by the
59+
* instant nuqs value; only its URL write is debounced via `limitUrlUpdates`
60+
* on the setter — never written on every keystroke.
61+
* - `enabled` filters by processing/enabled status (`all` clears from the URL).
62+
* - `sort` / `dir` follow the shared `sort`+`dir` convention. The defaults match
63+
* the document query's default order; "no active sort" is derived in the
64+
* component as `sort === DEFAULT && dir === DEFAULT`.
65+
*
66+
* `tagFilterEntries` is intentionally NOT represented here: it is an array of
67+
* rich filter-rule objects (slot, field type, operator, value, value-to per
68+
* row), too large/structured for the URL per the URL-state doctrine. It stays
69+
* in local `useState`.
70+
*/
71+
export const documentFiltersParsers = {
72+
q: parseAsString.withDefault(''),
73+
enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'),
74+
sort: parseAsStringLiteral(KB_SORT_COLUMNS).withDefault(DEFAULT_KB_SORT_COLUMN),
75+
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KB_SORT_DIRECTION),
76+
} as const
77+
78+
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
79+
export const documentFiltersUrlKeys = {
80+
history: 'replace',
81+
clearOnDefault: true,
82+
} as const

apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22

33
import { useCallback, useEffect, useRef, useState } from 'react'
44
import { isSameDay } from 'date-fns'
5+
import { useQueryStates } from 'nuqs'
56
import { zonedClockDate } from '@/lib/core/utils/timezone'
7+
import {
8+
calendarParsers,
9+
calendarUrlKeys,
10+
} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params'
611
import {
712
advanceAnchor,
813
type CalendarScope,
@@ -40,43 +45,74 @@ export interface UseCalendarReturn {
4045
}
4146

4247
/**
43-
* Owns the calendar's ephemeral view state (scope, anchor, selected slot, and
44-
* create-modal open state). Pure UI state — `useState`, not a store. Opens on
45-
* the `week` scope. "Now" (the today highlight, the anchor's initial day) is
46-
* resolved in `timezone` — the viewer's effective zone — so the calendar's date
47-
* frame matches the zone tasks are scheduled in. `today` is polled so the today
48-
* highlight and current-time column survive midnight without a remount; the poll
49-
* only re-renders when the day actually changes (the interval is resilient to
50-
* device sleep, unlike a one-shot timeout aimed at midnight).
48+
* Owns the calendar's view state. `scope` and `anchor` live in the URL (nuqs) so
49+
* the current view is shareable and survives reload / back-forward; the create
50+
* modal and selected slot stay local (ephemeral UI). Opens on the `week` scope.
51+
* "Now" (the today highlight, the default anchor) is resolved in `timezone` — the
52+
* viewer's effective zone — so the calendar's date frame matches the zone tasks
53+
* are scheduled in. The `anchor` param is date-only and nullable: a clean URL
54+
* means "today", derived per-timezone here, so navigating to today clears the
55+
* param. `today` is polled so the today highlight and current-time column survive
56+
* midnight without a remount; the poll only re-renders when the day actually
57+
* changes (the interval is resilient to device sleep, unlike a one-shot timeout
58+
* aimed at midnight).
5159
*/
5260
export function useCalendar(timezone: string): UseCalendarReturn {
5361
const timezoneRef = useRef(timezone)
5462
const [today, setToday] = useState<Date>(() => zonedClockDate(new Date(), timezone))
55-
const [scope, setScope] = useState<CalendarScope>('week')
56-
const [anchor, setAnchor] = useState<Date>(() => zonedClockDate(new Date(), timezone))
63+
const [{ scope, anchor: anchorParam }, setCalendarState] = useQueryStates(
64+
calendarParsers,
65+
calendarUrlKeys
66+
)
5767
const [selectedSlot, setSelectedSlot] = useState<CalendarSlot | null>(null)
5868
const [isCreateOpen, setIsCreateOpen] = useState(false)
5969
const todayRef = useRef(today)
6070

71+
/** A clean URL (no `anchor` param) means "today", resolved in the effective zone. */
72+
const anchor = anchorParam ?? today
73+
const anchorRef = useRef(anchor)
74+
6175
useEffect(() => {
6276
todayRef.current = today
6377
}, [today])
78+
useEffect(() => {
79+
anchorRef.current = anchor
80+
}, [anchor])
81+
82+
const setScope = useCallback(
83+
(next: CalendarScope) => {
84+
void setCalendarState({ scope: next })
85+
},
86+
[setCalendarState]
87+
)
6488

6589
/**
66-
* Re-sync to the effective zone's current day when `timezone` actually
67-
* changes — e.g. when `useTimezone()` resolves from the browser fallback to
68-
* the saved account zone after mount. The focused day follows only while it is
69-
* still on "today", so an in-progress navigation is preserved. Owning
70-
* `timezoneRef` here (instead of a separate sync effect) keeps the guard
71-
* honest: the ref still reflects the previous zone when this runs.
90+
* Set the focused day. Writing `today` (the default anchor) as `null` keeps the
91+
* URL clean and preserves the "clean URL = today" invariant.
92+
*/
93+
const setAnchorDate = useCallback(
94+
(date: Date) => {
95+
void setCalendarState({ anchor: isSameDay(date, todayRef.current) ? null : date })
96+
},
97+
[setCalendarState]
98+
)
99+
100+
/**
101+
* Re-sync `today` to the effective zone's current day when `timezone` actually
102+
* changes — e.g. when `useTimezone()` resolves from the browser fallback to the
103+
* saved account zone after mount. When the URL holds an explicit anchor that
104+
* was on the previous "today", drop it so the view follows to the new today;
105+
* an in-progress navigation (anchor on another day) is preserved.
72106
*/
73107
useEffect(() => {
74108
if (timezoneRef.current === timezone) return
75109
timezoneRef.current = timezone
76110
const now = zonedClockDate(new Date(), timezone)
77-
setAnchor((current) => (isSameDay(current, todayRef.current) ? now : current))
111+
if (anchorRef.current && isSameDay(anchorRef.current, todayRef.current)) {
112+
void setCalendarState({ anchor: null })
113+
}
78114
setToday(now)
79-
}, [timezone])
115+
}, [timezone, setCalendarState])
80116

81117
useEffect(() => {
82118
const id = setInterval(() => {
@@ -86,15 +122,29 @@ export function useCalendar(timezone: string): UseCalendarReturn {
86122
return () => clearInterval(id)
87123
}, [])
88124

89-
const next = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, 1)), [scope])
90-
const prev = useCallback(() => setAnchor((current) => advanceAnchor(current, scope, -1)), [scope])
91-
const goToday = useCallback(() => setAnchor(zonedClockDate(new Date(), timezoneRef.current)), [])
92-
const goToDate = useCallback((date: Date) => setAnchor(date), [])
125+
const next = useCallback(
126+
() => setAnchorDate(advanceAnchor(anchorRef.current, scope, 1)),
127+
[scope, setAnchorDate]
128+
)
129+
const prev = useCallback(
130+
() => setAnchorDate(advanceAnchor(anchorRef.current, scope, -1)),
131+
[scope, setAnchorDate]
132+
)
133+
const goToday = useCallback(
134+
() => setAnchorDate(zonedClockDate(new Date(), timezoneRef.current)),
135+
[setAnchorDate]
136+
)
137+
const goToDate = useCallback((date: Date) => setAnchorDate(date), [setAnchorDate])
93138

94-
const openDay = useCallback((date: Date) => {
95-
setAnchor(date)
96-
setScope('day')
97-
}, [])
139+
const openDay = useCallback(
140+
(date: Date) => {
141+
void setCalendarState({
142+
anchor: isSameDay(date, todayRef.current) ? null : date,
143+
scope: 'day',
144+
})
145+
},
146+
[setCalendarState]
147+
)
98148

99149
const selectSlot = useCallback((date: Date, time?: string) => {
100150
setSelectedSlot({ date, time })

0 commit comments

Comments
 (0)