Skip to content

Commit 26be826

Browse files
committed
improvement(url-state): migrate ee settings sections to nuqs deep-linkable view-state
- audit-logs: types/time-range/start-date/end-date filters move to a co-located search-params.ts (reusing the logs kebab-token time-range parser); search binds to the shared settings ?search= via useSettingsSearch, replacing a hand-rolled debounce effect - access-control: group detail deep-links via ?group-id (push/replace-on-close); search via useSettingsSearch - custom-blocks: block detail deep-links via ?custom-block-id; create flow stays local; search via useSettingsSearch - data-drains + forks: search via useSettingsSearch; forks close now replaces history like the mcp reference pattern - polish: nullable-reason comment on logs startDate/endDate, stale debounce TSDoc now references useDebouncedSearchSetter
1 parent a0a437d commit 26be826

14 files changed

Lines changed: 135 additions & 54 deletions

File tree

apps/sim/app/(landing)/integrations/search-params.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server'
66
* so the filtered view is server-rendered for shareable, crawlable `?category=`/`?q=`
77
* URLs — the same SSR pattern the blog index uses.
88
*
9-
* - `q` is the search filter; its URL write is debounced on the setter, never
10-
* written per keystroke.
9+
* - `q` is the search filter; its URL write is debounced via
10+
* `useDebouncedSearchSetter`, never written per keystroke.
1111
* - `category` filters by integration type (`''` = all).
1212
*/
1313
export const integrationsParsers = {

apps/sim/app/(landing)/models/search-params.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server'
66
* so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=`
77
* URLs — the same SSR pattern the blog index uses.
88
*
9-
* - `q` is the search filter; its URL write is debounced on the setter, never
10-
* written per keystroke.
9+
* - `q` is the search filter; its URL write is debounced via
10+
* `useDebouncedSearchSetter`, never written per keystroke.
1111
* - `provider` filters by provider id (`''` = all).
1212
*/
1313
export const modelsParsers = {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export const CONNECTED_LABEL = 'Connected'
1515
* pseudo-categories and are derived from the data set, so a plain string is
1616
* used; the `All` default clears from the URL.
1717
* - `search` is the integration search term. The input is controlled directly by
18-
* the nuqs value; only its URL write is debounced via `limitUrlUpdates`
19-
* (`debounce`) on the setter — never written on every keystroke.
18+
* the nuqs value; only its URL write is debounced via
19+
* `useDebouncedSearchSetter` — never written on every keystroke.
2020
*/
2121
export const integrationsParsers = {
2222
category: parseAsString.withDefault(ALL_CATEGORY),

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, {
2727
*
2828
* - `search` is the knowledge base name/description filter. The input is
2929
* controlled directly by the instant nuqs value; only its URL write is
30-
* debounced via `limitUrlUpdates` (`debounce`) on the setter — never written
31-
* on every keystroke.
30+
* debounced via `useDebouncedSearchSetter` — never written on every
31+
* keystroke.
3232
* - `connector` filters by connector presence; `content` filters by document
3333
* presence; `owner` filters by creator id. All are multi-select arrays.
3434
*

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export const parseAsTriggers = createParser<TriggerType[]>({
104104
*/
105105
export const logFilterParsers = {
106106
timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE),
107+
// Deliberately nullable: only populated when timeRange is "Custom range";
108+
// every preset range derives its window from the label instead.
107109
startDate: parseAsString,
108110
endDate: parseAsString,
109111
level: parseAsLogLevel.withDefault('all'),

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,37 @@ export const forkViewUrlKeys = {
4949
clearOnDefault: true,
5050
} as const
5151

52+
/**
53+
* `group-id` deep-links the Access Control settings tab to a specific
54+
* permission group's detail sub-view (mirrors `mcpServerId` on the MCP tab).
55+
*/
56+
export const groupIdParam = {
57+
key: 'group-id',
58+
parser: parseAsString,
59+
} as const
60+
61+
/** Opening a group's detail is a destination → push to history; clear on close. */
62+
export const groupIdUrlKeys = {
63+
history: 'push',
64+
clearOnDefault: true,
65+
} as const
66+
67+
/**
68+
* `custom-block-id` deep-links the Custom Blocks settings tab to a specific
69+
* block's detail sub-view. The "create new" flow stays in local state — only
70+
* existing entities are deep-linkable.
71+
*/
72+
export const customBlockIdParam = {
73+
key: 'custom-block-id',
74+
parser: parseAsString,
75+
} as const
76+
77+
/** Opening a block's detail is a destination → push to history; clear on close. */
78+
export const customBlockIdUrlKeys = {
79+
history: 'push',
80+
clearOnDefault: true,
81+
} as const
82+
5283
/**
5384
* `fork-direction` is the sync direction (push/pull) on the parent fork's detail
5485
* page — shareable view state, so a copied link opens the same side of the sync.

apps/sim/app/workspace/[workspaceId]/settings/components/inbox/search-params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export type InboxStatusFilter = (typeof INBOX_STATUS_FILTERS)[number]
1818
* - `status` is the active status filter (feeds the tasks query key).
1919
* - `search` is the subject/sender/body name filter. The input is controlled
2020
* directly by the nuqs value; only its URL write is debounced via
21-
* `limitUrlUpdates` (`debounce`) on the setter — never written per keystroke.
21+
* `useDebouncedSearchSetter` — never written per keystroke.
2222
*/
2323
export const inboxTaskParsers = {
2424
status: parseAsStringLiteral(INBOX_STATUS_FILTERS).withDefault('all'),

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,7 @@ export const skillIdUrlKeys = {
2323
/**
2424
* `search` filters the skills list by name/description. The input is controlled
2525
* directly by the instant nuqs value; only its URL write is debounced via
26-
* `limitUrlUpdates` (`debounce`) on the setter — never written on every
27-
* keystroke.
26+
* `useDebouncedSearchSetter` — never written on every keystroke.
2827
*/
2928
export const skillSearchParam = {
3029
key: 'search',

apps/sim/ee/access-control/components/access-control.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,17 @@ import { createLogger } from '@sim/logger'
1616
import { getErrorMessage } from '@sim/utils/errors'
1717
import { ArrowRight, Plus } from 'lucide-react'
1818
import { useParams } from 'next/navigation'
19+
import { useQueryState } from 'nuqs'
1920
import { isEnterprise } from '@/lib/billing/plan-helpers'
2021
import { getEnv, isTruthy } from '@/lib/core/config/env'
22+
import {
23+
groupIdParam,
24+
groupIdUrlKeys,
25+
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
2126
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
2227
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
2328
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
29+
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
2430
import { GroupDetail } from '@/ee/access-control/components/group-detail'
2531
import { WorkspaceSelect } from '@/ee/access-control/components/workspace-select'
2632
import {
@@ -74,8 +80,11 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
7480

7581
const createPermissionGroup = useCreatePermissionGroup()
7682

77-
const [searchTerm, setSearchTerm] = useState('')
78-
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null)
83+
const [searchTerm, setSearchTerm] = useSettingsSearch()
84+
const [selectedGroupId, setSelectedGroupId] = useQueryState(groupIdParam.key, {
85+
...groupIdParam.parser,
86+
...groupIdUrlKeys,
87+
})
7988
const [showCreateModal, setShowCreateModal] = useState(false)
8089
const [newGroupName, setNewGroupName] = useState('')
8190
const [newGroupDescription, setNewGroupDescription] = useState('')
@@ -160,8 +169,8 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
160169
workspaceOptions={workspaceOptions}
161170
organizationWorkspaces={organizationWorkspaces}
162171
workspacesLoading={workspacesLoading}
163-
onBack={() => setSelectedGroupId(null)}
164-
onDeleted={() => setSelectedGroupId(null)}
172+
onBack={() => void setSelectedGroupId(null, { history: 'replace' })}
173+
onDeleted={() => void setSelectedGroupId(null, { history: 'replace' })}
165174
/>
166175
)
167176
}
@@ -198,7 +207,7 @@ export function AccessControl({ isOrganizationAdmin, organizationId }: AccessCon
198207
<button
199208
key={group.id}
200209
type='button'
201-
onClick={() => setSelectedGroupId(group.id)}
210+
onClick={() => void setSelectedGroupId(group.id)}
202211
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
203212
>
204213
<div className='flex min-w-0 flex-1 flex-col'>

apps/sim/ee/audit-logs/components/audit-logs.tsx

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import {
2020
import { createLogger } from '@sim/logger'
2121
import { formatDateTime } from '@sim/utils/formatting'
2222
import { isRecordLike } from '@sim/utils/object'
23+
import { useQueryStates } from 'nuqs'
2324
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
25+
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
2426
import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format'
2527
import { formatDateShort } from '@/app/workspace/[workspaceId]/logs/utils'
2628
import {
@@ -29,8 +31,11 @@ import {
2931
} from '@/app/workspace/[workspaceId]/settings/components/activity-log'
3032
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
3133
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
34+
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
3235
import { RESOURCE_TYPE_OPTIONS } from '@/ee/audit-logs/constants'
3336
import { type AuditLogFilters, useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs'
37+
import { auditLogFilterParsers, auditLogFilterUrlKeys } from '@/ee/audit-logs/search-params'
38+
import { useDebounce } from '@/hooks/use-debounce'
3439
import type { TimeRange } from '@/stores/logs/filters/types'
3540

3641
const logger = createLogger('AuditLogs')
@@ -228,31 +233,19 @@ interface AuditLogsProps {
228233
}
229234

230235
export function AuditLogs({ organizationId }: AuditLogsProps) {
231-
const [selectedTypes, setSelectedTypes] = useState<string[]>([])
232-
const [timeRange, setTimeRange] = useState<TimeRange>('Past 30 days')
233-
const [customStartDate, setCustomStartDate] = useState('')
234-
const [customEndDate, setCustomEndDate] = useState('')
236+
const [urlFilters, setUrlFilters] = useQueryStates(auditLogFilterParsers, auditLogFilterUrlKeys)
237+
const { types: selectedTypes, timeRange } = urlFilters
238+
const customStartDate = urlFilters.startDate ?? ''
239+
const customEndDate = urlFilters.endDate ?? ''
235240
const [datePickerOpen, setDatePickerOpen] = useState(false)
236-
const previousTimeRangeRef = useRef<TimeRange>('Past 30 days')
241+
const previousTimeRangeRef = useRef<TimeRange>(timeRange)
237242
const dateRangeAppliedRef = useRef(false)
238-
const [searchTerm, setSearchTerm] = useState('')
239-
const [debouncedSearch, setDebouncedSearch] = useState('')
240-
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
243+
const [searchTerm, setSearchTerm] = useSettingsSearch()
244+
const debouncedSearch = useDebounce(searchTerm, SEARCH_DEBOUNCE_MS).trim()
241245
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
242246
const refreshTimersRef = useRef(new Set<number>())
243247
const [isExporting, setIsExporting] = useState(false)
244248

245-
useEffect(() => {
246-
const trimmed = searchTerm.trim()
247-
if (trimmed === debouncedSearch) return
248-
debounceRef.current = setTimeout(() => {
249-
setDebouncedSearch(trimmed)
250-
}, 300)
251-
return () => {
252-
if (debounceRef.current) clearTimeout(debounceRef.current)
253-
}
254-
}, [searchTerm, debouncedSearch])
255-
256249
useEffect(() => {
257250
const timers = refreshTimersRef.current
258251
return () => {
@@ -301,23 +294,19 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
301294
previousTimeRangeRef.current = timeRange
302295
setDatePickerOpen(true)
303296
} else {
304-
setCustomStartDate('')
305-
setCustomEndDate('')
306-
setTimeRange(value as TimeRange)
297+
void setUrlFilters({ timeRange: value as TimeRange, startDate: null, endDate: null })
307298
}
308299
}
309300

310301
const handleDateRangeApply = (start: string, end: string) => {
311302
dateRangeAppliedRef.current = true
312-
setCustomStartDate(start)
313-
setCustomEndDate(end)
314-
setTimeRange('Custom range')
303+
void setUrlFilters({ timeRange: 'Custom range', startDate: start, endDate: end })
315304
setDatePickerOpen(false)
316305
}
317306

318307
const handleDatePickerCancel = () => {
319308
if (timeRange === 'Custom range' && !customStartDate) {
320-
setTimeRange(previousTimeRangeRef.current)
309+
void setUrlFilters({ timeRange: previousTimeRangeRef.current })
321310
}
322311
setDatePickerOpen(false)
323312
}
@@ -399,7 +388,7 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
399388
options={RESOURCE_TYPE_OPTIONS}
400389
multiSelect
401390
multiSelectValues={selectedTypes}
402-
onMultiSelectChange={setSelectedTypes}
391+
onMultiSelectChange={(values) => void setUrlFilters({ types: values })}
403392
placeholder='All types'
404393
displayLabel={typeDisplayLabel}
405394
searchable

0 commit comments

Comments
 (0)