Skip to content

Commit 936536e

Browse files
authored
improvement(url-state): platform-wide nuqs audit — migrate remaining view-state, codify conventions (#5851)
* 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 * fix(url-state): review fixes — resolve custom-block deep links before opening detail, per-surface time-range fallback - custom-blocks: gate the detail view on the resolved block (matching mcp/ access-control), so a dead or still-loading ?custom-block-id no longer flashes a bogus create screen - parseAsTimeRange: unknown tokens now parse to null so each surface's .withDefault applies (logs keeps 'All time'; audit-logs keeps 'Past 30 days' instead of silently widening to all time on a malformed link) - audit-logs: date-picker cancel target can never be 'Custom range' itself on a dateless custom deep link - refresh shared ?search= consumer lists in TSDoc * improvement(url-state): platform-wide sweep — migrate the last three view-state stragglers, codify conventions Sweep across landing, workspace, and settings surfaces found only three remaining candidates (everything else verified clean or correctly non-URL): - knowledge document page: chunk enabled-status filter joins page/search/sort in the URL (?enabled=), resetting page in the same write - workflow-mcp-servers: detail Details/Workflows tab deep-links via ?server-tab, cleared alongside the server id on close - byok: provider search binds the shared settings ?search= via a controlled prop pair (modal/embedded consumers keep local state) Rule updates (.claude/rules/sim-url-state.md): shallow defaults documented, urlKeys kebab remapping, throttleMs deprecation, startTransition with shallow:false, shared-parser null-fallback rule, resolve-before-open gating, close-with-replace, and the reusable-component controlled-search pattern. * improvement(url-state): cleanup pass — replace-on-close for the fork activity view, TSDoc form for the logs nullable comment * fix(url-state): honor a custom time range only when both bounds are present A partial ?time-range=custom deep link (missing start/end) now falls back to the default preset window instead of displaying 'Custom range' while querying an unbounded result set. * fix(url-state): verification-round fixes — reject unparseable date params, tighten docs - new parseAsDateString parser (logs + audit-logs): an unparseable ?start-date=/?end-date= now parses to null (missing bound) instead of crashing the audit-logs render via Invalid Date .toISOString(), and hardens the same class in logs - audit-logs: remove the provably dead cancel-revert branch and its ref — the URL only holds 'Custom range' after Apply writes both bounds atomically - workflow-mcp-servers: reset a lingering ?server-tab= when opening a server so a dead deep link can't re-target the next open - knowledge document: drop the unreachable 'N selected' label branch - sim-url-state.md fact-check corrections: cover apps/sim/ee in paths, focusedBlockId -> currentBlockId (the real store field), note that history/clearOnDefault are nuqs v2 defaults, fix the Suspense cross-reference and parseAsIsoDate serialize detail, clarify the *UrlKeys naming convention; list byok in the shared-search consumer docs * fix(url-state): hold first paint while a custom-block deep link can still resolve A valid ?custom-block-id= no longer flashes the list while the blocks query is pending; a dead id still falls back to the list once loaded. * fix(url-state): include permissions loading in the custom-block deep-link paint hold canAdmin reads false while the permissions context loads, so the hold must gate on permissionsLoading too; drop the blocksPending conjunct — it shares one query with useCanPublishCustomBlock, so isLoading already covers it.
1 parent 70814bc commit 936536e

22 files changed

Lines changed: 303 additions & 96 deletions

File tree

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

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ paths:
33
- "apps/sim/app/**/*.tsx"
44
- "apps/sim/app/**/*.ts"
55
- "apps/sim/app/**/search-params.ts"
6+
- "apps/sim/ee/**/*.tsx"
7+
- "apps/sim/ee/**/*.ts"
68
---
79

810
# URL / Query-Param State (nuqs)
@@ -50,11 +52,13 @@ Co-locate a `search-params.ts` next to the feature. Export the parser map (and s
5052

5153
Conventions:
5254

53-
- `.withDefault(...)` on every parser so reads are non-null.
54-
- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn.
55+
- `.withDefault(...)` on every parser so reads are non-null. A deliberately **nullable** parser (dynamic default, custom-range-only dates, nullable sort) must carry a comment saying why.
56+
- Filter / search / toggle / pagination options: `{ history: 'replace', clearOnDefault: true }` — clean URLs, no back-stack churn. Note all three of `history: 'replace'`, `clearOnDefault: true`, and `shallow: true` are already the nuqs v2 defaults — writing the first two explicitly is documentation (and guards the groups whose options differ, e.g. `history: 'push'`), and `shallow: true` may be omitted entirely.
5557
- Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`.
56-
- `shallow: false` **only** when a Server Component / loader must re-read the param.
57-
- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one.
58+
- `shallow: false` **only** when a Server Component / loader must re-read the param. For loading states during the server re-render, pass React's `startTransition` via `.withOptions({ startTransition, shallow: false })`.
59+
- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. When the parser-map key is camelCase (for clean destructuring), remap the wire key via the `urlKeys` option in the shared options object (see `files/search-params.ts` `uploadedBy: 'uploaded-by'`, `ee/audit-logs/search-params.ts` `timeRange: 'time-range'`); nuqs also exports a `UrlKeys<typeof parsers>` type helper for standalone mappings.
60+
- `throttleMs` is deprecated in nuqs — rate-limit URL writes with `limitUrlUpdates: throttle(ms)` / `debounce(ms)` (the debounced-search hook below already does this).
61+
- A parser **shared across surfaces with different defaults** (e.g. `parseAsTimeRange`) must `parse` unknown tokens to `null` — never to one surface's default — so each consumer's `.withDefault(...)` decides the fallback.
5862
- For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers).
5963
- A `createParser` for a value **not** comparable with `===` (arrays, objects, `Date`) **must** define an `eq``clearOnDefault` uses it to detect the default, so without it an empty-array/object default never strips from the URL. Built-in `parseAsArrayOf(...)` already ships its own `eq`; only string/number/boolean custom parsers can omit it. Example (array): `eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])`.
6064

@@ -75,11 +79,12 @@ export const thingsParsers = {
7579
/** Clean URLs, no back-stack churn for filter changes. */
7680
export const thingsUrlKeys = {
7781
history: 'replace',
78-
shallow: true,
7982
clearOnDefault: true,
8083
} as const
8184
```
8285

86+
(The `*UrlKeys` suffix is the repo's naming convention for a feature's shared **options** object — which may itself contain a nuqs `urlKeys` key-remapping entry; the two are different things.)
87+
8388
### Client — `useQueryStates` (grouped) / `useQueryState` (single)
8489

8590
```typescript
@@ -182,7 +187,7 @@ Sort params live alongside — not inside — the feature's grouped filter parse
182187

183188
A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters.
184189

185-
**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate``new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`).
190+
**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString().slice(0, 10)`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate``new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`).
186191

187192
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/hooks/use-calendar.ts`.
188193

@@ -200,7 +205,11 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
200205
const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null
201206
```
202207

203-
Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`.
208+
Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`.
209+
210+
**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp.tsx`, `workflow-mcp-servers.tsx`, access-control, custom-blocks, forks). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update.
211+
212+
**Reusable components** rendered both as a settings/list page and inside a modal (e.g. `BYOKKeyManager`) expose an optional controlled `searchTerm`/`onSearchTermChange` prop pair: the page consumer binds the URL (`useSettingsSearch()`), modal consumers omit the props and keep local state. Never bind URL state from inside a component that can mount in a non-destination context.
204213

205214
## Read-then-strip deep links
206215

@@ -217,8 +226,8 @@ The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/so
217226

218227
Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery:
219228

220-
- **Panel `activeTab`** and **`canvasMode`**persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load.
221-
- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep.
229+
- **Panel `activeTab`** — a persisted local *preference* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`); moving it would unwind that machinery and risk tab-flash on load. **Canvas mode** (`mode` on `useCanvasModeStore`) is likewise a persisted layout preference, not a destination.
230+
- **The panel editor's `currentBlockId`** (`stores/panel/editor/store.ts` — a would-be "look at this block" deep link) — the only genuinely shareable candidate, but it is persisted and entangled with panel-open orchestration. Adding a URL param for it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep.
222231

223232
Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas.
224233

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/[id]/[documentId]/document.tsx

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,12 @@ export function Document({
137137
const { workspaceId } = useParams()
138138
const router = useRouter()
139139
const [
140-
{ page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery },
140+
{
141+
page: currentPageFromURL,
142+
chunk: chunkFromURL,
143+
search: searchQuery,
144+
enabled: enabledFilterParam,
145+
},
141146
setDocumentParams,
142147
] = useQueryStates(documentParsers, documentUrlKeys)
143148
const userPermissions = useUserPermissionsContext()
@@ -160,16 +165,31 @@ export function Document({
160165
)
161166
/** Raw URL value drives the input; the chunk search query always sees it trimmed. */
162167
const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim()
163-
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
164168
const {
165169
activeSort,
166170
onSort: onSortColumn,
167171
onClear: onClearSort,
168172
} = useUrlSort(documentChunkSortParams, documentUrlKeys)
169173

170-
const enabledFilterParam = useMemo(
171-
() => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'),
172-
[enabledFilter]
174+
/** Multi-select UI view of the scalar `enabled` param (`all` ↔ nothing selected). */
175+
const enabledFilter = useMemo<string[]>(
176+
() => (enabledFilterParam === 'all' ? [] : [enabledFilterParam]),
177+
[enabledFilterParam]
178+
)
179+
180+
/**
181+
* Collapses the dropdown's multi-select values to the scalar param (one value
182+
* filters; none or both mean `all`) and resets `page` in the same write so a
183+
* filter change always lands on the first page.
184+
*/
185+
const setEnabledFilter = useCallback(
186+
(values: string[]) => {
187+
void setDocumentParams({
188+
enabled: values.length === 1 ? (values[0] as 'enabled' | 'disabled') : null,
189+
page: null,
190+
})
191+
},
192+
[setDocumentParams]
173193
)
174194

175195
const {
@@ -619,8 +639,7 @@ export function Document({
619639

620640
const enabledDisplayLabel = useMemo(() => {
621641
if (enabledFilter.length === 0) return 'All'
622-
if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
623-
return `${enabledFilter.length} selected`
642+
return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
624643
}, [enabledFilter])
625644

626645
const filterContent = useMemo(
@@ -638,7 +657,6 @@ export function Document({
638657
onMultiSelectChange={(values) => {
639658
setEnabledFilter(values)
640659
setSelectedChunks(new Set())
641-
void goToPage(1)
642660
}}
643661
overlayContent={
644662
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
@@ -654,7 +672,6 @@ export function Document({
654672
onClick={() => {
655673
setEnabledFilter([])
656674
setSelectedChunks(new Set())
657-
void goToPage(1)
658675
}}
659676
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
660677
>
@@ -663,20 +680,19 @@ export function Document({
663680
)}
664681
</div>
665682
),
666-
[enabledFilter, enabledDisplayLabel, goToPage]
683+
[enabledFilter, enabledDisplayLabel, setEnabledFilter]
667684
)
668685

669686
const filterTags: FilterTag[] = useMemo(
670687
() =>
671688
enabledFilter.map((value) => ({
672689
label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`,
673690
onRemove: () => {
674-
setEnabledFilter((prev) => prev.filter((v) => v !== value))
691+
setEnabledFilter(enabledFilter.filter((v) => v !== value))
675692
setSelectedChunks(new Set())
676-
void goToPage(1)
677693
},
678694
})),
679-
[enabledFilter, goToPage]
695+
[enabledFilter, setEnabledFilter]
680696
)
681697

682698
const handleChunkClick = useCallback(

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { parseAsInteger, parseAsString } from 'nuqs/server'
1+
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
22
import { createSortParams } from '@/lib/url-state'
33

4+
/** Chunk `enabled` filter buckets, matching the status filter dropdown. */
5+
const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const
6+
47
/** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */
58
export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const
69

@@ -24,11 +27,14 @@ export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS)
2427
* - `search` is the chunk content search. The input is controlled directly by
2528
* the instant nuqs value; only its URL write is debounced via
2629
* `useDebouncedSearchSetter` — never written on every keystroke.
30+
* - `enabled` filters chunks by enabled status (`all` clears from the URL),
31+
* mirroring the same filter on the knowledge base document list.
2732
*/
2833
export const documentParsers = {
2934
page: parseAsInteger.withDefault(1),
3035
chunk: parseAsString,
3136
search: parseAsString.withDefault(''),
37+
enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'),
3238
} as const
3339

3440
/**

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: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,13 @@ const TOKEN_TO_TIME_RANGE: Record<string, TimeRange> = Object.fromEntries(
4141
) as Record<string, TimeRange>
4242

4343
/**
44-
* Parser for the `timeRange` param. Serializes labels to kebab tokens and
45-
* tolerantly maps unknown tokens back to the default ("All time").
44+
* Parser for the `timeRange` param. Serializes labels to kebab tokens. Unknown
45+
* tokens parse to `null` so each consuming surface's `.withDefault(...)` decides
46+
* the fallback (logs: "All time"; audit-logs: "Past 30 days").
4647
*/
4748
export const parseAsTimeRange = createParser<TimeRange>({
4849
parse(value) {
49-
return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE
50+
return TOKEN_TO_TIME_RANGE[value] ?? null
5051
},
5152
serialize(value) {
5253
return TIME_RANGE_TO_TOKEN[value] ?? 'all-time'
@@ -76,6 +77,21 @@ export const parseAsLogLevel = createParser<LogLevel>({
7677
},
7778
})
7879

80+
/**
81+
* Parser for free-form date/datetime strings (`startDate`/`endDate`). Rejects
82+
* unparseable values at the URL boundary — an invalid date string reaching
83+
* `new Date(...).toISOString()` throws, so a malformed deep link must parse to
84+
* `null` (treated as a missing bound) instead of crashing the consumer.
85+
*/
86+
export const parseAsDateString = createParser<string>({
87+
parse(value) {
88+
return Number.isNaN(Date.parse(value)) ? null : value
89+
},
90+
serialize(value) {
91+
return value
92+
},
93+
})
94+
7995
const CORE_TRIGGER_SET = new Set<string>(CORE_TRIGGER_TYPES)
8096

8197
/**
@@ -104,8 +120,12 @@ export const parseAsTriggers = createParser<TriggerType[]>({
104120
*/
105121
export const logFilterParsers = {
106122
timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE),
107-
startDate: parseAsString,
108-
endDate: parseAsString,
123+
/**
124+
* Deliberately nullable: only populated when timeRange is "Custom range";
125+
* every preset range derives its window from the label instead.
126+
*/
127+
startDate: parseAsDateString,
128+
endDate: parseAsDateString,
109129
level: parseAsLogLevel.withDefault('all'),
110130
workflowIds: parseAsArrayOf(parseAsString).withDefault([]),
111131
folderIds: parseAsArrayOf(parseAsString).withDefault([]),

0 commit comments

Comments
 (0)