mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(url-state): shared sort/search url-state helpers + task and log selection deep links (#5652)
* improvement(url-state): shared sort/search url-state helpers + task and log selection deep links * improvement(url-state): audit fixes — cancel pending deep-link on click, void settings search setter * fix(url-state): review fixes — trim-on-read filters, replace-on-close for task modal, unified executionId write path * improvement(url-state): final audit polish — consistent handler deps, document schedules-refetch invariant * improvement(url-state): compose useSettingsSearch on the shared setter, trim chunk query read * chore(skills): sync url-state skill edits through the canonical SKILL.md * fix(url-state): reset chunk page in the same write as a search change
This commit is contained in:
@@ -16,6 +16,10 @@ User arguments: $ARGUMENTS
|
||||
|
||||
Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`.
|
||||
|
||||
Shared helpers own the two repeated wirings — never hand-roll them inline:
|
||||
- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column.
|
||||
- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read.
|
||||
|
||||
`.claude/rules/sim-url-state.md` is the source of truth — read it first.
|
||||
|
||||
## References
|
||||
@@ -33,14 +37,15 @@ Read these before analyzing:
|
||||
3. **`window.history.replaceState`/`pushState`** to mutate a param.
|
||||
4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it.
|
||||
5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`).
|
||||
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand.
|
||||
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand.
|
||||
7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL.
|
||||
8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `<Suspense>` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback.
|
||||
9. **`import { z }` for param validation in client code**: use nuqs parsers instead.
|
||||
10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines
|
||||
2. Analyze the specified scope for the anti-patterns listed above
|
||||
3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state
|
||||
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
|
||||
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
|
||||
|
||||
@@ -15,6 +15,10 @@ User arguments: $ARGUMENTS
|
||||
|
||||
Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`.
|
||||
|
||||
Shared helpers own the two repeated wirings — never hand-roll them inline:
|
||||
- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column.
|
||||
- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read.
|
||||
|
||||
`.claude/rules/sim-url-state.md` is the source of truth — read it first.
|
||||
|
||||
## References
|
||||
@@ -32,14 +36,15 @@ Read these before analyzing:
|
||||
3. **`window.history.replaceState`/`pushState`** to mutate a param.
|
||||
4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it.
|
||||
5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`).
|
||||
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand.
|
||||
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand.
|
||||
7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL.
|
||||
8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `<Suspense>` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback.
|
||||
9. **`import { z }` for param validation in client code**: use nuqs parsers instead.
|
||||
10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines
|
||||
2. Analyze the specified scope for the anti-patterns listed above
|
||||
3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state
|
||||
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
|
||||
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
|
||||
|
||||
@@ -127,42 +127,56 @@ If a client param must be re-read server-side after a change, set `shallow: fals
|
||||
|
||||
## Debounced text inputs
|
||||
|
||||
Use nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options) — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect + a ref-guarded URL→local reconcile effect. The hook's returned value updates instantly (so the input is controlled directly by the nuqs value and stays snappy); only the *URL write* is debounced. Back/forward and deep links flow back natively because the input reads the nuqs value — no reconcile effect needed.
|
||||
Use `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect, and never hand-roll the debounce wiring inline. The nuqs value updates instantly (the input is controlled directly by it and stays snappy); only the *URL write* is debounced via nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options), which the hook applies for you. Clearing (or a whitespace-only value) writes `null` immediately so the param strips without lingering.
|
||||
|
||||
- **Standalone single search param** (`useQueryState`): put `limitUrlUpdates: debounce(300)` in the param's options.
|
||||
- **Search inside a grouped `useQueryStates`**: keep the group's immediate writes for the discrete filters; pass the option **per call** only on the search setter, never on the whole group:
|
||||
```typescript
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
```typescript
|
||||
import { debounce } from 'nuqs'
|
||||
// Search inside a grouped useQueryStates — the group's discrete filters keep immediate writes:
|
||||
const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ search: value }, options))
|
||||
|
||||
const setSearch = useCallback(
|
||||
(value: string) => {
|
||||
const next = value.length > 0 ? value : null
|
||||
// Immediate update when clearing so the param drops out without lingering.
|
||||
setFilters({ search: next }, next === null ? undefined : { limitUrlUpdates: debounce(300) })
|
||||
},
|
||||
[setFilters]
|
||||
)
|
||||
```
|
||||
// Standalone single param — pass the useQueryState setter directly:
|
||||
const setSearch = useDebouncedSearchSetter(setSearchParam)
|
||||
|
||||
- **Keep fetches/filtering debounced.** Where the search value feeds a React Query key or an expensive in-memory filter, derive a debounced value off the instant nuqs value (`const debounced = useDebounce(urlSearch, 300)`) and feed *that* to the query — the instant value is only for the input box. Cheap in-memory filtering over a small static list may read the instant value directly.
|
||||
- Preserve `.trim()` handling, `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. Import `debounce` from `nuqs` (client) — not `nuqs/server`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations/recently-deleted (cheap in-memory filter, instant value), and tables (filter stays debounced).
|
||||
// Non-default window (e.g. files' 200ms):
|
||||
const setSearch = useDebouncedSearchSetter(write, { debounceMs: 200 })
|
||||
```
|
||||
|
||||
- **Never write a trimmed value to a param that controls the input** — trimming on write eats the user's trailing space mid-typing and makes multi-word queries untypable. The hook writes the raw value; trim only for the empty-check (the hook does this) and on *read* where the value feeds a query or filter.
|
||||
- **Keep fetches/filtering debounced.** Where the search value feeds a React Query key or an expensive in-memory filter, derive a debounced value off the instant nuqs value (`const debounced = useDebounce(urlSearch, SEARCH_DEBOUNCE_MS)` with `SEARCH_DEBOUNCE_MS` from `@/lib/url-state`) and feed *that* to the query — the instant value is only for the input box. Cheap in-memory filtering over a small static list may read the instant value directly.
|
||||
- Settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search` — the shared `?search=` binding for that surface.
|
||||
- Preserve `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations (cheap in-memory filter, instant value), and tables (filter stays debounced).
|
||||
|
||||
## Sort convention (`sort` + `dir`)
|
||||
|
||||
Sortable lists use **two scalar params**, never a serialized `{column,direction}` object:
|
||||
Sortable lists use **two scalar params**, never a serialized `{column,direction}` object. Build them with `createSortParams` from `@/lib/url-state` (in the feature's `search-params.ts`) and consume them with `useUrlSort` from `@/hooks/use-url-sort` — never re-declare `SORT_DIRECTIONS`/default constants or hand-roll the `activeSort`/`onSort`/`onClear` wiring:
|
||||
|
||||
```typescript
|
||||
const SORT_COLUMNS = ['name', 'created', 'updated'] as const
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
// search-params.ts (server-safe)
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
export const thingsParsers = {
|
||||
sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'),
|
||||
} as const
|
||||
const THING_SORT_COLUMNS = ['name', 'created', 'updated'] as const
|
||||
|
||||
export const thingsSortParams = createSortParams(THING_SORT_COLUMNS, {
|
||||
column: 'updated',
|
||||
direction: 'desc',
|
||||
})
|
||||
```
|
||||
|
||||
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.
|
||||
```typescript
|
||||
// component (client)
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
|
||||
const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams, thingsUrlKeys)
|
||||
// activeSort/onSort/onClear plug straight into SortConfig; sort/dir feed query keys and comparators.
|
||||
```
|
||||
|
||||
Two modes, chosen by whether you pass a default:
|
||||
|
||||
- **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state.
|
||||
- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s).
|
||||
|
||||
Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there.
|
||||
|
||||
## Dates in the URL (date-only params)
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ User arguments: $ARGUMENTS
|
||||
|
||||
Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`.
|
||||
|
||||
Shared helpers own the two repeated wirings — never hand-roll them inline:
|
||||
- Sort: `createSortParams` from `@/lib/url-state` (in `search-params.ts`) + `useUrlSort` from `@/hooks/use-url-sort` (in the component) — defaulted mode for lists with a fixed default ordering, nullable mode when "no active sort" is distinct from the default column.
|
||||
- Debounced search: `useDebouncedSearchSetter` from `@/hooks/use-debounced-search-setter` (grouped or single-param); settings list search boxes use `useSettingsSearch()` from `settings/components/use-settings-search`. Never write a trimmed value to a param that controls the input — trim on read.
|
||||
|
||||
`.claude/rules/sim-url-state.md` is the source of truth — read it first.
|
||||
|
||||
## References
|
||||
@@ -27,14 +31,15 @@ Read these before analyzing:
|
||||
3. **`window.history.replaceState`/`pushState`** to mutate a param.
|
||||
4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it.
|
||||
5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`).
|
||||
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand.
|
||||
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search via `useDebouncedSearchSetter` (never a local `useState` mirror + reconcile effect, and never inline `limitUrlUpdates` wiring); keep canvas/presence/resize state in Zustand.
|
||||
7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL.
|
||||
8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `<Suspense>` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback.
|
||||
9. **`import { z }` for param validation in client code**: use nuqs parsers instead.
|
||||
10. **Re-implemented shared wiring**: a hand-rolled `SORT_DIRECTIONS`/default-sort constants/`activeSort` derivation instead of `createSortParams` + `useUrlSort`, or an inline debounced-search setter instead of `useDebouncedSearchSetter`/`useSettingsSearch`.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines
|
||||
2. Analyze the specified scope for the anti-patterns listed above
|
||||
3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state
|
||||
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
|
||||
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)` — sort via `createSortParams` + `useUrlSort`, search via `useDebouncedSearchSetter` — add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import {
|
||||
blockTypeToIconMap,
|
||||
formatIntegrationType,
|
||||
@@ -13,9 +13,7 @@ import {
|
||||
integrationsParsers,
|
||||
integrationsUrlKeys,
|
||||
} from '@/app/(landing)/integrations/search-params'
|
||||
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
const PILL_BASE =
|
||||
'rounded-[5px] border border-[var(--border-1)] px-[9px] py-0.5 text-small text-[var(--text-primary)] transition-colors' as const
|
||||
@@ -33,6 +31,9 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
)
|
||||
const activeCategory = category || null
|
||||
|
||||
/** Debounced `q` URL write; the input stays instant and clearing strips the param immediately. */
|
||||
const setQuery = useDebouncedSearchSetter((value, options) => setParams({ q: value }, options))
|
||||
|
||||
/** Category facets, derived once from the (stable) integration list. */
|
||||
const availableCategories = useMemo(() => {
|
||||
const counts = new Map<string, number>()
|
||||
@@ -66,9 +67,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
|
||||
type='search'
|
||||
placeholder='Search integrations, tools, or triggers…'
|
||||
value={query}
|
||||
onChange={(e) =>
|
||||
setParams({ q: e.target.value }, { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) })
|
||||
}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
aria-label='Search integrations'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ChipInput, Search } from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { ChevronArrow } from '@/app/(landing)/components/chevron-arrow'
|
||||
import { ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
|
||||
import { modelsParsers, modelsUrlKeys } from '@/app/(landing)/models/search-params'
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
MODEL_PROVIDERS_WITH_CATALOGS,
|
||||
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
|
||||
} from '@/app/(landing)/models/utils'
|
||||
|
||||
/** Debounce window for writing the search term to the URL (filtering is instant). */
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
const PROVIDER_OPTIONS = MODEL_PROVIDERS_WITH_CATALOGS.map((provider) => ({
|
||||
id: provider.id,
|
||||
@@ -29,6 +27,9 @@ export function ModelDirectory() {
|
||||
const [{ q: query, provider }, setParams] = useQueryStates(modelsParsers, modelsUrlKeys)
|
||||
const activeProviderId = provider || null
|
||||
|
||||
/** Debounced `q` URL write; the input stays instant and clearing strips the param immediately. */
|
||||
const setQuery = useDebouncedSearchSetter((value, options) => setParams({ q: value }, options))
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase()
|
||||
|
||||
const { filteredProviders, filteredDynamicProviders } = useMemo(() => {
|
||||
@@ -90,12 +91,7 @@ export function ModelDirectory() {
|
||||
type='search'
|
||||
placeholder='Search models, providers, or capabilities…'
|
||||
value={query}
|
||||
onChange={(event) =>
|
||||
setParams(
|
||||
{ q: event.target.value },
|
||||
{ limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label='Search AI models'
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { Download, Send } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage, toError } from '@sim/utils/errors'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { useLimitUpgradeToast } from '@/lib/billing/client'
|
||||
@@ -76,11 +76,10 @@ import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/compon
|
||||
import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal'
|
||||
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
|
||||
import {
|
||||
FILE_SORT_COLUMNS,
|
||||
type FileSortColumn,
|
||||
filesFilterParsers,
|
||||
filesFilterUrlKeys,
|
||||
filesParsers,
|
||||
filesSortParams,
|
||||
filesUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/files/search-params'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -101,8 +100,10 @@ import {
|
||||
useWorkspaceFiles,
|
||||
} from '@/hooks/queries/workspace-files'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
type FileResourceItem =
|
||||
@@ -111,8 +112,11 @@ type FileResourceItem =
|
||||
|
||||
const logger = createLogger('Files')
|
||||
|
||||
/** Debounce window for `search` URL writes and filtering; the input itself stays instant. */
|
||||
const SEARCH_DEBOUNCE_MS = 200 as const
|
||||
/**
|
||||
* Debounce window for `search` URL writes and filtering; the input itself stays
|
||||
* instant. Intentionally shorter than the shared `SEARCH_DEBOUNCE_MS` (300).
|
||||
*/
|
||||
const FILES_SEARCH_DEBOUNCE_MS = 200 as const
|
||||
|
||||
const SUPPORTED_EXTENSIONS = [
|
||||
...SUPPORTED_DOCUMENT_EXTENSIONS,
|
||||
@@ -255,14 +259,7 @@ export function Files() {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false)
|
||||
const dragCounterRef = useRef(0)
|
||||
const [
|
||||
{
|
||||
search: urlSearchTerm,
|
||||
sort: sortColumn,
|
||||
dir: sortDirection,
|
||||
type: typeFilter,
|
||||
size: sizeFilter,
|
||||
uploadedBy: uploadedByFilter,
|
||||
},
|
||||
{ search: urlSearchTerm, type: typeFilter, size: sizeFilter, uploadedBy: uploadedByFilter },
|
||||
setFileFilters,
|
||||
] = useQueryStates(filesFilterParsers, filesFilterUrlKeys)
|
||||
|
||||
@@ -271,17 +268,11 @@ export function Files() {
|
||||
* write is debounced. The in-memory filter below still reads a debounced value
|
||||
* so it doesn't recompute on every keystroke.
|
||||
*/
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
const next = value.length > 0 ? value : null
|
||||
setFileFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setFileFilters]
|
||||
const setSearchTerm = useDebouncedSearchSetter(
|
||||
(value, options) => setFileFilters({ search: value }, options),
|
||||
{ debounceMs: FILES_SEARCH_DEBOUNCE_MS }
|
||||
)
|
||||
const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS)
|
||||
const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS)
|
||||
|
||||
/**
|
||||
* `sort`/`dir` are nullable in the URL because "no active sort" is distinct
|
||||
@@ -289,13 +280,7 @@ export function Files() {
|
||||
* updated/desc but folders to name/asc, while an explicit sort orders both
|
||||
* sections by the chosen column.
|
||||
*/
|
||||
const activeSort = useMemo(
|
||||
() =>
|
||||
sortColumn !== null && sortDirection !== null
|
||||
? { column: sortColumn, direction: sortDirection }
|
||||
: null,
|
||||
[sortColumn, sortDirection]
|
||||
)
|
||||
const { activeSort, onSort, onClear } = useUrlSort(filesSortParams, filesFilterUrlKeys)
|
||||
|
||||
const setTypeFilter = useCallback(
|
||||
(next: string[]) => setFileFilters({ type: next }),
|
||||
@@ -403,10 +388,9 @@ export function Files() {
|
||||
|
||||
const visibleFolders = useMemo(() => {
|
||||
const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId)
|
||||
const searched = debouncedSearchTerm
|
||||
? siblings.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
|
||||
)
|
||||
const needle = debouncedSearchTerm.trim().toLowerCase()
|
||||
const searched = needle
|
||||
? siblings.filter((folder) => folder.name.toLowerCase().includes(needle))
|
||||
: siblings
|
||||
const col = activeSort?.column ?? 'name'
|
||||
const dir = activeSort?.direction ?? 'asc'
|
||||
@@ -424,11 +408,10 @@ export function Files() {
|
||||
}, [folders, currentFolderId, debouncedSearchTerm, activeSort])
|
||||
|
||||
const filteredFiles = useMemo(() => {
|
||||
let result = debouncedSearchTerm
|
||||
const needle = debouncedSearchTerm.trim().toLowerCase()
|
||||
let result = needle
|
||||
? files.filter(
|
||||
(f) =>
|
||||
(f.folderId ?? null) === currentFolderId &&
|
||||
f.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase())
|
||||
(f) => (f.folderId ?? null) === currentFolderId && f.name.toLowerCase().includes(needle)
|
||||
)
|
||||
: files.filter((f) => (f.folderId ?? null) === currentFolderId)
|
||||
|
||||
@@ -1745,13 +1728,10 @@ export function Files() {
|
||||
{ id: 'owner', label: 'Owner' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => {
|
||||
if (!(FILE_SORT_COLUMNS as readonly string[]).includes(column)) return
|
||||
setFileFilters({ sort: column as FileSortColumn, dir: direction })
|
||||
},
|
||||
onClear: () => setFileFilters({ sort: null, dir: null }),
|
||||
onSort,
|
||||
onClear,
|
||||
}),
|
||||
[activeSort, setFileFilters]
|
||||
[activeSort, onSort, onClear]
|
||||
)
|
||||
|
||||
const hasActiveFilters =
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { createParser, parseAsArrayOf, parseAsString } from 'nuqs/server'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
/** Sortable list columns, matching the `Resource.Options` sort menu. */
|
||||
export const FILE_SORT_COLUMNS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const
|
||||
|
||||
export type FileSortColumn = (typeof FILE_SORT_COLUMNS)[number]
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
/**
|
||||
* Parser for the `new` flag. Preserves the prior `?new=1` wire format on
|
||||
* serialize while tolerantly accepting the legacy `1`/`true` tokens on parse, so
|
||||
@@ -61,28 +58,30 @@ export const filesUrlKeys = {
|
||||
* above because filter writes must never land in the browser history.
|
||||
*
|
||||
* - `search` is the file/folder name filter. The input is controlled directly
|
||||
* by the nuqs value; only its URL write is debounced via `limitUrlUpdates`
|
||||
* (`debounce`) on the setter — never written on every keystroke.
|
||||
* - `sort` / `dir` follow the shared sort convention (two scalar params). They
|
||||
* are intentionally nullable (no `.withDefault`) because "no active sort" is
|
||||
* behaviorally distinct from explicitly sorting by the fallback column: with
|
||||
* no sort, files order by updated/desc but folders by name/asc, while an
|
||||
* explicit updated/desc sorts both sections by updatedAt. Collapsing the
|
||||
* explicit selection into a clean URL would make that folder ordering
|
||||
* unreachable. Clearing the sort writes `null`, which strips both params.
|
||||
* by the nuqs value; only its URL write is debounced via
|
||||
* `useDebouncedSearchSetter` — never written on every keystroke.
|
||||
* - `type` filters by file kind (document/image/audio/video); `size` filters by
|
||||
* size bucket (small/medium/large); `uploadedBy` filters by uploader user id
|
||||
* (URL key `uploaded-by`). All three are multi-select arrays.
|
||||
*/
|
||||
export const filesFilterParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
sort: parseAsStringLiteral(FILE_SORT_COLUMNS),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS),
|
||||
type: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
size: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
uploadedBy: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
} as const
|
||||
|
||||
/**
|
||||
* `sort` / `dir` follow the shared sort convention (two scalar params) in
|
||||
* nullable mode (no `defaultSort`) because "no active sort" is behaviorally
|
||||
* distinct from explicitly sorting by the fallback column: with no sort, files
|
||||
* order by updated/desc but folders by name/asc, while an explicit updated/desc
|
||||
* sorts both sections by updatedAt. Collapsing the explicit selection into a
|
||||
* clean URL would make that folder ordering unreachable. Clearing the sort
|
||||
* writes `null`, which strips both params.
|
||||
*/
|
||||
export const filesSortParams = createSortParams(FILE_SORT_COLUMNS)
|
||||
|
||||
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
|
||||
export const filesFilterUrlKeys = {
|
||||
history: 'replace',
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import {
|
||||
blockTypeToIconMap,
|
||||
formatIntegrationType,
|
||||
@@ -35,9 +35,7 @@ import {
|
||||
integrationsUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/integrations/search-params'
|
||||
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
|
||||
/** Debounce window for `search` URL writes; the input itself stays instant. */
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
/** Slugs surfaced in the pinned Featured section, in display order. */
|
||||
const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const
|
||||
@@ -145,19 +143,12 @@ export function Integrations() {
|
||||
|
||||
/**
|
||||
* The input is controlled directly by the instant nuqs value; only the URL
|
||||
* write is debounced. Filtering below is cheap in-memory over a static list,
|
||||
* so it reads the instant value too.
|
||||
* write is debounced. The raw value is written (trimming happens on read in
|
||||
* the filters below) so trailing spaces stay typable mid-word. Filtering is
|
||||
* cheap in-memory over a static list, so it reads the instant value too.
|
||||
*/
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim()
|
||||
const next = trimmed.length > 0 ? trimmed : null
|
||||
setIntegrationFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setIntegrationFilters]
|
||||
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
|
||||
setIntegrationFilters({ search: value }, options)
|
||||
)
|
||||
|
||||
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
DocumentTagsModal,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
|
||||
import {
|
||||
documentChunkSortParams,
|
||||
documentParsers,
|
||||
documentUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params'
|
||||
@@ -51,10 +52,19 @@ import {
|
||||
useUpdateDocument,
|
||||
} from '@/hooks/queries/kb/knowledge'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
|
||||
const logger = createLogger('Document')
|
||||
|
||||
/**
|
||||
* Debounce window for chunk-search URL writes and the query feed; the input
|
||||
* itself stays instant. Intentionally shorter than the shared
|
||||
* `SEARCH_DEBOUNCE_MS` (300) to match the chunk search's snappier feel.
|
||||
*/
|
||||
const CHUNK_SEARCH_DEBOUNCE_MS = 200 as const
|
||||
|
||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||
|
||||
interface UnsavedChangesModalProps {
|
||||
@@ -127,10 +137,10 @@ export function Document({
|
||||
}: DocumentProps) {
|
||||
const { workspaceId } = useParams()
|
||||
const router = useRouter()
|
||||
const [{ page: currentPageFromURL, chunk: chunkFromURL }, setDocumentParams] = useQueryStates(
|
||||
documentParsers,
|
||||
documentUrlKeys
|
||||
)
|
||||
const [
|
||||
{ page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery },
|
||||
setDocumentParams,
|
||||
] = useQueryStates(documentParsers, documentUrlKeys)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId)
|
||||
@@ -138,13 +148,25 @@ export function Document({
|
||||
|
||||
const [showTagsModal, setShowTagsModal] = useState(false)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 200)
|
||||
/**
|
||||
* The input is controlled directly by the instant nuqs value; only the URL
|
||||
* write is debounced. The chunk search query below reads a debounced value so
|
||||
* it doesn't refetch on every keystroke. Changing the search resets `page` in
|
||||
* the same write — a search started from a later page must land on the first
|
||||
* page of matches, and a shared search link must open there too.
|
||||
*/
|
||||
const handleSearchChange = useDebouncedSearchSetter(
|
||||
(value, options) => void setDocumentParams({ search: value, page: null }, options),
|
||||
{ debounceMs: CHUNK_SEARCH_DEBOUNCE_MS }
|
||||
)
|
||||
/** Raw URL value drives the input; the chunk search query always sees it trimmed. */
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim()
|
||||
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
|
||||
const [activeSort, setActiveSort] = useState<{
|
||||
column: string
|
||||
direction: 'asc' | 'desc'
|
||||
} | null>(null)
|
||||
const {
|
||||
activeSort,
|
||||
onSort: onSortColumn,
|
||||
onClear: onClearSort,
|
||||
} = useUrlSort(documentChunkSortParams, documentUrlKeys)
|
||||
|
||||
const enabledFilterParam = useMemo(
|
||||
() => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'),
|
||||
@@ -181,7 +203,7 @@ export function Document({
|
||||
search: debouncedSearchQuery,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(debouncedSearchQuery.trim()),
|
||||
enabled: Boolean(debouncedSearchQuery),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -211,7 +233,7 @@ export function Document({
|
||||
const saveStatusRef = useRef<SaveStatus>('idle')
|
||||
saveStatusRef.current = saveStatus
|
||||
|
||||
const isSearching = debouncedSearchQuery.trim().length > 0
|
||||
const isSearching = debouncedSearchQuery.length > 0
|
||||
const showingSearch = isSearching && searchQuery.trim().length > 0 && searchResults.length > 0
|
||||
const SEARCH_PAGE_SIZE = 50
|
||||
const maxSearchPages = Math.ceil(searchResults.length / SEARCH_PAGE_SIZE)
|
||||
@@ -591,7 +613,7 @@ export function Document({
|
||||
const searchConfig: SearchConfig | undefined = isCompleted
|
||||
? {
|
||||
value: searchQuery,
|
||||
onChange: (value: string) => setSearchQuery(value),
|
||||
onChange: handleSearchChange,
|
||||
placeholder: 'Search chunks...',
|
||||
}
|
||||
: undefined
|
||||
@@ -861,16 +883,17 @@ export function Document({
|
||||
{ id: 'status', label: 'Status' },
|
||||
],
|
||||
active: activeSort,
|
||||
/** Sorting (or clearing the sort) resets pagination to the first page. */
|
||||
onSort: (column, direction) => {
|
||||
setActiveSort({ column, direction })
|
||||
onSortColumn(column, direction)
|
||||
void goToPage(1)
|
||||
},
|
||||
onClear: () => {
|
||||
setActiveSort(null)
|
||||
onClearSort()
|
||||
void goToPage(1)
|
||||
},
|
||||
}),
|
||||
[activeSort, goToPage]
|
||||
[activeSort, onSortColumn, onClearSort, goToPage]
|
||||
)
|
||||
|
||||
const chunkRows: ResourceRow[] = useMemo(() => {
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import { parseAsInteger, parseAsString } from 'nuqs/server'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
/** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */
|
||||
export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const
|
||||
|
||||
/**
|
||||
* `sort` / `dir` follow the shared sort convention (see `useUrlSort`), in
|
||||
* nullable mode: with no active sort the chunk query omits `sortBy` entirely
|
||||
* (server default order), which is distinct from any explicit column, so an
|
||||
* explicit selection always persists in the URL and clearing strips both.
|
||||
*/
|
||||
export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS)
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the knowledge document
|
||||
@@ -9,10 +21,14 @@ import { parseAsInteger, parseAsString } from 'nuqs/server'
|
||||
* to 1 and clears from the URL at the default to keep links clean.
|
||||
* - `chunk` deep-links a specific chunk so it can be focused/opened in the inline
|
||||
* editor from a shared link.
|
||||
* - `search` is the chunk content search. The input is controlled directly by
|
||||
* the instant nuqs value; only its URL write is debounced via
|
||||
* `useDebouncedSearchSetter` — never written on every keystroke.
|
||||
*/
|
||||
export const documentParsers = {
|
||||
page: parseAsInteger.withDefault(1),
|
||||
chunk: parseAsString,
|
||||
search: parseAsString.withDefault(''),
|
||||
} as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ import { generateId } from '@sim/utils/id'
|
||||
import { format } from 'date-fns'
|
||||
import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryState, useQueryStates } from 'nuqs'
|
||||
import { useQueryState, useQueryStates } from 'nuqs'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { SearchHighlight } from '@/components/ui/search-highlight'
|
||||
import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants'
|
||||
@@ -38,6 +38,7 @@ import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/
|
||||
import type { DocumentData } from '@/lib/knowledge/types'
|
||||
import { captureEvent } from '@/lib/posthog/client'
|
||||
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import type {
|
||||
BreadcrumbItem,
|
||||
FilterTag,
|
||||
@@ -61,11 +62,9 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/components'
|
||||
import {
|
||||
addConnectorParam,
|
||||
DEFAULT_KB_SORT_COLUMN,
|
||||
DEFAULT_KB_SORT_DIRECTION,
|
||||
documentFiltersParsers,
|
||||
documentFiltersUrlKeys,
|
||||
type KbSortColumn,
|
||||
kbDocumentSortParams,
|
||||
pageParam,
|
||||
pageUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params'
|
||||
@@ -92,8 +91,10 @@ import {
|
||||
useUpdateKnowledgeBase,
|
||||
} from '@/hooks/queries/kb/knowledge'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
|
||||
const logger = createLogger('KnowledgeBase')
|
||||
|
||||
@@ -296,41 +297,31 @@ export function KnowledgeBase({
|
||||
...pageUrlKeys,
|
||||
})
|
||||
|
||||
const [
|
||||
{ q: searchQuery, enabled: enabledFilter, sort: sortColumn, dir: sortDirection },
|
||||
setDocumentFilters,
|
||||
] = useQueryStates(documentFiltersParsers, documentFiltersUrlKeys)
|
||||
const [{ q: searchQuery, enabled: enabledFilter }, setDocumentFilters] = useQueryStates(
|
||||
documentFiltersParsers,
|
||||
documentFiltersUrlKeys
|
||||
)
|
||||
|
||||
/**
|
||||
* The input is controlled directly by the instant nuqs value; only the URL
|
||||
* write is debounced. The document query below reads a debounced value so it
|
||||
* doesn't refetch on every keystroke. Changing the search resets pagination.
|
||||
*/
|
||||
const handleSearchChange = useCallback(
|
||||
(newQuery: string) => {
|
||||
const trimmed = newQuery.trim()
|
||||
const next = trimmed.length > 0 ? trimmed : null
|
||||
setDocumentFilters(
|
||||
{ q: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(300) }
|
||||
)
|
||||
setCurrentPage(1)
|
||||
},
|
||||
[setDocumentFilters, setCurrentPage]
|
||||
)
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const handleSearchChange = useDebouncedSearchSetter((value, options) => {
|
||||
setDocumentFilters({ q: value }, options)
|
||||
setCurrentPage(1)
|
||||
})
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, SEARCH_DEBOUNCE_MS)
|
||||
/** Raw URL value drives the input; matching/highlighting always sees it trimmed. */
|
||||
const highlightQuery = searchQuery.trim()
|
||||
|
||||
/**
|
||||
* The resolved sort is exposed to the sort menu only when it differs from the
|
||||
* default, mirroring the prior `null`-means-default semantics.
|
||||
*/
|
||||
const activeSort = useMemo(
|
||||
() =>
|
||||
sortColumn === DEFAULT_KB_SORT_COLUMN && sortDirection === DEFAULT_KB_SORT_DIRECTION
|
||||
? null
|
||||
: { column: sortColumn, direction: sortDirection },
|
||||
[sortColumn, sortDirection]
|
||||
)
|
||||
const {
|
||||
sort: sortColumn,
|
||||
dir: sortDirection,
|
||||
activeSort,
|
||||
onSort: onSortColumn,
|
||||
onClear: onClearSort,
|
||||
} = useUrlSort(kbDocumentSortParams, documentFiltersUrlKeys)
|
||||
|
||||
const setEnabledFilter = useCallback(
|
||||
(value: 'all' | 'enabled' | 'disabled') => {
|
||||
@@ -385,7 +376,7 @@ export function KnowledgeBase({
|
||||
updateDocument,
|
||||
refreshDocuments,
|
||||
} = useKnowledgeBaseDocuments(id, {
|
||||
search: debouncedSearchQuery || undefined,
|
||||
search: debouncedSearchQuery.trim() || undefined,
|
||||
limit: DOCUMENTS_PER_PAGE,
|
||||
offset: (currentPage - 1) * DOCUMENTS_PER_PAGE,
|
||||
sortBy: sortColumn as DocumentSortField,
|
||||
@@ -914,20 +905,17 @@ export function KnowledgeBase({
|
||||
{ id: 'enabled', label: 'Status' },
|
||||
],
|
||||
active: activeSort,
|
||||
/** Sorting (or clearing the sort) resets pagination to the first page. */
|
||||
onSort: (column, direction) => {
|
||||
setDocumentFilters({ sort: column as KbSortColumn, dir: direction })
|
||||
onSortColumn(column, direction)
|
||||
setCurrentPage(1)
|
||||
},
|
||||
/**
|
||||
* Clearing writes the defaults back (stripped by clearOnDefault), so the
|
||||
* sort menu reads "no active sort" again and the URL stays clean.
|
||||
*/
|
||||
onClear: () => {
|
||||
setDocumentFilters({ sort: DEFAULT_KB_SORT_COLUMN, dir: DEFAULT_KB_SORT_DIRECTION })
|
||||
onClearSort()
|
||||
setCurrentPage(1)
|
||||
},
|
||||
}),
|
||||
[activeSort, setDocumentFilters, setCurrentPage]
|
||||
[activeSort, onSortColumn, onClearSort, setCurrentPage]
|
||||
)
|
||||
|
||||
const filterContent = useMemo(
|
||||
@@ -1130,7 +1118,7 @@ export function KnowledgeBase({
|
||||
label={doc.filename}
|
||||
className={cn('block', chipContentLabelClass)}
|
||||
>
|
||||
<SearchHighlight text={doc.filename} searchQuery={searchQuery} />
|
||||
<SearchHighlight text={doc.filename} searchQuery={highlightQuery} />
|
||||
</FloatingOverflowText>
|
||||
</span>
|
||||
),
|
||||
@@ -1166,7 +1154,7 @@ export function KnowledgeBase({
|
||||
},
|
||||
}
|
||||
}),
|
||||
[documents, tagDefinitions, searchQuery]
|
||||
[documents, tagDefinitions, highlightQuery]
|
||||
)
|
||||
|
||||
if (error && !knowledgeBase) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the knowledge base detail
|
||||
@@ -44,24 +45,23 @@ export const KB_SORT_COLUMNS = [
|
||||
'enabled',
|
||||
] as const
|
||||
|
||||
export type KbSortColumn = (typeof KB_SORT_COLUMNS)[number]
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
/** Default sort: most-recently-uploaded first (matches the document query default). */
|
||||
export const DEFAULT_KB_SORT_COLUMN = 'uploadedAt'
|
||||
export const DEFAULT_KB_SORT_DIRECTION = 'desc'
|
||||
/**
|
||||
* `sort` / `dir` follow the shared sort convention (see `useUrlSort`). The
|
||||
* default (most-recently-uploaded first) matches the document query's default
|
||||
* order, so a clean URL means the default sort.
|
||||
*/
|
||||
export const kbDocumentSortParams = createSortParams(KB_SORT_COLUMNS, {
|
||||
column: 'uploadedAt',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
/**
|
||||
* Grouped filter/search/sort URL state for the document list.
|
||||
* Grouped filter/search URL state for the document list.
|
||||
*
|
||||
* - `q` is the document name search. The input is controlled directly by the
|
||||
* instant nuqs value; only its URL write is debounced via `limitUrlUpdates`
|
||||
* on the setter — never written on every keystroke.
|
||||
* instant nuqs value; only its URL write is debounced via
|
||||
* `useDebouncedSearchSetter` — never written on every keystroke.
|
||||
* - `enabled` filters by processing/enabled status (`all` clears from the URL).
|
||||
* - `sort` / `dir` follow the shared `sort`+`dir` convention. The defaults match
|
||||
* the document query's default order; "no active sort" is derived in the
|
||||
* component as `sort === DEFAULT && dir === DEFAULT`.
|
||||
*
|
||||
* `tagFilterEntries` is intentionally NOT represented here: it is an array of
|
||||
* rich filter-rule objects (slot, field type, operator, value, value-to per
|
||||
@@ -71,8 +71,6 @@ export const DEFAULT_KB_SORT_DIRECTION = 'desc'
|
||||
export const documentFiltersParsers = {
|
||||
q: parseAsString.withDefault(''),
|
||||
enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'),
|
||||
sort: parseAsStringLiteral(KB_SORT_COLUMNS).withDefault(DEFAULT_KB_SORT_COLUMN),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KB_SORT_DIRECTION),
|
||||
} as const
|
||||
|
||||
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
|
||||
|
||||
@@ -6,8 +6,9 @@ import { Button, ChipDropdown, Plus, Tooltip } from '@sim/emcn'
|
||||
import { Database } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import type {
|
||||
FilterTag,
|
||||
ResourceAction,
|
||||
@@ -32,11 +33,8 @@ import {
|
||||
KnowledgeListContextMenu,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||
import {
|
||||
DEFAULT_KNOWLEDGE_SORT_COLUMN,
|
||||
DEFAULT_KNOWLEDGE_SORT_DIRECTION,
|
||||
KNOWLEDGE_SORT_COLUMNS,
|
||||
type KnowledgeSortColumn,
|
||||
knowledgeParsers,
|
||||
knowledgeSortParams,
|
||||
knowledgeUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/knowledge/search-params'
|
||||
import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||
@@ -47,13 +45,12 @@ import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
|
||||
/** Debounce window for `search` URL writes; the input itself stays instant. */
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
|
||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||
docCount?: number
|
||||
}
|
||||
@@ -158,8 +155,6 @@ export function Knowledge() {
|
||||
const [
|
||||
{
|
||||
search: urlSearchQuery,
|
||||
sort: sortColumn,
|
||||
dir: sortDirection,
|
||||
connector: connectorFilter,
|
||||
content: contentFilter,
|
||||
owner: ownerFilter,
|
||||
@@ -172,30 +167,16 @@ export function Knowledge() {
|
||||
* write is debounced. The in-memory filter below still reads a debounced
|
||||
* value so it doesn't recompute on every keystroke.
|
||||
*/
|
||||
const setSearchQuery = useCallback(
|
||||
(value: string) => {
|
||||
const next = value.length > 0 ? value : null
|
||||
setKnowledgeFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setKnowledgeFilters]
|
||||
const setSearchQuery = useDebouncedSearchSetter((value, options) =>
|
||||
setKnowledgeFilters({ search: value }, options)
|
||||
)
|
||||
const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
/**
|
||||
* The resolved sort is exposed to the sort menu only when it differs from the
|
||||
* default, mirroring the prior `null`-means-default semantics.
|
||||
*/
|
||||
const activeSort = useMemo(
|
||||
() =>
|
||||
sortColumn === DEFAULT_KNOWLEDGE_SORT_COLUMN &&
|
||||
sortDirection === DEFAULT_KNOWLEDGE_SORT_DIRECTION
|
||||
? null
|
||||
: { column: sortColumn, direction: sortDirection },
|
||||
[sortColumn, sortDirection]
|
||||
)
|
||||
const {
|
||||
activeSort,
|
||||
onSort: onSortColumn,
|
||||
onClear: onClearSort,
|
||||
} = useUrlSort(knowledgeSortParams, knowledgeUrlKeys)
|
||||
|
||||
const setConnectorFilter = useCallback(
|
||||
(next: string[]) => setKnowledgeFilters({ connector: next }),
|
||||
@@ -478,19 +459,10 @@ export function Knowledge() {
|
||||
{ id: 'owner', label: 'Owner' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => {
|
||||
const sort = (KNOWLEDGE_SORT_COLUMNS as readonly string[]).includes(column)
|
||||
? (column as KnowledgeSortColumn)
|
||||
: DEFAULT_KNOWLEDGE_SORT_COLUMN
|
||||
setKnowledgeFilters({ sort, dir: direction })
|
||||
},
|
||||
onClear: () =>
|
||||
setKnowledgeFilters({
|
||||
sort: DEFAULT_KNOWLEDGE_SORT_COLUMN,
|
||||
dir: DEFAULT_KNOWLEDGE_SORT_DIRECTION,
|
||||
}),
|
||||
onSort: onSortColumn,
|
||||
onClear: onClearSort,
|
||||
}),
|
||||
[activeSort, setKnowledgeFilters]
|
||||
[activeSort, onSortColumn, onClearSort]
|
||||
)
|
||||
|
||||
const memberOptions: ChipDropdownOption[] = useMemo(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { parseAsArrayOf, parseAsString } from 'nuqs/server'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
/** Sortable knowledge base columns, matching the `Resource.Options` sort menu. */
|
||||
export const KNOWLEDGE_SORT_COLUMNS = [
|
||||
@@ -11,13 +12,15 @@ export const KNOWLEDGE_SORT_COLUMNS = [
|
||||
'updated',
|
||||
] as const
|
||||
|
||||
export type KnowledgeSortColumn = (typeof KNOWLEDGE_SORT_COLUMNS)[number]
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
/** Default sort: most-recently-updated first. */
|
||||
export const DEFAULT_KNOWLEDGE_SORT_COLUMN: KnowledgeSortColumn = 'updated'
|
||||
export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc'
|
||||
/**
|
||||
* `sort` / `dir` follow the shared sort convention (see `useUrlSort`). The
|
||||
* default (most-recently-updated first) matches the list's default ordering,
|
||||
* so a clean URL means the default sort.
|
||||
*/
|
||||
export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, {
|
||||
column: 'updated',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the Knowledge Base list.
|
||||
@@ -26,9 +29,6 @@ export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc'
|
||||
* controlled directly by the instant nuqs value; only its URL write is
|
||||
* debounced via `limitUrlUpdates` (`debounce`) on the setter — never written
|
||||
* on every keystroke.
|
||||
* - `sort` / `dir` follow the shared sort convention (two scalar params). "No
|
||||
* active sort" is derived in the component as `sort === DEFAULT && dir ===
|
||||
* DEFAULT`.
|
||||
* - `connector` filters by connector presence; `content` filters by document
|
||||
* presence; `owner` filters by creator id. All are multi-select arrays.
|
||||
*
|
||||
@@ -38,8 +38,6 @@ export const DEFAULT_KNOWLEDGE_SORT_DIRECTION = 'desc'
|
||||
*/
|
||||
export const knowledgeParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
sort: parseAsStringLiteral(KNOWLEDGE_SORT_COLUMNS).withDefault(DEFAULT_KNOWLEDGE_SORT_COLUMN),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_KNOWLEDGE_SORT_DIRECTION),
|
||||
connector: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
content: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
owner: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
|
||||
@@ -48,7 +48,7 @@ export function filterKnowledgeBases(
|
||||
return knowledgeBases
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
return knowledgeBases.filter(
|
||||
(kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query)
|
||||
)
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import {
|
||||
logFilterParsers,
|
||||
logFilterUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/logs/search-params'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import type { LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/types'
|
||||
|
||||
const DEFAULT_TIME_RANGE: TimeRange = 'All time'
|
||||
|
||||
/** Debounce window for `search` URL writes; keystrokes stay instant in the input. */
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
|
||||
/**
|
||||
* The logs filter state, sourced entirely from typed URL query params via nuqs.
|
||||
*
|
||||
@@ -118,18 +116,11 @@ export function useLogFilters(): UseLogFilters {
|
||||
/**
|
||||
* Debounces only the search param's URL write; the returned `filters.search`
|
||||
* value still updates instantly so the controlled input stays responsive.
|
||||
* Clearing flushes immediately so the param drops out without lingering.
|
||||
* Writes the raw value (query consumers trim on read); clearing flushes
|
||||
* immediately so the param drops out without lingering.
|
||||
*/
|
||||
const setSearchQuery = useCallback(
|
||||
(query: string) => {
|
||||
const trimmed = query.trim()
|
||||
const next = trimmed.length > 0 ? trimmed : null
|
||||
setFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setFilters]
|
||||
const setSearchQuery = useDebouncedSearchSetter((value, options) =>
|
||||
setFilters({ search: value }, options)
|
||||
)
|
||||
|
||||
const setTriggers = useCallback(
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Download, Workflow } from '@sim/emcn/icons'
|
||||
import { formatDuration } from '@sim/utils/formatting'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useQueryState, useQueryStates } from 'nuqs'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import type {
|
||||
WorkflowLogDetail,
|
||||
WorkflowLogRow,
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
type TriggerData,
|
||||
type WorkflowData,
|
||||
} from '@/lib/logs/search-suggestions'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import type {
|
||||
FilterTag,
|
||||
ResourceAction,
|
||||
@@ -58,20 +59,17 @@ import { Resource, type ResourceTableHandle } from '@/app/workspace/[workspaceId
|
||||
import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters'
|
||||
import { useSearchState } from '@/app/workspace/[workspaceId]/logs/hooks/use-search-state'
|
||||
import {
|
||||
DEFAULT_LOG_SORT_COLUMN,
|
||||
DEFAULT_LOG_SORT_DIRECTION,
|
||||
executionIdParam,
|
||||
LOG_SORT_COLUMNS,
|
||||
executionIdWriteOptions,
|
||||
logDetailsTabParam,
|
||||
logDetailsTabUrlKeys,
|
||||
logFilterUrlKeys,
|
||||
logSortParsers,
|
||||
logSortParams,
|
||||
} from '@/app/workspace/[workspaceId]/logs/search-params'
|
||||
import type { Suggestion } from '@/app/workspace/[workspaceId]/logs/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { getBlock } from '@/blocks/registry'
|
||||
import { useFolderMap, useFolders } from '@/hooks/queries/folders'
|
||||
import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs'
|
||||
import {
|
||||
fetchLogDetail,
|
||||
logKeys,
|
||||
@@ -85,6 +83,7 @@ import {
|
||||
} from '@/hooks/queries/logs'
|
||||
import { useWorkflowMap, useWorkflows } from '@/hooks/queries/workflows'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
||||
import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components'
|
||||
@@ -241,7 +240,7 @@ export default function Logs() {
|
||||
isSidebarOpen: false,
|
||||
})
|
||||
|
||||
const [executionId] = useQueryState(executionIdParam.key, executionIdParam.parser)
|
||||
const [executionId, setExecutionId] = useQueryState(executionIdParam.key, executionIdParam.parser)
|
||||
const [pendingExecutionId, setPendingExecutionId] = useState<string | null>(() => executionId)
|
||||
|
||||
/**
|
||||
@@ -257,9 +256,10 @@ export default function Logs() {
|
||||
/**
|
||||
* `urlSearchQuery` is the instant nuqs value (its URL write is debounced inside
|
||||
* `useLogFilters`); the query/filtering still debounce off it to avoid
|
||||
* per-keystroke fetches.
|
||||
* per-keystroke fetches. The raw value is written to the URL, so trim here on
|
||||
* read — the server keeps receiving a trimmed query.
|
||||
*/
|
||||
const debouncedSearchQuery = useDebounce(urlSearchQuery, 300)
|
||||
const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS).trim()
|
||||
|
||||
const isLive = true
|
||||
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
|
||||
@@ -268,6 +268,7 @@ export default function Logs() {
|
||||
const logsRef = useRef<WorkflowLogSummary[]>([])
|
||||
const selectedLogIndexRef = useRef(-1)
|
||||
const selectedLogIdRef = useRef<string | null>(null)
|
||||
const isSidebarOpenRef = useRef(false)
|
||||
const shouldScrollIntoViewRef = useRef(false)
|
||||
const resourceTableRef = useRef<ResourceTableHandle>(null)
|
||||
const logsRefetchRef = useRef<() => void>(() => {})
|
||||
@@ -280,7 +281,13 @@ export default function Logs() {
|
||||
* ordering, so a clean URL means "no active sort" and clearing the sort
|
||||
* writes the defaults back (which `clearOnDefault` strips from the URL).
|
||||
*/
|
||||
const [sortParams, setSortParams] = useQueryStates(logSortParsers, logFilterUrlKeys)
|
||||
const {
|
||||
sort: sortBy,
|
||||
dir: sortOrder,
|
||||
activeSort,
|
||||
onSort,
|
||||
onClear: onClearSort,
|
||||
} = useUrlSort(logSortParams, logFilterUrlKeys)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false)
|
||||
@@ -308,9 +315,6 @@ export default function Logs() {
|
||||
refetchInterval,
|
||||
})
|
||||
|
||||
const sortBy: LogSortBy = sortParams.sort
|
||||
const sortOrder: LogSortOrder = sortParams.dir
|
||||
|
||||
const logFilters = useMemo(
|
||||
() => ({
|
||||
timeRange,
|
||||
@@ -381,6 +385,7 @@ export default function Logs() {
|
||||
logsRef.current = logs
|
||||
selectedLogIndexRef.current = selectedLogIndex
|
||||
selectedLogIdRef.current = selectedLogId
|
||||
isSidebarOpenRef.current = isSidebarOpen
|
||||
logsRefetchRef.current = logsQuery.refetch
|
||||
activeLogRefetchRef.current = selectedDetailQuery.refetch
|
||||
logsQueryRef.current = {
|
||||
@@ -410,31 +415,69 @@ export default function Logs() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLogClick = useCallback((rowId: string) => {
|
||||
dispatch({ type: 'TOGGLE_LOG', logId: rowId })
|
||||
}, [])
|
||||
/**
|
||||
* The single write path for user-driven `executionId` changes. Cancels any
|
||||
* in-flight deep-link resolution first — an explicit interaction supersedes
|
||||
* it, otherwise the resolved row would open over the user's selection and
|
||||
* leave the URL pointing at a different run than the panel shows.
|
||||
*/
|
||||
const writeExecutionId = useCallback(
|
||||
(value: string | null) => {
|
||||
setPendingExecutionId(null)
|
||||
void setExecutionId(value, executionIdWriteOptions)
|
||||
},
|
||||
[setExecutionId]
|
||||
)
|
||||
|
||||
/**
|
||||
* Mirrors the reducer's TOGGLE_LOG branch: clicking the already-open row
|
||||
* closes the sidebar (strip `executionId`); any other click opens the row
|
||||
* (sync `executionId` to it so the URL always deep-links the open run).
|
||||
*/
|
||||
const handleLogClick = useCallback(
|
||||
(rowId: string) => {
|
||||
const opens = !(selectedLogIdRef.current === rowId && isSidebarOpenRef.current)
|
||||
dispatch({ type: 'TOGGLE_LOG', logId: rowId })
|
||||
if (opens) {
|
||||
const log = logsRef.current.find((l) => l.id === rowId)
|
||||
writeExecutionId(log?.executionId ?? null)
|
||||
} else {
|
||||
writeExecutionId(null)
|
||||
}
|
||||
},
|
||||
[writeExecutionId]
|
||||
)
|
||||
|
||||
const handleNavigateNext = useCallback(() => {
|
||||
const idx = selectedLogIndexRef.current
|
||||
const currentLogs = logsRef.current
|
||||
if (idx >= 0 && idx < currentLogs.length - 1) {
|
||||
const nextLog = currentLogs[idx + 1]
|
||||
shouldScrollIntoViewRef.current = true
|
||||
dispatch({ type: 'SELECT_LOG', logId: currentLogs[idx + 1].id })
|
||||
dispatch({ type: 'SELECT_LOG', logId: nextLog.id })
|
||||
if (isSidebarOpenRef.current) {
|
||||
writeExecutionId(nextLog.executionId ?? null)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [writeExecutionId])
|
||||
|
||||
const handleNavigatePrev = useCallback(() => {
|
||||
const idx = selectedLogIndexRef.current
|
||||
if (idx > 0) {
|
||||
const prevLog = logsRef.current[idx - 1]
|
||||
shouldScrollIntoViewRef.current = true
|
||||
dispatch({ type: 'SELECT_LOG', logId: logsRef.current[idx - 1].id })
|
||||
dispatch({ type: 'SELECT_LOG', logId: prevLog.id })
|
||||
if (isSidebarOpenRef.current) {
|
||||
writeExecutionId(prevLog.executionId ?? null)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [writeExecutionId])
|
||||
|
||||
const handleCloseSidebar = useCallback(() => {
|
||||
dispatch({ type: 'CLOSE_SIDEBAR' })
|
||||
writeExecutionId(null)
|
||||
activeLogTabRef.current = 'overview'
|
||||
}, [])
|
||||
}, [writeExecutionId])
|
||||
|
||||
/**
|
||||
* Strip the `tab` param whenever the detail panel transitions from open to
|
||||
@@ -663,6 +706,9 @@ export default function Logs() {
|
||||
|
||||
const handleNavigateNextEvent = useEffectEvent(handleNavigateNext)
|
||||
const handleNavigatePrevEvent = useEffectEvent(handleNavigatePrev)
|
||||
const writeExecutionIdEvent = useEffectEvent((value: string | null) => {
|
||||
writeExecutionId(value)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -697,7 +743,14 @@ export default function Logs() {
|
||||
|
||||
if (e.key === 'Enter' && selectedLogIdRef.current) {
|
||||
e.preventDefault()
|
||||
const willOpen = !isSidebarOpenRef.current
|
||||
dispatch({ type: 'TOGGLE_SIDEBAR' })
|
||||
if (willOpen) {
|
||||
const log = currentLogs.find((l) => l.id === selectedLogIdRef.current)
|
||||
writeExecutionIdEvent(log?.executionId ?? null)
|
||||
} else {
|
||||
writeExecutionIdEvent(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,18 +1073,11 @@ export default function Logs() {
|
||||
{ id: 'cost', label: 'Cost' },
|
||||
{ id: 'status', label: 'Status' },
|
||||
],
|
||||
active:
|
||||
sortParams.sort === DEFAULT_LOG_SORT_COLUMN && sortParams.dir === DEFAULT_LOG_SORT_DIRECTION
|
||||
? null
|
||||
: { column: sortParams.sort, direction: sortParams.dir },
|
||||
onSort: (column, direction) => {
|
||||
if (!(LOG_SORT_COLUMNS as readonly string[]).includes(column)) return
|
||||
setSortParams({ sort: column as LogSortBy, dir: direction })
|
||||
},
|
||||
onClear: () =>
|
||||
setSortParams({ sort: DEFAULT_LOG_SORT_COLUMN, dir: DEFAULT_LOG_SORT_DIRECTION }),
|
||||
active: activeSort,
|
||||
onSort,
|
||||
onClear: onClearSort,
|
||||
}),
|
||||
[sortParams, setSortParams]
|
||||
[activeSort, onSort, onClearSort]
|
||||
)
|
||||
|
||||
const searchConfig = useMemo<SearchConfig>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createParser, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import type { LogSortBy, LogSortOrder } from '@/hooks/queries/logs'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
import type { LogSortBy } from '@/hooks/queries/logs'
|
||||
import {
|
||||
CORE_TRIGGER_TYPES,
|
||||
type LogLevel,
|
||||
@@ -13,8 +14,9 @@ import {
|
||||
* single source of truth.
|
||||
*
|
||||
* The encoding here intentionally preserves the exact wire format the logs page
|
||||
* shipped before nuqs: `timeRange` uses kebab tokens, `level` / `workflowIds` /
|
||||
* `folderIds` / `triggers` are comma-joined, and `search` is trimmed.
|
||||
* shipped before nuqs: `timeRange` uses kebab tokens and `level` /
|
||||
* `workflowIds` / `folderIds` / `triggers` are comma-joined. `search` carries
|
||||
* the raw input value (consumers trim on read).
|
||||
*/
|
||||
|
||||
const DEFAULT_TIME_RANGE: TimeRange = 'All time'
|
||||
@@ -125,33 +127,37 @@ export const LOG_SORT_COLUMNS = [
|
||||
'status',
|
||||
] as const satisfies readonly LogSortBy[]
|
||||
|
||||
const LOG_SORT_DIRECTIONS = ['asc', 'desc'] as const satisfies readonly LogSortOrder[]
|
||||
|
||||
/** Default ordering the server applies when no sort is active (newest first). */
|
||||
export const DEFAULT_LOG_SORT_COLUMN: LogSortBy = 'date'
|
||||
export const DEFAULT_LOG_SORT_DIRECTION: LogSortOrder = 'desc'
|
||||
|
||||
/**
|
||||
* Sort params for the logs resource table (`sort` + `dir`). The defaults match
|
||||
* the server's default ordering exactly, so with `clearOnDefault` a clean URL
|
||||
* means "no active sort" and clearing the sort strips both params. Shares
|
||||
* {@link logFilterUrlKeys} so sort changes replace history like filter changes.
|
||||
* the server's default ordering exactly (newest first), so with
|
||||
* `clearOnDefault` a clean URL means "no active sort" and clearing the sort
|
||||
* strips both params. Shares {@link logFilterUrlKeys} so sort changes replace
|
||||
* history like filter changes.
|
||||
*/
|
||||
export const logSortParsers = {
|
||||
sort: parseAsStringLiteral(LOG_SORT_COLUMNS).withDefault(DEFAULT_LOG_SORT_COLUMN),
|
||||
dir: parseAsStringLiteral(LOG_SORT_DIRECTIONS).withDefault(DEFAULT_LOG_SORT_DIRECTION),
|
||||
} as const
|
||||
export const logSortParams = createSortParams(LOG_SORT_COLUMNS, {
|
||||
column: 'date',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
/**
|
||||
* Read-only deep link to a specific execution. Resolves to a log row and opens
|
||||
* the details sidebar on load. Intentionally NOT stripped — the link stays
|
||||
* shareable — so it carries no `clearOnDefault`/`history` options here.
|
||||
* Deep link to a specific execution. On load it resolves to a log row and
|
||||
* opens the details sidebar; from then on it is kept in sync with the open
|
||||
* run — row click, Enter, the sidebar's next/prev navigation, and closing the
|
||||
* panel all write it — so the address bar always matches the open log and the
|
||||
* link stays shareable.
|
||||
*/
|
||||
export const executionIdParam = {
|
||||
key: 'executionId',
|
||||
parser: parseAsString,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Options for every `executionId` write. Browsing runs is view-state, not a
|
||||
* destination — `replace` keeps rapid row-to-row navigation from flooding the
|
||||
* browser back stack.
|
||||
*/
|
||||
export const executionIdWriteOptions = { history: 'replace' } as const
|
||||
|
||||
const LOG_DETAILS_TABS = ['overview', 'trace'] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import type { CreateScheduleBody, UpdateScheduleBody } from '@/lib/api/contracts/schedules'
|
||||
import { zonedWallClock } from '@/lib/core/utils/timezone'
|
||||
import type {
|
||||
TaskDraft,
|
||||
TaskEditSeed,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/components/task-modal'
|
||||
import {
|
||||
taskIdParam,
|
||||
taskIdUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/scheduled-tasks/search-params'
|
||||
import {
|
||||
cronToRecurrence,
|
||||
recurrenceToScheduleFields,
|
||||
@@ -110,8 +115,10 @@ export interface UseScheduledTasksReturn {
|
||||
/**
|
||||
* Bridges the calendar to the persisted job-schedule backend: reads the
|
||||
* workspace's scheduled tasks, expands them into the occurrences visible in the
|
||||
* current range, and exposes create/edit/delete mutations. UI-only selection
|
||||
* state lives here; all task data flows through React Query.
|
||||
* current range, and exposes create/edit/delete mutations. The open task lives
|
||||
* in the URL (`?taskId=`, deep-linkable — see {@link taskIdParam}) and the task
|
||||
* object is derived from the loaded occurrences; all task data flows through
|
||||
* React Query.
|
||||
*/
|
||||
export function useScheduledTasks({
|
||||
workspaceId,
|
||||
@@ -126,7 +133,10 @@ export function useScheduledTasks({
|
||||
const disableSchedule = useDisableSchedule()
|
||||
const resumeSchedule = useResumeSchedule()
|
||||
|
||||
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
|
||||
const [taskId, setTaskId] = useQueryState(taskIdParam.key, {
|
||||
...taskIdParam.parser,
|
||||
...taskIdUrlKeys,
|
||||
})
|
||||
|
||||
const events = useMemo(() => {
|
||||
const now = new Date()
|
||||
@@ -139,8 +149,49 @@ export function useScheduledTasks({
|
||||
|
||||
const eventsByDay = useMemo(() => bucketEventsByDay(events), [events])
|
||||
|
||||
const openTask = useCallback((task: ScheduledTask) => setSelectedTask(task), [])
|
||||
const closeTask = useCallback(() => setSelectedTask(null), [])
|
||||
/**
|
||||
* Occurrence lookup for the `?taskId=` deep link. First occurrence wins on a
|
||||
* duplicate id — a one-time schedule reuses its bare schedule id for both its
|
||||
* pending run and its last-run marker (mutually exclusive today, but the id's
|
||||
* meaning shifts as the run completes). Until schedules load — or when the
|
||||
* occurrence falls outside the current anchor/scope window — the id doesn't
|
||||
* resolve, `selectedTask` stays `null`, and the param lingers harmlessly; the
|
||||
* modal opens as soon as the id resolves. Stability of an OPEN modal relies on
|
||||
* the schedules query not refetching in the background (no refetchInterval;
|
||||
* the app-wide QueryClient disables refetchOnWindowFocus) — a mid-edit refetch
|
||||
* that regenerates occurrence ids would close the modal and drop the draft.
|
||||
*/
|
||||
const taskById = useMemo(() => {
|
||||
const byId = new Map<string, CalendarEvent>()
|
||||
for (const event of events) {
|
||||
if (!byId.has(event.task.id)) byId.set(event.task.id, event)
|
||||
}
|
||||
return byId
|
||||
}, [events])
|
||||
|
||||
const selectedTask = taskId ? (taskById.get(taskId)?.task ?? null) : null
|
||||
|
||||
const openTask = useCallback((task: ScheduledTask) => setTaskId(task.id), [setTaskId])
|
||||
/** Closing replaces the URL — Back should leave the calendar, not reopen the modal. */
|
||||
const closeTask = useCallback(() => setTaskId(null, { history: 'replace' }), [setTaskId])
|
||||
|
||||
/**
|
||||
* Mutation-driven closes replace the URL instead of pushing — Back must not
|
||||
* reopen a task the user just deleted/paused/resumed. Matches by schedule id
|
||||
* prefix because occurrence ids are `scheduleId`, `scheduleId:<runAt ISO>`,
|
||||
* or `scheduleId:last` (the ISO contains colons, so never split on `:`).
|
||||
*/
|
||||
const clearTaskIdForSchedule = useCallback(
|
||||
(scheduleId: string) =>
|
||||
setTaskId(
|
||||
(current) =>
|
||||
current !== null && (current === scheduleId || current.startsWith(`${scheduleId}:`))
|
||||
? null
|
||||
: current,
|
||||
{ history: 'replace' }
|
||||
),
|
||||
[setTaskId]
|
||||
)
|
||||
|
||||
const editSeedFor = useCallback(
|
||||
(task: ScheduledTask): TaskEditSeed | null => {
|
||||
@@ -185,7 +236,7 @@ export function useScheduledTasks({
|
||||
const deleteTask = useCallback(
|
||||
(scheduleId: string) => {
|
||||
deleteSchedule.mutate({ scheduleId, workspaceId })
|
||||
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
|
||||
clearTaskIdForSchedule(scheduleId)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
@@ -194,7 +245,7 @@ export function useScheduledTasks({
|
||||
const deleteOccurrence = useCallback(
|
||||
(scheduleId: string, occurrence: Date) => {
|
||||
excludeOccurrence.mutate({ scheduleId, workspaceId, occurrence: occurrence.toISOString() })
|
||||
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
|
||||
clearTaskIdForSchedule(scheduleId)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
@@ -203,7 +254,7 @@ export function useScheduledTasks({
|
||||
const pauseTask = useCallback(
|
||||
(scheduleId: string) => {
|
||||
disableSchedule.mutate({ scheduleId, workspaceId })
|
||||
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
|
||||
clearTaskIdForSchedule(scheduleId)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
@@ -212,7 +263,7 @@ export function useScheduledTasks({
|
||||
const resumeTask = useCallback(
|
||||
(scheduleId: string) => {
|
||||
resumeSchedule.mutate({ scheduleId, workspaceId })
|
||||
setSelectedTask((current) => (current?.scheduleId === scheduleId ? null : current))
|
||||
clearTaskIdForSchedule(scheduleId)
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[workspaceId]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createParser, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { createParser, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
|
||||
const CALENDAR_SCOPES = ['day', 'week', 'month'] as const
|
||||
|
||||
@@ -59,3 +59,20 @@ export const calendarUrlKeys = {
|
||||
history: 'replace',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The open task occurrence's id (`?taskId=`). The value is the occurrence id
|
||||
* from `scheduleToTasks`: `scheduleId` (one-time), `scheduleId:<runAt ISO>`
|
||||
* (recurring occurrence), or `scheduleId:last` (last-run marker). Nullable —
|
||||
* a clean URL means no task modal is open.
|
||||
*/
|
||||
export const taskIdParam = {
|
||||
key: 'taskId',
|
||||
parser: parseAsString,
|
||||
} as const
|
||||
|
||||
/** Opening a task is a destination — Back closes it. */
|
||||
export const taskIdUrlKeys = {
|
||||
history: 'push',
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
@@ -6,19 +6,15 @@ import { createLogger } from '@sim/logger'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import { Info, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import {
|
||||
type ApiKey,
|
||||
type ApiKeyScope,
|
||||
@@ -110,11 +106,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
const [deleteKey, setDeleteKey] = useState<ApiKey | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
|
||||
const defaultKeyType = isPersonalScope
|
||||
? 'personal'
|
||||
@@ -193,7 +185,7 @@ export function ApiKeys({ scope = 'workspace' }: ApiKeysProps) {
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search API keys...',
|
||||
}}
|
||||
actions={actions}
|
||||
|
||||
@@ -15,14 +15,10 @@ import {
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import {
|
||||
type CopilotKey,
|
||||
useCopilotKeys,
|
||||
@@ -53,11 +49,7 @@ export function Copilot() {
|
||||
const [showNewKeyDialog, setShowNewKeyDialog] = useState(false)
|
||||
const [deleteKey, setDeleteKey] = useState<CopilotKey | null>(null)
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
@@ -132,7 +124,7 @@ export function Copilot() {
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search API keys...',
|
||||
}}
|
||||
actions={actions}
|
||||
|
||||
+3
-11
@@ -6,17 +6,13 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { CustomToolModal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/components/custom-tool-modal/custom-tool-modal'
|
||||
import { useCustomTools, useDeleteCustomTool } from '@/hooks/queries/custom-tools'
|
||||
|
||||
@@ -31,11 +27,7 @@ export function CustomTools() {
|
||||
const { data: tools = [], isLoading, error, refetch: refetchTools } = useCustomTools(workspaceId)
|
||||
const deleteToolMutation = useDeleteCustomTool()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [deletingTools, setDeletingTools] = useState<Set<string>>(() => new Set())
|
||||
const [editingTool, setEditingTool] = useState<string | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
@@ -117,7 +109,7 @@ export function CustomTools() {
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search tools...',
|
||||
disabled: isLoading,
|
||||
}}
|
||||
|
||||
+5
-14
@@ -5,7 +5,7 @@ import { Badge, ChipInput, ChipSelect, Search } from '@sim/emcn'
|
||||
import { formatRelativeTime } from '@sim/utils/formatting'
|
||||
import { ArrowRight, Paperclip } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import {
|
||||
type InboxStatusFilter,
|
||||
inboxTaskParsers,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { InboxTaskItem } from '@/hooks/queries/inbox'
|
||||
import { useInboxConfig, useInboxTasks } from '@/hooks/queries/inbox'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: 'All statuses' },
|
||||
@@ -26,9 +27,6 @@ const STATUS_OPTIONS = [
|
||||
|
||||
type StatusFilter = InboxStatusFilter
|
||||
|
||||
/** Debounce window for `search` URL writes; the input itself stays instant. */
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
|
||||
const STATUS_BADGES: Record<
|
||||
string,
|
||||
{ label: string; variant: 'gray' | 'amber' | 'green' | 'red' | 'gray-secondary' }
|
||||
@@ -55,15 +53,8 @@ export function InboxTaskList() {
|
||||
* write is debounced. Filtering below is cheap in-memory over the loaded
|
||||
* tasks, so it reads the instant value too.
|
||||
*/
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
const next = value.length > 0 ? value : null
|
||||
setInboxFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setInboxFilters]
|
||||
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
|
||||
setInboxFilters({ search: value }, options)
|
||||
)
|
||||
|
||||
const { data: config } = useInboxConfig(workspaceId)
|
||||
@@ -74,7 +65,7 @@ export function InboxTaskList() {
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (!tasksData?.tasks) return []
|
||||
if (!searchTerm.trim()) return tasksData.tasks
|
||||
const term = searchTerm.toLowerCase()
|
||||
const term = searchTerm.trim().toLowerCase()
|
||||
return tasksData.tasks.filter(
|
||||
(t) =>
|
||||
t.subject?.toLowerCase().includes(term) ||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { getWorkflowStateContract } from '@/lib/api/contracts/workflows'
|
||||
@@ -24,13 +24,10 @@ import {
|
||||
mcpServerIdUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { useMcpOauthPopup } from '@/hooks/mcp/use-mcp-oauth-popup'
|
||||
import {
|
||||
type McpServer,
|
||||
@@ -191,11 +188,7 @@ export function MCP() {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingServerId, setEditingServerId] = useState<string | null>(null)
|
||||
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [deletingServers, setDeletingServers] = useState<Set<string>>(() => new Set())
|
||||
const { connectingServers: connectingOauthServers, startOauthForServer } = useMcpOauthPopup({
|
||||
workspaceId,
|
||||
@@ -626,7 +619,7 @@ export function MCP() {
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search MCPs...',
|
||||
}}
|
||||
actions={
|
||||
|
||||
+27
-63
@@ -1,24 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Chip, ChipInput, ChipModalTabs } from '@sim/emcn'
|
||||
import { Folder, Search, Workflow } from '@sim/emcn/icons'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
|
||||
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
|
||||
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import {
|
||||
DEFAULT_RECENTLY_DELETED_SORT_COLUMN,
|
||||
DEFAULT_RECENTLY_DELETED_SORT_DIRECTION,
|
||||
RECENTLY_DELETED_SORT_COLUMNS,
|
||||
type RecentlyDeletedSortColumn,
|
||||
type RecentlyDeletedTab,
|
||||
recentlyDeletedParsers,
|
||||
recentlyDeletedSortParams,
|
||||
recentlyDeletedUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
@@ -33,6 +30,8 @@ import {
|
||||
useWorkspaceFileFolders,
|
||||
} from '@/hooks/queries/workspace-file-folders'
|
||||
import { useRestoreWorkspaceFile, useWorkspaceFiles } from '@/hooks/queries/workspace-files'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
import { useFolderStore } from '@/stores/folders/store'
|
||||
import type { WorkflowFolder } from '@/stores/folders/types'
|
||||
|
||||
@@ -67,18 +66,6 @@ function getResourceHref(
|
||||
}
|
||||
}
|
||||
|
||||
type SortColumn = 'deleted' | 'name' | 'type'
|
||||
|
||||
interface SortConfig {
|
||||
column: SortColumn
|
||||
direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
const DEFAULT_SORT: SortConfig = { column: 'deleted', direction: 'desc' }
|
||||
|
||||
/** Debounce window for `search` URL writes; the input itself stays instant. */
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
|
||||
const SORT_OPTIONS: ColumnOption[] = [
|
||||
{ id: 'deleted', label: 'Deleted' },
|
||||
{ id: 'name', label: 'Name' },
|
||||
@@ -159,35 +146,26 @@ export function RecentlyDeleted() {
|
||||
const workspaceId = params?.workspaceId as string
|
||||
const workspacePermissions = useUserPermissionsContext()
|
||||
const canEdit = canMutateWorkspaceSettingsSection('recently-deleted', workspacePermissions)
|
||||
const [
|
||||
{ tab: activeTab, sort: sortColumn, dir: sortDirection, search: urlSearchTerm },
|
||||
setRecentlyDeletedFilters,
|
||||
] = useQueryStates(recentlyDeletedParsers, recentlyDeletedUrlKeys)
|
||||
const [{ tab: activeTab, search: urlSearchTerm }, setRecentlyDeletedFilters] = useQueryStates(
|
||||
recentlyDeletedParsers,
|
||||
recentlyDeletedUrlKeys
|
||||
)
|
||||
|
||||
const {
|
||||
sort: sortColumn,
|
||||
dir: sortDirection,
|
||||
activeSort,
|
||||
onSort,
|
||||
onClear,
|
||||
} = useUrlSort(recentlyDeletedSortParams, recentlyDeletedUrlKeys)
|
||||
|
||||
/**
|
||||
* The input is controlled directly by the instant nuqs value; only the URL
|
||||
* write is debounced. Filtering below is cheap in-memory over a small list, so
|
||||
* it reads the instant value too.
|
||||
*/
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim()
|
||||
const next = trimmed.length > 0 ? trimmed : null
|
||||
setRecentlyDeletedFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setRecentlyDeletedFilters]
|
||||
)
|
||||
|
||||
const activeSort = useMemo<SortConfig | null>(
|
||||
() =>
|
||||
sortColumn === DEFAULT_RECENTLY_DELETED_SORT_COLUMN &&
|
||||
sortDirection === DEFAULT_RECENTLY_DELETED_SORT_DIRECTION
|
||||
? null
|
||||
: { column: sortColumn, direction: sortDirection },
|
||||
[sortColumn, sortDirection]
|
||||
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
|
||||
setRecentlyDeletedFilters({ search: value }, options)
|
||||
)
|
||||
|
||||
const [restoringIds, setRestoringIds] = useState<Set<string>>(new Set())
|
||||
@@ -301,15 +279,13 @@ export function RecentlyDeleted() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let items = resources.filter((resource) => matchesActiveTab(resource, activeTab))
|
||||
if (urlSearchTerm.trim()) {
|
||||
const normalized = urlSearchTerm.toLowerCase()
|
||||
const normalized = urlSearchTerm.trim().toLowerCase()
|
||||
if (normalized) {
|
||||
items = items.filter((r) => r.name.toLowerCase().includes(normalized))
|
||||
}
|
||||
const col = (activeSort ?? DEFAULT_SORT).column
|
||||
const dir = (activeSort ?? DEFAULT_SORT).direction
|
||||
items.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
@@ -320,24 +296,21 @@ export function RecentlyDeleted() {
|
||||
cmp = a.deletedAt.getTime() - b.deletedAt.getTime()
|
||||
break
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
return sortDirection === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
const itemIds = new Set(items.map((item) => item.id))
|
||||
for (const [id, entry] of restoredItems) {
|
||||
if (itemIds.has(id)) continue
|
||||
if (!matchesActiveTab(entry.resource, activeTab)) continue
|
||||
if (
|
||||
urlSearchTerm.trim() &&
|
||||
!entry.resource.name.toLowerCase().includes(urlSearchTerm.toLowerCase())
|
||||
) {
|
||||
if (normalized && !entry.resource.name.toLowerCase().includes(normalized)) {
|
||||
continue
|
||||
}
|
||||
items.splice(Math.min(entry.displayIndex, items.length), 0, entry.resource)
|
||||
}
|
||||
|
||||
return items
|
||||
}, [resources, activeTab, urlSearchTerm, activeSort, restoredItems])
|
||||
}, [resources, activeTab, urlSearchTerm, sortColumn, sortDirection, restoredItems])
|
||||
|
||||
const showNoResults = urlSearchTerm.trim() && filtered.length === 0 && resources.length > 0
|
||||
|
||||
@@ -427,17 +400,8 @@ export function RecentlyDeleted() {
|
||||
config={{
|
||||
options: SORT_OPTIONS,
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => {
|
||||
const sort = (RECENTLY_DELETED_SORT_COLUMNS as readonly string[]).includes(column)
|
||||
? (column as RecentlyDeletedSortColumn)
|
||||
: DEFAULT_RECENTLY_DELETED_SORT_COLUMN
|
||||
setRecentlyDeletedFilters({ sort, dir: direction })
|
||||
},
|
||||
onClear: () =>
|
||||
setRecentlyDeletedFilters({
|
||||
sort: DEFAULT_RECENTLY_DELETED_SORT_COLUMN,
|
||||
dir: DEFAULT_RECENTLY_DELETED_SORT_DIRECTION,
|
||||
}),
|
||||
onSort,
|
||||
onClear,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+13
-14
@@ -1,4 +1,5 @@
|
||||
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
/** Selectable resource-type tabs in the Recently Deleted view. */
|
||||
export const RECENTLY_DELETED_TABS = [
|
||||
@@ -15,30 +16,28 @@ export type RecentlyDeletedTab = (typeof RECENTLY_DELETED_TABS)[number]
|
||||
/** Sortable columns for the deleted-items list. */
|
||||
export const RECENTLY_DELETED_SORT_COLUMNS = ['deleted', 'name', 'type'] as const
|
||||
|
||||
export type RecentlyDeletedSortColumn = (typeof RECENTLY_DELETED_SORT_COLUMNS)[number]
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
/** Default sort: most-recently-deleted first. */
|
||||
export const DEFAULT_RECENTLY_DELETED_SORT_COLUMN: RecentlyDeletedSortColumn = 'deleted'
|
||||
export const DEFAULT_RECENTLY_DELETED_SORT_DIRECTION = 'desc'
|
||||
/**
|
||||
* Shared `sort` + `dir` params for the deleted-items list. Default sort:
|
||||
* most-recently-deleted first. Consumed via `useUrlSort` in
|
||||
* `recently-deleted.tsx`.
|
||||
*/
|
||||
export const recentlyDeletedSortParams = createSortParams(RECENTLY_DELETED_SORT_COLUMNS, {
|
||||
column: 'deleted',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the Recently Deleted
|
||||
* settings view.
|
||||
*
|
||||
* - `tab` is the active resource-type filter.
|
||||
* - `sort` / `dir` follow the shared sort convention.
|
||||
* - `sort` / `dir` live in {@link recentlyDeletedSortParams} (shared sort
|
||||
* convention).
|
||||
* - `search` is the name filter. The input is controlled directly by the nuqs
|
||||
* value; only its URL write is debounced via `limitUrlUpdates` (`debounce`) on
|
||||
* the setter — never written on every keystroke.
|
||||
* value; only its URL write is debounced via `useDebouncedSearchSetter`.
|
||||
*/
|
||||
export const recentlyDeletedParsers = {
|
||||
tab: parseAsStringLiteral(RECENTLY_DELETED_TABS).withDefault('all'),
|
||||
sort: parseAsStringLiteral(RECENTLY_DELETED_SORT_COLUMNS).withDefault(
|
||||
DEFAULT_RECENTLY_DELETED_SORT_COLUMN
|
||||
),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_RECENTLY_DELETED_SORT_DIRECTION),
|
||||
search: parseAsString.withDefault(''),
|
||||
} as const
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ import { parseAsString } from 'nuqs/server'
|
||||
* workflow-mcp-servers). Settings sections never co-render, so they all share
|
||||
* the `search` key without collisions.
|
||||
*
|
||||
* The input is controlled directly by the nuqs value; only its URL write is
|
||||
* debounced via `limitUrlUpdates` at the hook options.
|
||||
* Consume via `useSettingsSearch` (`settings/components/use-settings-search`),
|
||||
* which owns the debounced-write wiring — the input is controlled directly by
|
||||
* the instant nuqs value; only the URL write is debounced.
|
||||
*/
|
||||
export const settingsSearchParam = {
|
||||
key: 'search',
|
||||
|
||||
+3
-11
@@ -6,7 +6,6 @@ import { createLogger } from '@sim/logger'
|
||||
import { generateShortId } from '@sim/utils/id'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import {
|
||||
clearPendingCredentialCreateRequest,
|
||||
@@ -17,14 +16,11 @@ import {
|
||||
import type { WorkspaceEnvironmentData } from '@/lib/environment/api'
|
||||
import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { isValidEnvVarName } from '@/executor/constants'
|
||||
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
|
||||
import {
|
||||
@@ -367,11 +363,7 @@ export function SecretsManager() {
|
||||
const [newWorkspaceRows, setNewWorkspaceRows] = useState<UIEnvironmentVariable[]>([
|
||||
createEmptyEnvVar(),
|
||||
])
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [showUnsavedChanges, setShowUnsavedChanges] = useState(false)
|
||||
const [workspaceVars, setWorkspaceVars] = useState<Record<string, string>>({})
|
||||
const [renamingKey, setRenamingKey] = useState<string | null>(null)
|
||||
@@ -960,7 +952,7 @@ export function SecretsManager() {
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search secrets...',
|
||||
}}
|
||||
actions={[
|
||||
|
||||
@@ -6,7 +6,6 @@ import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import {
|
||||
RoleLockTooltip,
|
||||
type WorkspaceRoleSource,
|
||||
@@ -21,11 +20,8 @@ import {
|
||||
MemberSection,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/member-list'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { InviteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workspace-header/components/invite-modal'
|
||||
import {
|
||||
useCancelWorkspaceInvitation,
|
||||
@@ -74,11 +70,7 @@ export function Teammates() {
|
||||
const params = useParams()
|
||||
const workspaceId = (params?.workspaceId as string) || ''
|
||||
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false)
|
||||
|
||||
const { data: permissions, isPending: permissionsLoading } =
|
||||
@@ -177,7 +169,7 @@ export function Teammates() {
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search teammates...',
|
||||
}}
|
||||
actions={
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useQueryState } from 'nuqs'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
/**
|
||||
* The shared `?search=` binding for settings list search boxes (teammates,
|
||||
* api-keys, copilot, custom-tools, mcp, secrets, workflow-mcp-servers).
|
||||
* Composes `useDebouncedSearchSetter`, so it carries the canonical semantics:
|
||||
* the value updates instantly (drives the controlled input and the in-memory
|
||||
* filter), non-empty URL writes are debounced, and clearing (or a
|
||||
* whitespace-only value) strips the param immediately. The setter is
|
||||
* `void`-returning so it passes straight to `SettingsPanel`'s `search.onChange`.
|
||||
*/
|
||||
export function useSettingsSearch(): [string, (value: string) => void] {
|
||||
const [searchTerm, setSearchTermParam] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
})
|
||||
const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
|
||||
return [searchTerm, setSearchTerm]
|
||||
}
|
||||
+4
-11
@@ -25,7 +25,7 @@ import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { Check, Clipboard, Plus, Server } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
@@ -35,13 +35,10 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/settings/[section]/search-params'
|
||||
import { CreateApiKeyModal } from '@/app/workspace/[workspaceId]/settings/components/api-keys/components'
|
||||
import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
|
||||
import {
|
||||
settingsSearchParam,
|
||||
settingsSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/settings/components/search-params'
|
||||
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
|
||||
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
|
||||
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
|
||||
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
|
||||
import { CreateWorkflowMcpServerModal } from '@/app/workspace/[workspaceId]/settings/components/workflow-mcp-servers/components'
|
||||
import { useApiKeys } from '@/hooks/queries/api-keys'
|
||||
import { useCreateMcpServer } from '@/hooks/queries/mcp'
|
||||
@@ -887,11 +884,7 @@ export function WorkflowMcpServers() {
|
||||
const { data: deployedWorkflows = [] } = useDeployedWorkflows(workspaceId)
|
||||
const deleteServerMutation = useDeleteWorkflowMcpServer()
|
||||
|
||||
const [searchTerm, setSearchTerm] = useQueryState(settingsSearchParam.key, {
|
||||
...settingsSearchParam.parser,
|
||||
...settingsSearchUrlKeys,
|
||||
limitUrlUpdates: debounce(300),
|
||||
})
|
||||
const [searchTerm, setSearchTerm] = useSettingsSearch()
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedServerId, setSelectedServerId] = useQueryState(mcpServerIdParam.key, {
|
||||
...mcpServerIdParam.parser,
|
||||
@@ -977,7 +970,7 @@ export function WorkflowMcpServers() {
|
||||
<SettingsPanel
|
||||
search={{
|
||||
value: searchTerm,
|
||||
onChange: (value) => void setSearchTerm(value),
|
||||
onChange: setSearchTerm,
|
||||
placeholder: 'Search servers...',
|
||||
}}
|
||||
actions={actions}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Chip, ChipConfirmModal, ChipInput, Search } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ArrowRight, Plus } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { debounce, useQueryState } from 'nuqs'
|
||||
import { useQueryState } from 'nuqs'
|
||||
import { SkillTile } from '@/app/workspace/[workspaceId]/components'
|
||||
import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header'
|
||||
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
|
||||
@@ -18,13 +18,12 @@ import {
|
||||
skillSearchUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/skills/search-params'
|
||||
import { useDeleteSkill, useSkills } from '@/hooks/queries/skills'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
|
||||
const logger = createLogger('SkillsSettings')
|
||||
|
||||
const SKILLS_LABEL = 'Skills'
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
|
||||
interface SkillItemProps {
|
||||
name: string
|
||||
description: string
|
||||
@@ -89,26 +88,16 @@ export function Skills() {
|
||||
/**
|
||||
* The input is controlled directly by the instant nuqs value; only the URL
|
||||
* write is debounced. Filtering below is cheap in-memory over a small list,
|
||||
* so it reads the instant value too. Clearing writes immediately so the
|
||||
* param drops out without lingering.
|
||||
* so it reads the instant value too.
|
||||
*/
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
const next = value.length > 0 ? value : null
|
||||
setSearchTermParam(
|
||||
next,
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setSearchTermParam]
|
||||
)
|
||||
const setSearchTerm = useDebouncedSearchSetter(setSearchTermParam)
|
||||
|
||||
/** Derive the skill being edited from the loaded list — never store the object in the URL. */
|
||||
const editingSkill = editingSkillId ? (skills.find((s) => s.id === editingSkillId) ?? null) : null
|
||||
|
||||
const filteredSkills = skills.filter((s) => {
|
||||
if (!searchTerm.trim()) return true
|
||||
const searchLower = searchTerm.toLowerCase()
|
||||
const searchLower = searchTerm.trim().toLowerCase()
|
||||
return (
|
||||
s.name.toLowerCase().includes(searchLower) ||
|
||||
s.description.toLowerCase().includes(searchLower)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
import { SORT_DIRECTIONS } from '@/lib/url-state'
|
||||
|
||||
/** Default sort direction applied when a sort column is selected. */
|
||||
export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
|
||||
import { parseAsArrayOf, parseAsString } from 'nuqs/server'
|
||||
import { createSortParams } from '@/lib/url-state'
|
||||
|
||||
/** Sortable table columns, matching the `Resource.Options` sort menu. */
|
||||
export const TABLE_SORT_COLUMNS = [
|
||||
@@ -10,21 +11,21 @@ export const TABLE_SORT_COLUMNS = [
|
||||
'updated',
|
||||
] as const
|
||||
|
||||
export type TableSortColumn = (typeof TABLE_SORT_COLUMNS)[number]
|
||||
|
||||
const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
/** Default sort: most-recently-updated first. */
|
||||
export const DEFAULT_TABLE_SORT_COLUMN: TableSortColumn = 'updated'
|
||||
export const DEFAULT_TABLE_SORT_DIRECTION = 'desc'
|
||||
/**
|
||||
* Shared `sort` + `dir` params for the Tables list. Default sort:
|
||||
* most-recently-updated first. Consumed via `useUrlSort` in `tables.tsx`.
|
||||
*/
|
||||
export const tablesSortParams = createSortParams(TABLE_SORT_COLUMNS, {
|
||||
column: 'updated',
|
||||
direction: 'desc',
|
||||
})
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query-param definitions for the Tables list.
|
||||
*
|
||||
* - `search` is the table name filter. The input is controlled directly by the
|
||||
* nuqs value; only its URL write is debounced via `limitUrlUpdates`
|
||||
* (`debounce`) on the setter — never written on every keystroke.
|
||||
* - `sort` / `dir` follow the shared sort convention (two scalar params).
|
||||
* nuqs value; only its URL write is debounced via `useDebouncedSearchSetter`.
|
||||
* - `sort` / `dir` live in {@link tablesSortParams} (shared sort convention).
|
||||
* - `rows` filters by row-count bucket; `owner` filters by creator id. Both are
|
||||
* multi-select arrays.
|
||||
*
|
||||
@@ -34,8 +35,6 @@ export const DEFAULT_TABLE_SORT_DIRECTION = 'desc'
|
||||
*/
|
||||
export const tablesParsers = {
|
||||
search: parseAsString.withDefault(''),
|
||||
sort: parseAsStringLiteral(TABLE_SORT_COLUMNS).withDefault(DEFAULT_TABLE_SORT_COLUMN),
|
||||
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_TABLE_SORT_DIRECTION),
|
||||
rows: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
owner: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
} as const
|
||||
|
||||
@@ -7,9 +7,10 @@ import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { debounce, useQueryStates } from 'nuqs'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { TableDefinition } from '@/lib/table'
|
||||
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
import type {
|
||||
FilterTag,
|
||||
ResourceAction,
|
||||
@@ -27,11 +28,8 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/tables/components'
|
||||
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
|
||||
import {
|
||||
DEFAULT_TABLE_SORT_COLUMN,
|
||||
DEFAULT_TABLE_SORT_DIRECTION,
|
||||
TABLE_SORT_COLUMNS,
|
||||
type TableSortColumn,
|
||||
tablesParsers,
|
||||
tablesSortParams,
|
||||
tablesUrlKeys,
|
||||
} from '@/app/workspace/[workspaceId]/tables/search-params'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
@@ -47,15 +45,14 @@ import {
|
||||
} from '@/hooks/queries/tables'
|
||||
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
|
||||
import { useInlineRename } from '@/hooks/use-inline-rename'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useUrlSort } from '@/hooks/use-url-sort'
|
||||
import { useImportTrayStore } from '@/stores/table/import-tray/store'
|
||||
|
||||
const logger = createLogger('Tables')
|
||||
|
||||
/** Debounce window for `search` URL writes; the input itself stays instant. */
|
||||
const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
|
||||
const COLUMNS: ResourceColumn[] = [
|
||||
{ id: 'name', header: 'Name' },
|
||||
{ id: 'columns', header: 'Columns' },
|
||||
@@ -99,46 +96,26 @@ export function Tables() {
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
|
||||
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
|
||||
|
||||
const [
|
||||
{
|
||||
search: urlSearchTerm,
|
||||
sort: sortColumn,
|
||||
dir: sortDirection,
|
||||
rows: rowCountFilter,
|
||||
owner: ownerFilter,
|
||||
},
|
||||
setTableFilters,
|
||||
] = useQueryStates(tablesParsers, tablesUrlKeys)
|
||||
const [{ search: urlSearchTerm, rows: rowCountFilter, owner: ownerFilter }, setTableFilters] =
|
||||
useQueryStates(tablesParsers, tablesUrlKeys)
|
||||
|
||||
const {
|
||||
sort: sortColumn,
|
||||
dir: sortDirection,
|
||||
activeSort,
|
||||
onSort,
|
||||
onClear,
|
||||
} = useUrlSort(tablesSortParams, tablesUrlKeys)
|
||||
|
||||
/**
|
||||
* The input is controlled directly by the instant nuqs value; only the URL
|
||||
* write is debounced. The in-memory filter below still reads a debounced value
|
||||
* so it doesn't recompute on every keystroke.
|
||||
*/
|
||||
const setSearchTerm = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim()
|
||||
const next = trimmed.length > 0 ? trimmed : null
|
||||
setTableFilters(
|
||||
{ search: next },
|
||||
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
|
||||
)
|
||||
},
|
||||
[setTableFilters]
|
||||
)
|
||||
const debouncedSearchTerm = useDebounce(urlSearchTerm, 300)
|
||||
|
||||
/**
|
||||
* The resolved sort is exposed to the sort menu only when it differs from the
|
||||
* default, mirroring the prior `null`-means-default semantics.
|
||||
*/
|
||||
const activeSort = useMemo(
|
||||
() =>
|
||||
sortColumn === DEFAULT_TABLE_SORT_COLUMN && sortDirection === DEFAULT_TABLE_SORT_DIRECTION
|
||||
? null
|
||||
: { column: sortColumn, direction: sortDirection },
|
||||
[sortColumn, sortDirection]
|
||||
const setSearchTerm = useDebouncedSearchSetter((value, options) =>
|
||||
setTableFilters({ search: value }, options)
|
||||
)
|
||||
const debouncedSearchTerm = useDebounce(urlSearchTerm, SEARCH_DEBOUNCE_MS)
|
||||
|
||||
const setRowCountFilter = useCallback(
|
||||
(next: string[]) => setTableFilters({ rows: next }),
|
||||
@@ -168,9 +145,8 @@ export function Tables() {
|
||||
} = useContextMenu()
|
||||
|
||||
const processedTables = useMemo(() => {
|
||||
let result = debouncedSearchTerm
|
||||
? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
|
||||
: tables
|
||||
const query = debouncedSearchTerm.trim().toLowerCase()
|
||||
let result = query ? tables.filter((t) => t.name.toLowerCase().includes(query)) : tables
|
||||
|
||||
if (rowCountFilter.length > 0) {
|
||||
result = result.filter((t) => {
|
||||
@@ -183,11 +159,9 @@ export function Tables() {
|
||||
if (ownerFilter.length > 0) {
|
||||
result = result.filter((t) => ownerFilter.includes(t.createdBy))
|
||||
}
|
||||
const col = activeSort?.column ?? 'updated'
|
||||
const dir = activeSort?.direction ?? 'desc'
|
||||
return [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (col) {
|
||||
switch (sortColumn) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
@@ -210,9 +184,9 @@ export function Tables() {
|
||||
break
|
||||
}
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp
|
||||
return sortDirection === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members])
|
||||
}, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, sortColumn, sortDirection, members])
|
||||
|
||||
const rows: ResourceRow[] = useMemo(
|
||||
() =>
|
||||
@@ -277,19 +251,10 @@ export function Tables() {
|
||||
{ id: 'updated', label: 'Last Updated' },
|
||||
],
|
||||
active: activeSort,
|
||||
onSort: (column, direction) => {
|
||||
const sort = (TABLE_SORT_COLUMNS as readonly string[]).includes(column)
|
||||
? (column as TableSortColumn)
|
||||
: DEFAULT_TABLE_SORT_COLUMN
|
||||
setTableFilters({ sort, dir: direction })
|
||||
},
|
||||
onClear: () =>
|
||||
setTableFilters({
|
||||
sort: DEFAULT_TABLE_SORT_COLUMN,
|
||||
dir: DEFAULT_TABLE_SORT_DIRECTION,
|
||||
}),
|
||||
onSort,
|
||||
onClear,
|
||||
}),
|
||||
[activeSort, setTableFilters]
|
||||
[activeSort, onSort, onClear]
|
||||
)
|
||||
|
||||
const rowCountDisplayLabel = useMemo(() => {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { debounce, type Options } from 'nuqs'
|
||||
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
|
||||
|
||||
type SearchWrite = (value: string | null, options?: Options) => void
|
||||
|
||||
interface UseDebouncedSearchSetterOptions {
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical setter for a nuqs-backed search param (see
|
||||
* `.claude/rules/sim-url-state.md`, "Debounced text inputs"). The input stays
|
||||
* controlled by the instant nuqs value; only the URL write is debounced.
|
||||
* Clearing (or a whitespace-only value) writes `null` immediately so the param
|
||||
* strips without lingering. The RAW value is written — never a trimmed one,
|
||||
* which would eat the user's trailing space mid-typing; consumers trim on read.
|
||||
*
|
||||
* Grouped params: `useDebouncedSearchSetter((v, o) => setFilters({ search: v }, o))`.
|
||||
* Single param: pass the `useQueryState` setter directly.
|
||||
*/
|
||||
export function useDebouncedSearchSetter(
|
||||
write: SearchWrite,
|
||||
{ debounceMs = SEARCH_DEBOUNCE_MS }: UseDebouncedSearchSetterOptions = {}
|
||||
): (value: string) => void {
|
||||
const writeRef = useRef(write)
|
||||
writeRef.current = write
|
||||
|
||||
return useCallback(
|
||||
(value: string) => {
|
||||
const next = value.trim().length > 0 ? value : null
|
||||
writeRef.current(next, next === null ? undefined : { limitUrlUpdates: debounce(debounceMs) })
|
||||
},
|
||||
[debounceMs]
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { type Options, useQueryStates } from 'nuqs'
|
||||
import type { DefaultedSortParams, NullableSortParams, SortDirection } from '@/lib/url-state'
|
||||
|
||||
/** The nullable active-sort shape the shared sort menu (`SortConfig`) consumes. */
|
||||
export interface ActiveSort {
|
||||
column: string
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
export interface UseUrlSortReturn<Sort, Dir> {
|
||||
/** Raw resolved column — feed this to query keys / comparators. */
|
||||
sort: Sort
|
||||
/** Raw resolved direction. */
|
||||
dir: Dir
|
||||
/** `null` when the list shows no active sort; plugs into `SortConfig.active`. */
|
||||
activeSort: ActiveSort | null
|
||||
/** Validates the column against the param's literal set; no-op on unknown ids. */
|
||||
onSort: (column: string, direction: SortDirection) => void
|
||||
/** Defaulted mode writes the defaults back (stripped by `clearOnDefault`); nullable mode strips both params. */
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a `createSortParams` definition (from `@/lib/url-state/sort-params`)
|
||||
* to the URL and derives the canonical sort wiring for a sortable list:
|
||||
* defaulted mode collapses an explicit default selection to "no active sort",
|
||||
* nullable mode treats `null` params as the distinct unsorted state. Pass the
|
||||
* feature's shared url-keys object (e.g. `{ history: 'replace', shallow: true,
|
||||
* clearOnDefault: true }`) as `options`.
|
||||
*/
|
||||
export function useUrlSort<C extends string>(
|
||||
params: DefaultedSortParams<C>,
|
||||
options?: Options
|
||||
): UseUrlSortReturn<C, SortDirection>
|
||||
export function useUrlSort<C extends string>(
|
||||
params: NullableSortParams<C>,
|
||||
options?: Options
|
||||
): UseUrlSortReturn<C | null, SortDirection | null>
|
||||
export function useUrlSort<C extends string>(
|
||||
params: DefaultedSortParams<C> | NullableSortParams<C>,
|
||||
options: Options = {}
|
||||
): UseUrlSortReturn<C | null, SortDirection | null> {
|
||||
const [values, setValues] = useQueryStates(
|
||||
params.parsers as NullableSortParams<C>['parsers'],
|
||||
options
|
||||
)
|
||||
const sort = values.sort ?? null
|
||||
const dir = values.dir ?? null
|
||||
const sortDefault = params.default
|
||||
|
||||
const activeSort = useMemo<ActiveSort | null>(() => {
|
||||
if (sortDefault !== null) {
|
||||
return sort === sortDefault.column && dir === sortDefault.direction
|
||||
? null
|
||||
: { column: sort as string, direction: dir as SortDirection }
|
||||
}
|
||||
return sort !== null && dir !== null ? { column: sort, direction: dir } : null
|
||||
}, [sortDefault, sort, dir])
|
||||
|
||||
const onSort = useCallback(
|
||||
(column: string, direction: SortDirection) => {
|
||||
if (!(params.columns as readonly string[]).includes(column)) return
|
||||
void setValues({ sort: column as C, dir: direction })
|
||||
},
|
||||
[params, setValues]
|
||||
)
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
void setValues(
|
||||
sortDefault !== null
|
||||
? { sort: sortDefault.column, dir: sortDefault.direction }
|
||||
: { sort: null, dir: null }
|
||||
)
|
||||
}, [sortDefault, setValues])
|
||||
|
||||
return { sort, dir, activeSort, onSort, onClear }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Shared debounce window for search-param URL writes across list surfaces.
|
||||
* The input is always controlled by the instant nuqs value; only the URL
|
||||
* write (and any query/filter consumer via `useDebounce`) waits this long.
|
||||
*/
|
||||
export const SEARCH_DEBOUNCE_MS = 300 as const
|
||||
@@ -0,0 +1,9 @@
|
||||
export { SEARCH_DEBOUNCE_MS } from '@/lib/url-state/constants'
|
||||
export {
|
||||
createSortParams,
|
||||
type DefaultedSortParams,
|
||||
type NullableSortParams,
|
||||
SORT_DIRECTIONS,
|
||||
type SortDefault,
|
||||
type SortDirection,
|
||||
} from '@/lib/url-state/sort-params'
|
||||
@@ -0,0 +1,72 @@
|
||||
import { parseAsStringLiteral, type SingleParserBuilder } from 'nuqs/server'
|
||||
|
||||
/** The two sort directions every sortable list shares. */
|
||||
export const SORT_DIRECTIONS = ['asc', 'desc'] as const
|
||||
|
||||
export type SortDirection = (typeof SORT_DIRECTIONS)[number]
|
||||
|
||||
/** The list's default ordering — what a clean URL means. */
|
||||
export interface SortDefault<C extends string> {
|
||||
column: C
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
type DefaultedParser<T extends string> = ReturnType<SingleParserBuilder<T>['withDefault']>
|
||||
|
||||
/**
|
||||
* Sort params whose defaults match the list's server/default ordering. A clean
|
||||
* URL means the default sort; explicitly selecting the default collapses back
|
||||
* to a clean URL (`clearOnDefault`), so "no active sort" and "default sort"
|
||||
* are the same state.
|
||||
*/
|
||||
export interface DefaultedSortParams<C extends string> {
|
||||
columns: readonly C[]
|
||||
default: SortDefault<C>
|
||||
parsers: { sort: DefaultedParser<C>; dir: DefaultedParser<SortDirection> }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nullable sort params for lists where "no active sort" is behaviorally
|
||||
* distinct from explicitly sorting by the fallback column (e.g. files: with no
|
||||
* sort, files order by updated/desc but folders by name/asc). The params carry
|
||||
* no defaults, so an explicit selection always persists in the URL and
|
||||
* clearing writes `null` to strip both.
|
||||
*/
|
||||
export interface NullableSortParams<C extends string> {
|
||||
columns: readonly C[]
|
||||
default: null
|
||||
parsers: { sort: SingleParserBuilder<C>; dir: SingleParserBuilder<SortDirection> }
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the canonical `sort` + `dir` URL param pair for a sortable list (see
|
||||
* `.claude/rules/sim-url-state.md`, "Sort convention"). Pass `defaultSort`
|
||||
* when the list has a fixed default ordering (the common case); omit it for
|
||||
* the nullable mode where "no active sort" is a distinct state. Consume with
|
||||
* `useUrlSort` from `@/hooks/use-url-sort`.
|
||||
*/
|
||||
export function createSortParams<const C extends string>(
|
||||
columns: readonly C[],
|
||||
defaultSort: SortDefault<NoInfer<C>>
|
||||
): DefaultedSortParams<C>
|
||||
export function createSortParams<const C extends string>(
|
||||
columns: readonly C[]
|
||||
): NullableSortParams<C>
|
||||
export function createSortParams<const C extends string>(
|
||||
columns: readonly C[],
|
||||
defaultSort?: SortDefault<NoInfer<C>>
|
||||
): DefaultedSortParams<C> | NullableSortParams<C> {
|
||||
const sort = parseAsStringLiteral(columns)
|
||||
const dir = parseAsStringLiteral(SORT_DIRECTIONS)
|
||||
if (defaultSort) {
|
||||
return {
|
||||
columns,
|
||||
default: defaultSort,
|
||||
parsers: {
|
||||
sort: sort.withDefault(defaultSort.column),
|
||||
dir: dir.withDefault(defaultSort.direction),
|
||||
},
|
||||
}
|
||||
}
|
||||
return { columns, default: null, parsers: { sort, dir } }
|
||||
}
|
||||
Reference in New Issue
Block a user