From fef3c03bdbe2a9aed22f8d48ecc5b1941803e3e4 Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 18:58:52 -0700 Subject: [PATCH 01/15] feat(url-state): introduce nuqs for type-safe query-param state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nuqs and migrate ad-hoc URL query-param handling to typed parsers. - Wrap the provider tree in `NuqsAdapter` (app/layout.tsx). - Co-locate typed param modules: - logs/search-params.ts — timeRange/level/workflowIds/folderIds/triggers/ search parsers (history: 'replace', clearOnDefault) preserving the exact prior wire encoding (kebab time-range tokens, comma-joined arrays). - integrations/[block]/search-params.ts — ephemeral `connect` literal param. - Replace the logs filter store's hand-rolled URL sync (initializeFromURL / syncWithURL / popstate) with a URL-backed `useLogFilters` hook over useQueryStates; the zustand store now holds only the non-URL viewMode toggle. - Migrate logs.tsx (executionId + search), logs-toolbar, dashboard, and the integration detail `connect` deep-link (read-then-strip) to nuqs. URL keys, defaults, and history semantics are unchanged. --- apps/sim/app/layout.tsx | 25 +- .../[block]/integration-block-detail.tsx | 29 +- .../integrations/[block]/search-params.ts | 17 + .../logs/components/dashboard/dashboard.tsx | 12 +- .../components/logs-toolbar/logs-toolbar.tsx | 23 +- .../logs/hooks/use-log-filters.ts | 194 +++++++++++ .../app/workspace/[workspaceId]/logs/logs.tsx | 101 ++---- .../[workspaceId]/logs/search-params.ts | 114 ++++++ apps/sim/package.json | 1 + apps/sim/stores/logs/filters/store.ts | 327 +----------------- apps/sim/stores/logs/filters/types.ts | 38 +- bun.lock | 5 + 12 files changed, 410 insertions(+), 476 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/logs/search-params.ts diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index 4ab0bddef79..11b83f0b4a0 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from 'next' import Script from 'next/script' import { PublicEnvScript } from 'next-runtime-env' +import { NuqsAdapter } from 'nuqs/adapters/next/app' import { BrandedLayout } from '@/components/branded-layout' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' @@ -242,17 +243,19 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src= )} - - - - - - {children} - - - - - + + + + + + + {children} + + + + + + ) diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx index f16dbf2b10c..3a2d73cf79a 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx @@ -3,7 +3,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ArrowLeft, ArrowRight, Plus } from 'lucide-react' import Link from 'next/link' -import { usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useRouter } from 'next/navigation' +import { useQueryState } from 'nuqs' import { Chip, ChipDropdown, ChipLink } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { @@ -14,13 +15,11 @@ import { import { getServiceConfigByProviderId } from '@/lib/oauth' import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal' import { IntegrationSkillsSection } from '@/app/workspace/[workspaceId]/integrations/[block]/integration-skills-section' +import { connectParam } from '@/app/workspace/[workspaceId]/integrations/[block]/search-params' import { ConnectServiceAccountModal } from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal' import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' -import { - CONNECT_MODE, - CONNECT_QUERY_PARAM, -} from '@/app/workspace/[workspaceId]/integrations/connect-route' +import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route' import { storeCuratedPrompt } from '@/blocks/integration-matcher' import { getSuggestedSkillsForBlock, @@ -47,8 +46,7 @@ interface IntegrationBlockDetailProps { export function IntegrationBlockDetail({ integration, workspaceId }: IntegrationBlockDetailProps) { useOAuthReturnRouter() const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() + const [connectMode, setConnectMode] = useQueryState(connectParam.key, connectParam.parser) const Icon = blockTypeToIconMap[integration.type] const matchingTemplates = getTemplatesForBlock(integration.type) const suggestedSkills = getSuggestedSkillsForBlock(integration.type) @@ -75,25 +73,24 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration useEffect(() => { if (hasHandledConnectQueryRef.current) return - const connect = searchParams.get(CONNECT_QUERY_PARAM) - if (!connect) return + if (!connectMode) return let handled = false - if (connect === CONNECT_MODE.oauth && oauthService) { + if (connectMode === CONNECT_MODE.oauth && oauthService) { setOAuthOpen(true) handled = true - } else if (connect === CONNECT_MODE.serviceAccount && oauthService?.serviceAccountProviderId) { + } else if ( + connectMode === CONNECT_MODE.serviceAccount && + oauthService?.serviceAccountProviderId + ) { setServiceAccountOpen(true) handled = true } if (!handled) return hasHandledConnectQueryRef.current = true - const params = new URLSearchParams(searchParams.toString()) - params.delete(CONNECT_QUERY_PARAM) - const qs = params.toString() - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) - }, [searchParams, oauthService, pathname, router]) + void setConnectMode(null, { history: 'replace', scroll: false }) + }, [connectMode, oauthService, setConnectMode]) const connectOptions = oauthService ? [ diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts new file mode 100644 index 00000000000..c5c50fd6a01 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/[block]/search-params.ts @@ -0,0 +1,17 @@ +import { parseAsStringLiteral } from 'nuqs/server' +import { + CONNECT_MODE, + CONNECT_QUERY_PARAM, +} from '@/app/workspace/[workspaceId]/integrations/connect-route' + +const CONNECT_MODE_VALUES = [CONNECT_MODE.oauth, CONNECT_MODE.serviceAccount] as const + +/** + * Typed parser for the ephemeral `?connect=oauth|service-account` deep-link on + * the integration detail page. The param is read once to pre-open the matching + * connect modal, then stripped from the URL. + */ +export const connectParam = { + key: CONNECT_QUERY_PARAM, + parser: parseAsStringLiteral(CONNECT_MODE_VALUES), +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx index 38ba3139490..a06f9bd05bb 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/dashboard/dashboard.tsx @@ -2,16 +2,15 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useParams } from 'next/navigation' -import { useShallow } from 'zustand/react/shallow' import { Loader } from '@/components/emcn' import { DashboardSegmentsContext, type SegmentSelectionMode, } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard-segments-context' +import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import type { DashboardStatsResponse, WorkflowStats } from '@/hooks/queries/logs' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFilterStore } from '@/stores/logs/filters/store' import { LineChart, WorkflowsList } from './components' interface WorkflowExecution { @@ -66,14 +65,7 @@ function DashboardInner({ stats, isLoading, error }: DashboardProps) { const [lastAnchorIndices, setLastAnchorIndices] = useState>({}) const lastAnchorIndicesRef = useRef>({}) - const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useFilterStore( - useShallow((s) => ({ - workflowIds: s.workflowIds, - searchQuery: s.searchQuery, - toggleWorkflowId: s.toggleWorkflowId, - timeRange: s.timeRange, - })) - ) + const { workflowIds, searchQuery, toggleWorkflowId, timeRange } = useLogFilters() const { workspaceId } = useParams<{ workspaceId: string }>() const { data: allWorkflowList = [], isPending: isWorkflowsPending } = useWorkflows(workspaceId) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx index f628b636b9c..b173d8e1272 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/components/logs-toolbar/logs-toolbar.tsx @@ -4,7 +4,6 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react' import { ArrowUp, Library, MoreHorizontal, RefreshCw } from 'lucide-react' import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { useShallow } from 'zustand/react/shallow' import { Button, ChipCombobox, @@ -21,6 +20,7 @@ import { cn } from '@/lib/core/utils/cn' import { hasActiveFilters } from '@/lib/logs/filters' import { getTriggerOptions } from '@/lib/logs/get-trigger-options' import { captureEvent } from '@/lib/posthog/client' +import { useLogFilters } from '@/app/workspace/[workspaceId]/logs/hooks/use-log-filters' import { formatDateShort, type LogStatus, @@ -29,7 +29,6 @@ import { import { getBlock } from '@/blocks/registry' import { useFolderMap } from '@/hooks/queries/folders' import { useWorkflows } from '@/hooks/queries/workflows' -import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { AutocompleteSearch } from './components/search' @@ -181,25 +180,7 @@ export const LogsToolbar = memo(function LogsToolbar({ setDateRange, clearDateRange, resetFilters, - } = useFilterStore( - useShallow((s) => ({ - level: s.level, - setLevel: s.setLevel, - workflowIds: s.workflowIds, - setWorkflowIds: s.setWorkflowIds, - folderIds: s.folderIds, - setFolderIds: s.setFolderIds, - triggers: s.triggers, - setTriggers: s.setTriggers, - timeRange: s.timeRange, - setTimeRange: s.setTimeRange, - startDate: s.startDate, - endDate: s.endDate, - setDateRange: s.setDateRange, - clearDateRange: s.clearDateRange, - resetFilters: s.resetFilters, - })) - ) + } = useLogFilters() const [datePickerOpen, setDatePickerOpen] = useState(false) const [previousTimeRange, setPreviousTimeRange] = useState(timeRange) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts new file mode 100644 index 00000000000..7ea669abecf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts @@ -0,0 +1,194 @@ +'use client' + +import { useCallback, useMemo } from 'react' +import { useQueryStates } from 'nuqs' +import { + logFilterParsers, + logFilterUrlKeys, +} from '@/app/workspace/[workspaceId]/logs/search-params' +import type { LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/types' + +const DEFAULT_TIME_RANGE: TimeRange = 'All time' + +/** + * The logs filter state, sourced entirely from typed URL query params via nuqs. + * + * This replaces the former hand-rolled `useFilterStore` URL sync (`syncWithURL` + * / `initializeFromURL` / `popstate`). The URL is now the single source of + * truth — initialization, serialization, and back/forward navigation are all + * handled by nuqs. The action surface mirrors the previous store so consumers + * migrate with minimal churn. + */ +export interface UseLogFilters { + timeRange: TimeRange + startDate: string | undefined + endDate: string | undefined + level: LogLevel + workflowIds: string[] + folderIds: string[] + triggers: TriggerType[] + searchQuery: string + + setTimeRange: (timeRange: TimeRange) => void + setDateRange: (startDate: string | undefined, endDate: string | undefined) => void + clearDateRange: () => void + setLevel: (level: LogLevel) => void + setWorkflowIds: (workflowIds: string[]) => void + toggleWorkflowId: (workflowId: string) => void + setFolderIds: (folderIds: string[]) => void + toggleFolderId: (folderId: string) => void + setSearchQuery: (query: string) => void + setTriggers: (triggers: TriggerType[]) => void + toggleTrigger: (trigger: TriggerType) => void + resetFilters: () => void +} + +/** + * Hook exposing the logs filter state and actions backed by URL query params. + * `startDate`/`endDate` are only retained while the time range is "Custom range" + * to match the prior store semantics. + */ +export function useLogFilters(): UseLogFilters { + const [filters, setFilters] = useQueryStates(logFilterParsers, logFilterUrlKeys) + + const setTimeRange = useCallback( + (timeRange: TimeRange) => { + if (timeRange === 'Custom range') { + setFilters({ timeRange }) + } else { + setFilters({ timeRange, startDate: null, endDate: null }) + } + }, + [setFilters] + ) + + const setDateRange = useCallback( + (startDate: string | undefined, endDate: string | undefined) => { + setFilters({ + timeRange: 'Custom range', + startDate: startDate ?? null, + endDate: endDate ?? null, + }) + }, + [setFilters] + ) + + const clearDateRange = useCallback(() => { + setFilters({ timeRange: DEFAULT_TIME_RANGE, startDate: null, endDate: null }) + }, [setFilters]) + + const setLevel = useCallback((level: LogLevel) => setFilters({ level }), [setFilters]) + + const setWorkflowIds = useCallback( + (workflowIds: string[]) => setFilters({ workflowIds }), + [setFilters] + ) + + const toggleWorkflowId = useCallback( + (workflowId: string) => { + setFilters((prev) => { + const current = prev.workflowIds + const next = current.includes(workflowId) + ? current.filter((id) => id !== workflowId) + : [...current, workflowId] + return { workflowIds: next } + }) + }, + [setFilters] + ) + + const setFolderIds = useCallback((folderIds: string[]) => setFilters({ folderIds }), [setFilters]) + + const toggleFolderId = useCallback( + (folderId: string) => { + setFilters((prev) => { + const current = prev.folderIds + const next = current.includes(folderId) + ? current.filter((id) => id !== folderId) + : [...current, folderId] + return { folderIds: next } + }) + }, + [setFilters] + ) + + const setSearchQuery = useCallback( + (query: string) => { + const trimmed = query.trim() + setFilters({ search: trimmed.length > 0 ? trimmed : null }) + }, + [setFilters] + ) + + const setTriggers = useCallback( + (triggers: TriggerType[]) => setFilters({ triggers }), + [setFilters] + ) + + const toggleTrigger = useCallback( + (trigger: TriggerType) => { + setFilters((prev) => { + const current = prev.triggers + const next = current.includes(trigger) + ? current.filter((t) => t !== trigger) + : [...current, trigger] + return { triggers: next } + }) + }, + [setFilters] + ) + + const resetFilters = useCallback(() => { + setFilters({ + timeRange: DEFAULT_TIME_RANGE, + startDate: null, + endDate: null, + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + search: null, + }) + }, [setFilters]) + + return useMemo( + () => ({ + timeRange: filters.timeRange, + startDate: + filters.timeRange === 'Custom range' ? (filters.startDate ?? undefined) : undefined, + endDate: filters.timeRange === 'Custom range' ? (filters.endDate ?? undefined) : undefined, + level: filters.level, + workflowIds: filters.workflowIds, + folderIds: filters.folderIds, + triggers: filters.triggers, + searchQuery: filters.search, + setTimeRange, + setDateRange, + clearDateRange, + setLevel, + setWorkflowIds, + toggleWorkflowId, + setFolderIds, + toggleFolderId, + setSearchQuery, + setTriggers, + toggleTrigger, + resetFilters, + }), + [ + filters, + setTimeRange, + setDateRange, + clearDateRange, + setLevel, + setWorkflowIds, + toggleWorkflowId, + setFolderIds, + toggleFolderId, + setSearchQuery, + setTriggers, + toggleTrigger, + resetFilters, + ] + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx index df99b7cb097..fdc71f0853d 100644 --- a/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/logs/logs.tsx @@ -12,7 +12,7 @@ import { import { formatDuration } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useParams } from 'next/navigation' -import { useShallow } from 'zustand/react/shallow' +import { parseAsString, useQueryState } from 'nuqs' import { Button, ChipCombobox, @@ -74,6 +74,7 @@ import { useDebounce } from '@/hooks/use-debounce' import { useFilterStore } from '@/stores/logs/filters/store' import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types' import { Dashboard, ExecutionSnapshot, LogDetails, LogRowContextMenu } from './components' +import { useLogFilters } from './hooks/use-log-filters' import { DELETED_WORKFLOW_LABEL, extractRetryInput, @@ -200,14 +201,7 @@ export default function Logs() { const params = useParams() const workspaceId = params.workspaceId as string - useState(() => { - useFilterStore.getState().initializeFromURL() - return null - }) - const { - setWorkspaceId, - initializeFromURL, timeRange, startDate, endDate, @@ -215,10 +209,9 @@ export default function Logs() { workflowIds, folderIds, setWorkflowIds, - setSearchQuery: setStoreSearchQuery, + searchQuery: urlSearchQuery, + setSearchQuery: setUrlSearchQuery, triggers, - viewMode, - setViewMode, resetFilters, setLevel, setFolderIds, @@ -226,49 +219,20 @@ export default function Logs() { setTimeRange, setDateRange, clearDateRange, - } = useFilterStore( - useShallow((s) => ({ - setWorkspaceId: s.setWorkspaceId, - initializeFromURL: s.initializeFromURL, - timeRange: s.timeRange, - startDate: s.startDate, - endDate: s.endDate, - level: s.level, - workflowIds: s.workflowIds, - folderIds: s.folderIds, - setWorkflowIds: s.setWorkflowIds, - setSearchQuery: s.setSearchQuery, - triggers: s.triggers, - viewMode: s.viewMode, - setViewMode: s.setViewMode, - resetFilters: s.resetFilters, - setLevel: s.setLevel, - setFolderIds: s.setFolderIds, - setTriggers: s.setTriggers, - setTimeRange: s.setTimeRange, - setDateRange: s.setDateRange, - clearDateRange: s.clearDateRange, - })) - ) + } = useLogFilters() - useEffect(() => { - setWorkspaceId(workspaceId) - }, [workspaceId, setWorkspaceId]) + const viewMode = useFilterStore((s) => s.viewMode) + const setViewMode = useFilterStore((s) => s.setViewMode) const [{ selectedLogId, isSidebarOpen }, dispatch] = useReducer(logSelectionReducer, { selectedLogId: null, isSidebarOpen: false, }) - const [pendingExecutionId, setPendingExecutionId] = useState(() => - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('executionId') - : null - ) - const [searchQuery, setSearchQuery] = useState(() => { - if (typeof window === 'undefined') return '' - return new URLSearchParams(window.location.search).get('search') ?? '' - }) + const [executionId] = useQueryState('executionId', parseAsString) + const [pendingExecutionId, setPendingExecutionId] = useState(() => executionId) + + const [searchQuery, setSearchQuery] = useState(urlSearchQuery) const debouncedSearchQuery = useDebounce(searchQuery, 300) const isLive = true @@ -420,8 +384,8 @@ export default function Logs() { }, []) useEffect(() => { - setStoreSearchQuery(debouncedSearchQuery) - }, [debouncedSearchQuery, setStoreSearchQuery]) + setUrlSearchQuery(debouncedSearchQuery) + }, [debouncedSearchQuery, setUrlSearchQuery]) const handleLogClick = useCallback((rowId: string) => { dispatch({ type: 'TOGGLE_LOG', logId: rowId }) @@ -649,16 +613,17 @@ export default function Logs() { debouncedSearchQuery, ]) + /** + * Mirror external URL `search` changes (back/forward navigation, programmatic + * resets) into the local input state. nuqs keeps the filter state itself in + * sync with the URL; this only reconciles the debounced local input mirror. + */ + const lastSyncedUrlSearchRef = useRef(urlSearchQuery) useEffect(() => { - const handlePopState = () => { - initializeFromURL() - const params = new URLSearchParams(window.location.search) - setSearchQuery(params.get('search') || '') - } - - window.addEventListener('popstate', handlePopState) - return () => window.removeEventListener('popstate', handlePopState) - }, [initializeFromURL]) + if (urlSearchQuery === lastSyncedUrlSearchRef.current) return + lastSyncedUrlSearchRef.current = urlSearchQuery + setSearchQuery((current) => (current.trim() === urlSearchQuery ? current : urlSearchQuery)) + }, [urlSearchQuery]) const loadMoreLogs = useCallback(() => { const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current @@ -1197,25 +1162,7 @@ function LogsFilterPanel({ searchQuery, onSearchQueryChange }: LogsFilterPanelPr setDateRange, clearDateRange, resetFilters, - } = useFilterStore( - useShallow((s) => ({ - level: s.level, - setLevel: s.setLevel, - workflowIds: s.workflowIds, - setWorkflowIds: s.setWorkflowIds, - folderIds: s.folderIds, - setFolderIds: s.setFolderIds, - triggers: s.triggers, - setTriggers: s.setTriggers, - timeRange: s.timeRange, - setTimeRange: s.setTimeRange, - startDate: s.startDate, - endDate: s.endDate, - setDateRange: s.setDateRange, - clearDateRange: s.clearDateRange, - resetFilters: s.resetFilters, - })) - ) + } = useLogFilters() const [datePickerOpen, setDatePickerOpen] = useState(false) const previousTimeRangeRef = useRef(timeRange) diff --git a/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts new file mode 100644 index 00000000000..599ec5e320a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/logs/search-params.ts @@ -0,0 +1,114 @@ +import { createParser, parseAsArrayOf, parseAsString } from 'nuqs/server' +import { + CORE_TRIGGER_TYPES, + type LogLevel, + type TimeRange, + type TriggerType, +} from '@/stores/logs/filters/types' + +/** + * Co-located, typed URL query-param definitions for the logs feature. Both the + * client (`useLogFilters`) and any server component that needs to read these + * params consume this 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. + */ + +const DEFAULT_TIME_RANGE: TimeRange = 'All time' + +/** Maps a {@link TimeRange} label to its stable URL token and back. */ +const TIME_RANGE_TO_TOKEN: Record = { + 'All time': 'all-time', + 'Past 30 minutes': 'past-30-minutes', + 'Past hour': 'past-hour', + 'Past 6 hours': 'past-6-hours', + 'Past 12 hours': 'past-12-hours', + 'Past 24 hours': 'past-24-hours', + 'Past 3 days': 'past-3-days', + 'Past 7 days': 'past-7-days', + 'Past 14 days': 'past-14-days', + 'Past 30 days': 'past-30-days', + 'Custom range': 'custom', +} + +const TOKEN_TO_TIME_RANGE: Record = Object.fromEntries( + Object.entries(TIME_RANGE_TO_TOKEN).map(([label, token]) => [token, label as TimeRange]) +) as Record + +/** + * Parser for the `timeRange` param. Serializes labels to kebab tokens and + * tolerantly maps unknown tokens back to the default ("All time"). + */ +export const parseAsTimeRange = createParser({ + parse(value) { + return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE + }, + serialize(value) { + return TIME_RANGE_TO_TOKEN[value] ?? 'all-time' + }, +}) + +const VALID_LEVELS = ['error', 'info', 'running', 'pending'] as const + +/** + * Parser for the `level` param. `level` is a comma-joined list of statuses on + * the wire but is surfaced as a single `LogLevel` value ("all", a single status, + * or a comma-joined string) to match the existing store contract. + */ +export const parseAsLogLevel = createParser({ + parse(value) { + const levels = value + .split(',') + .filter((l): l is (typeof VALID_LEVELS)[number] => + (VALID_LEVELS as readonly string[]).includes(l) + ) + if (levels.length === 0) return 'all' + if (levels.length === 1) return levels[0] + return levels.join(',') as LogLevel + }, + serialize(value) { + return value + }, +}) + +const CORE_TRIGGER_SET = new Set(CORE_TRIGGER_TYPES) + +/** + * Parser for the `triggers` param, restricted to known core trigger types. + * Surfaced as `TriggerType[]` to match the consumer contract — unknown tokens + * are dropped (mirrors the prior `parseTriggerArrayFromURL` behavior). + */ +export const parseAsTriggers = createParser({ + parse(value) { + const triggers = value.split(',').filter((t): t is TriggerType => CORE_TRIGGER_SET.has(t)) + return triggers + }, + serialize(value) { + return value.join(',') + }, +}).withDefault([]) + +/** + * The nuqs parser map for every URL-synced logs filter. `clearOnDefault` keeps + * the URL clean (params drop out when they hold their default value) and + * `history: 'replace'` matches the prior `history.replaceState` behavior so + * filter changes don't pollute the browser back stack. + */ +export const logFilterParsers = { + timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE), + startDate: parseAsString, + endDate: parseAsString, + level: parseAsLogLevel.withDefault('all'), + workflowIds: parseAsArrayOf(parseAsString).withDefault([]), + folderIds: parseAsArrayOf(parseAsString).withDefault([]), + triggers: parseAsTriggers, + search: parseAsString.withDefault(''), +} as const + +/** Shared nuqs options for the logs filters: clean URLs, no back-stack churn. */ +export const logFilterUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/package.json b/apps/sim/package.json index 85fd1c508a1..a030bf172a5 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -169,6 +169,7 @@ "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", "nodemailer": "8.0.9", + "nuqs": "2.8.9", "officeparser": "^5.2.0", "openai": "^4.91.1", "pdf-lib": "1.17.1", diff --git a/apps/sim/stores/logs/filters/store.ts b/apps/sim/stores/logs/filters/store.ts index 4d9ffee18cf..d9b1aa02798 100644 --- a/apps/sim/stores/logs/filters/store.ts +++ b/apps/sim/stores/logs/filters/store.ts @@ -1,314 +1,17 @@ import { create } from 'zustand' -import { - CORE_TRIGGER_TYPES, - type FilterState, - type LogLevel, - type TimeRange, - type TriggerType, -} from '@/stores/logs/filters/types' - -const getSearchParams = () => { - if (typeof window === 'undefined') return new URLSearchParams() - return new URLSearchParams(window.location.search) -} - -const updateURL = (params: URLSearchParams) => { - if (typeof window === 'undefined') return - const url = new URL(window.location.href) - url.search = params.toString() - window.history.replaceState({}, '', url) -} - -const DEFAULT_TIME_RANGE: TimeRange = 'All time' - -const parseTimeRangeFromURL = (value: string | null): TimeRange => { - switch (value) { - case 'all-time': - return 'All time' - case 'past-30-minutes': - return 'Past 30 minutes' - case 'past-hour': - return 'Past hour' - case 'past-6-hours': - return 'Past 6 hours' - case 'past-12-hours': - return 'Past 12 hours' - case 'past-24-hours': - return 'Past 24 hours' - case 'past-3-days': - return 'Past 3 days' - case 'past-7-days': - return 'Past 7 days' - case 'past-14-days': - return 'Past 14 days' - case 'past-30-days': - return 'Past 30 days' - case 'custom': - return 'Custom range' - default: - return DEFAULT_TIME_RANGE - } -} - -const parseLogLevelFromURL = (value: string | null): LogLevel => { - if (!value) return 'all' - const levels = value.split(',').filter(Boolean) - const validLevels = levels.filter( - (l) => l === 'error' || l === 'info' || l === 'running' || l === 'pending' +import { devtools } from 'zustand/middleware' +import type { LogViewState } from '@/stores/logs/filters/types' + +/** + * Logs view store. Holds only non-URL view state (the logs/dashboard toggle). + * All filter state is URL-backed via `useLogFilters` (nuqs). + */ +export const useFilterStore = create()( + devtools( + (set) => ({ + viewMode: 'logs', + setViewMode: (viewMode) => set({ viewMode }), + }), + { name: 'logs-view-store' } ) - if (validLevels.length === 0) return 'all' - if (validLevels.length === 1) return validLevels[0] as LogLevel - return validLevels.join(',') as LogLevel -} - -const parseTriggerArrayFromURL = (value: string | null): TriggerType[] => { - if (!value) return [] - return value - .split(',') - .filter((t): t is TriggerType => (CORE_TRIGGER_TYPES as readonly string[]).includes(t)) -} - -const parseStringArrayFromURL = (value: string | null): string[] => { - if (!value) return [] - return value.split(',').filter(Boolean) -} - -const timeRangeToURL = (timeRange: TimeRange): string => { - switch (timeRange) { - case 'Past 30 minutes': - return 'past-30-minutes' - case 'Past hour': - return 'past-hour' - case 'Past 6 hours': - return 'past-6-hours' - case 'Past 12 hours': - return 'past-12-hours' - case 'Past 24 hours': - return 'past-24-hours' - case 'Past 3 days': - return 'past-3-days' - case 'Past 7 days': - return 'past-7-days' - case 'Past 14 days': - return 'past-14-days' - case 'Past 30 days': - return 'past-30-days' - case 'Custom range': - return 'custom' - default: - return 'all-time' - } -} - -export const useFilterStore = create((set, get) => ({ - workspaceId: '', - viewMode: 'logs', - timeRange: DEFAULT_TIME_RANGE, - startDate: undefined, - endDate: undefined, - level: 'all', - workflowIds: [], - folderIds: [], - searchQuery: '', - triggers: [], - isInitializing: false, - - setWorkspaceId: (workspaceId) => set({ workspaceId }), - - setViewMode: (viewMode) => set({ viewMode }), - - setTimeRange: (timeRange) => { - set({ timeRange }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - setDateRange: (start, end) => { - set({ - timeRange: 'Custom range', - startDate: start, - endDate: end, - }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - clearDateRange: () => { - set({ - timeRange: DEFAULT_TIME_RANGE, - startDate: undefined, - endDate: undefined, - }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - setLevel: (level) => { - set({ level }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - setWorkflowIds: (workflowIds) => { - set({ workflowIds }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - toggleWorkflowId: (workflowId) => { - const currentWorkflowIds = [...get().workflowIds] - const index = currentWorkflowIds.indexOf(workflowId) - - if (index === -1) { - currentWorkflowIds.push(workflowId) - } else { - currentWorkflowIds.splice(index, 1) - } - - set({ workflowIds: currentWorkflowIds }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - setFolderIds: (folderIds) => { - set({ folderIds }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - toggleFolderId: (folderId) => { - const currentFolderIds = [...get().folderIds] - const index = currentFolderIds.indexOf(folderId) - - if (index === -1) { - currentFolderIds.push(folderId) - } else { - currentFolderIds.splice(index, 1) - } - - set({ folderIds: currentFolderIds }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - setSearchQuery: (searchQuery) => { - set({ searchQuery }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - setTriggers: (triggers: TriggerType[]) => { - set({ triggers }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - toggleTrigger: (trigger: TriggerType) => { - const currentTriggers = [...get().triggers] - const index = currentTriggers.indexOf(trigger) - - if (index === -1) { - currentTriggers.push(trigger) - } else { - currentTriggers.splice(index, 1) - } - - set({ triggers: currentTriggers }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - initializeFromURL: () => { - set({ isInitializing: true }) - - const params = getSearchParams() - const timeRange = parseTimeRangeFromURL(params.get('timeRange')) - const level = parseLogLevelFromURL(params.get('level')) - const workflowIds = parseStringArrayFromURL(params.get('workflowIds')) - const folderIds = parseStringArrayFromURL(params.get('folderIds')) - const triggers = parseTriggerArrayFromURL(params.get('triggers')) - const searchQuery = params.get('search') || '' - const startDate = params.get('startDate') || undefined - const endDate = params.get('endDate') || undefined - - set({ - timeRange, - startDate, - endDate, - level, - workflowIds, - folderIds, - triggers, - searchQuery, - isInitializing: false, - }) - }, - - resetFilters: () => { - set({ - timeRange: DEFAULT_TIME_RANGE, - startDate: undefined, - endDate: undefined, - level: 'all', - workflowIds: [], - folderIds: [], - triggers: [], - searchQuery: '', - }) - if (!get().isInitializing) { - get().syncWithURL() - } - }, - - syncWithURL: () => { - const { timeRange, startDate, endDate, level, workflowIds, folderIds, triggers, searchQuery } = - get() - const params = new URLSearchParams() - - if (timeRange !== DEFAULT_TIME_RANGE) { - params.set('timeRange', timeRangeToURL(timeRange)) - } - - if (timeRange === 'Custom range') { - if (startDate) { - params.set('startDate', startDate) - } - if (endDate) { - params.set('endDate', endDate) - } - } - - if (level !== 'all') { - params.set('level', level) - } - - if (workflowIds.length > 0) { - params.set('workflowIds', workflowIds.join(',')) - } - - if (folderIds.length > 0) { - params.set('folderIds', folderIds.join(',')) - } - - if (triggers.length > 0) { - params.set('triggers', triggers.join(',')) - } - - if (searchQuery.trim()) { - params.set('search', searchQuery.trim()) - } - - updateURL(params) - }, -})) +) diff --git a/apps/sim/stores/logs/filters/types.ts b/apps/sim/stores/logs/filters/types.ts index cf95d3bee3e..32e6925cb10 100644 --- a/apps/sim/stores/logs/filters/types.ts +++ b/apps/sim/stores/logs/filters/types.ts @@ -38,34 +38,14 @@ export type CoreTriggerType = (typeof CORE_TRIGGER_TYPES)[number] export type TriggerType = CoreTriggerType | 'all' | (string & {}) -/** Filter state for logs and dashboard views */ -export interface FilterState { - workspaceId: string - viewMode: 'logs' | 'dashboard' - timeRange: TimeRange - startDate?: string - endDate?: string - level: LogLevel - workflowIds: string[] - folderIds: string[] - searchQuery: string - triggers: TriggerType[] - isInitializing: boolean +export type LogViewMode = 'logs' | 'dashboard' - setWorkspaceId: (workspaceId: string) => void - setViewMode: (viewMode: 'logs' | 'dashboard') => void - setTimeRange: (timeRange: TimeRange) => void - setDateRange: (startDate: string | undefined, endDate: string | undefined) => void - clearDateRange: () => void - setLevel: (level: LogLevel) => void - setWorkflowIds: (workflowIds: string[]) => void - toggleWorkflowId: (workflowId: string) => void - setFolderIds: (folderIds: string[]) => void - toggleFolderId: (folderId: string) => void - setSearchQuery: (query: string) => void - setTriggers: (triggers: TriggerType[]) => void - toggleTrigger: (trigger: TriggerType) => void - initializeFromURL: () => void - syncWithURL: () => void - resetFilters: () => void +/** + * Non-URL logs view state. The filter state itself (time range, level, + * workflows, folders, triggers, search) lives in the URL via nuqs + * (`useLogFilters`); only the view-mode toggle is kept in this store. + */ +export interface LogViewState { + viewMode: LogViewMode + setViewMode: (viewMode: LogViewMode) => void } diff --git a/bun.lock b/bun.lock index 18ded27e0c7..ec76f4c62d8 100644 --- a/bun.lock +++ b/bun.lock @@ -225,6 +225,7 @@ "next-runtime-env": "3.3.0", "next-themes": "^0.4.6", "nodemailer": "8.0.9", + "nuqs": "2.8.9", "officeparser": "^5.2.0", "openai": "^4.91.1", "pdf-lib": "1.17.1", @@ -3092,6 +3093,8 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + "nuqs": ["nuqs@2.8.9", "", { "dependencies": { "@standard-schema/spec": "1.0.0" }, "peerDependencies": { "@remix-run/react": ">=2", "@tanstack/react-router": "^1", "next": ">=14.2.0", "react": ">=18.2.0 || ^19.0.0-0", "react-router": "^5 || ^6 || ^7", "react-router-dom": "^5 || ^6 || ^7" }, "optionalPeers": ["@remix-run/react", "@tanstack/react-router", "next", "react-router", "react-router-dom"] }, "sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ=="], + "nwsapi": ["nwsapi@2.2.24", "", {}, "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A=="], "nypm": ["nypm@0.6.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^2.0.0", "tinyexec": "^0.3.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg=="], @@ -4414,6 +4417,8 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + "nuqs/@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "nypm/pkg-types": ["pkg-types@2.3.1", "", { "dependencies": { "confbox": "^0.2.4", "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg=="], "nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], From fcfe99f2c75223d65ffd64edd40e375e5f03957c Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 20 Jun 2026 20:03:40 -0700 Subject: [PATCH 02/15] feat(url-state): migrate deferred sites to nuqs + add url-state rule Migrate the deferred query-param sites to typed nuqs parsers, each with a co-located search-params.ts single source of truth: - settings/[section]: mcpServerId deep-link - files: folderId (history: push) + new compose flag - knowledge/[id]: addConnector read-then-strip deep-link - knowledge/[id]/[documentId]: page (int, default 1) + chunk deep-link Workflow editor intentionally left store-backed (socket-synced / high-frequency / persisted-preference view-state); documented in the rule's carve-out. Add .claude/rules/sim-url-state.md (decision framework, conventions, server cache + debounced-input patterns, editor carve-out); cross-link from CLAUDE.md and sim-queries.md. --- .claude/rules/sim-queries.md | 2 + .claude/rules/sim-url-state.md | 140 ++++++++++++++++++ CLAUDE.md | 6 + .../workspace/[workspaceId]/files/files.tsx | 28 ++-- .../workspace/[workspaceId]/files/page.tsx | 5 +- .../[workspaceId]/files/search-params.ts | 40 +++++ .../knowledge/[id]/[documentId]/document.tsx | 27 ++-- .../[id]/[documentId]/search-params.ts | 26 ++++ .../[workspaceId]/knowledge/[id]/base.tsx | 32 ++-- .../knowledge/[id]/search-params.ts | 16 ++ .../settings/[section]/search-params.ts | 14 ++ .../settings/[section]/settings.tsx | 6 +- 12 files changed, 289 insertions(+), 53 deletions(-) create mode 100644 .claude/rules/sim-url-state.md create mode 100644 apps/sim/app/workspace/[workspaceId]/files/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index f1d7270f0ca..1eb89ca5d19 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -7,6 +7,8 @@ paths: All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations. +For *client* view-state that belongs in a shareable link (tabs, filters, search, pagination, selected entity id), use URL query params via nuqs — see `.claude/rules/sim-url-state.md`. React Query owns remote data; nuqs owns shareable client view-state. + ## Query Key Factory Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation: diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md new file mode 100644 index 00000000000..2d8f9fc0d76 --- /dev/null +++ b/.claude/rules/sim-url-state.md @@ -0,0 +1,140 @@ +--- +paths: + - "apps/sim/app/**/*.tsx" + - "apps/sim/app/**/*.ts" + - "apps/sim/app/**/search-params.ts" +--- + +# URL / Query-Param State (nuqs) + +URL query state is managed with [`nuqs`](https://nuqs.dev). The `NuqsAdapter` is wired once in `apps/sim/app/layout.tsx` — do not add another. This rule is the source of truth for *what* belongs in the URL and *how* to wire it. + +## Decision framework — where does this state live? + +Pick exactly one home for each piece of state: + +- **React Query** → server/remote data. Unchanged; see `.claude/rules/sim-queries.md`. +- **URL params (nuqs)** → client view-state worth putting in a link: active tab/panel, selected entity id, filters, search query, pagination, view mode (list/grid), an open "view" drawer/modal that represents a destination. +- **Zustand** → cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, or socket-synced (canvas pan/zoom, cursor, drag state, resize widths, unsaved buffers, live collaborative selection). +- **`useState`** → purely local, single-component UI. + +Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL. + +## Anti-patterns (forbidden) + +- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state. +- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. +- `window.history.replaceState`/`pushState` to mutate a param. +- Duplicating URL state into a store and syncing it with effects / `popstate` listeners. +- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs). +- `import { z } from 'zod'` in client code for param validation — use nuqs parsers (`parseAsString`, `parseAsInteger`, `parseAsBoolean`, `parseAsStringLiteral`, `parseAsArrayOf`) or a custom `createParser`. + +These reads/mutations are **not** anti-patterns and stay as-is: + +- **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination. +- **Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`. +- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. + +## Per-feature `search-params.ts` — single source of truth + +Co-locate a `search-params.ts` next to the feature. Export the parser map (and shared options). Both the client (`useQueryStates`/`useQueryState`) and any server component (`createSearchParamsCache` from `nuqs/server`) import from this one file. Import parsers from `nuqs/server` so the module is safe to import in both client and server contexts. + +Conventions: + +- `.withDefault(...)` on every parser so reads are non-null. +- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn. +- Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`. +- `shallow: false` **only** when a Server Component / loader must re-read the param. +- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. +- For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use `createParser`. + +### Example — grouped filters (single source of truth) + +```typescript +// apps/sim/app/workspace/[workspaceId]/things/search-params.ts +import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server' + +const VIEW_MODES = ['list', 'grid'] as const + +export const thingsParsers = { + search: parseAsString.withDefault(''), + tags: parseAsArrayOf(parseAsString).withDefault([]), + view: parseAsStringLiteral(VIEW_MODES).withDefault('list'), +} as const + +/** Clean URLs, no back-stack churn for filter changes. */ +export const thingsUrlKeys = { + history: 'replace', + shallow: true, + clearOnDefault: true, +} as const +``` + +### Client — `useQueryStates` (grouped) / `useQueryState` (single) + +```typescript +'use client' + +import { useQueryStates } from 'nuqs' +import { thingsParsers, thingsUrlKeys } from '@/app/workspace/[workspaceId]/things/search-params' + +export function useThingFilters() { + const [filters, setFilters] = useQueryStates(thingsParsers, thingsUrlKeys) + // filters.search / filters.tags / filters.view are non-null (defaults applied) + // setFilters({ view: 'grid' }) — pass null to clear a single key back to default + return { filters, setFilters } +} +``` + +For a single param, use `useQueryState(key, parser)`: + +```typescript +const [serverId, setServerId] = useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser) +``` + +### Server — `createSearchParamsCache` + +When a Server Component or loader must read a param, build a cache from the **same** parser map: + +```typescript +// in a server component / page.tsx +import { createSearchParamsCache } from 'nuqs/server' +import { thingsParsers } from '@/app/workspace/[workspaceId]/things/search-params' + +const thingsCache = createSearchParamsCache(thingsParsers) + +export default async function Page({ searchParams }: { searchParams: Promise> }) { + const { search, view } = await thingsCache.parse(await searchParams) + // ... +} +``` + +If a client param must be re-read server-side after a change, set `shallow: false` on the write. + +## Suspense boundary + +`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`. + +## Debounced text inputs + +Keep local `useState` for snappy typing; push to the URL debounced, and reconcile from the URL with a ref-guarded effect so external URL changes (back/forward, deep link) flow back into the input without clobbering in-flight keystrokes. This is the established logs pattern — follow it rather than writing every keystroke to the URL. + +## Read-then-strip deep links + +For an ephemeral deep-link that pre-opens a modal/drawer and should not linger in the URL (e.g. integrations `?connect=oauth`, knowledge `?addConnector=`), read the param, act on it once behind a `useRef` guard, then clear it: `setParam(null, { history: 'replace', scroll: false })`. See `apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx`. + +## Workflow editor carve-out — what must NOT go in the URL + +The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/socket-synced via `socket-provider.tsx`. Its view-state is intentionally store-backed (Zustand), not URL-backed. Do **not** move the following into the URL: + +- **Live cursor** and **broadcast live selection** (presence; emitted over the socket, throttled). +- **Pan / zoom / viewport** (ReactFlow-owned, continuous, not persisted). +- **Drag state** and **resize widths/heights** (panel/terminal/sidebar — high-frequency, persisted as local preferences). +- **Ephemeral diff staging** (`hasActiveDiff`, `baselineWorkflow`, `diffAnalysis`). + +Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery: + +- **Panel `activeTab`** and **`canvasMode`** — persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load. +- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. + +Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas. diff --git a/CLAUDE.md b/CLAUDE.md index 9ce16b909d9..253af047932 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -367,6 +367,12 @@ export function useUpdateEntity() { } ``` +## URL / Query-Param State + +Shareable *client* view-state (active tab/panel, filters, search query, pagination, selected entity id, view mode, a deep-linked drawer/modal) lives in the URL via [`nuqs`](https://nuqs.dev) — not in a store synced with effects, and never read via `useSearchParams().get(...)` / `new URLSearchParams(window.location.search)`. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand (canvas pan/zoom, cursor, drag, resize widths, live collaborative selection). + +Co-locate a `search-params.ts` per feature exporting the parser map (single source of truth, shared by client `useQueryStates`/`useQueryState` and server `createSearchParamsCache`). Never `import { z }` in client code for params — use nuqs parsers. Full decision framework, conventions, the debounced-input pattern, and the workflow-editor carve-out are in `.claude/rules/sim-url-state.md`. + ## Styling Use Tailwind only, no inline styles. Use `cn()` from `@/lib/core/utils/cn` for conditional classes. diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index f6d8b192203..6efef184d3d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -3,7 +3,8 @@ import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { Button, @@ -73,6 +74,7 @@ import { import { FilesListContextMenu } from '@/app/workspace/[workspaceId]/files/components/files-list-context-menu' import { ShareModal } from '@/app/workspace/[workspaceId]/files/components/share-modal' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options' +import { filesParsers, filesUrlKeys } from '@/app/workspace/[workspaceId]/files/search-params' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' @@ -171,9 +173,10 @@ export function Files() { const params = useParams() const router = useRouter() - const searchParams = useSearchParams() - const isNewFile = searchParams.get('new') === '1' - const currentFolderId = searchParams.get('folderId') + const [{ folderId: currentFolderId, new: isNewFile }, setFilesParams] = useQueryStates( + filesParsers, + filesUrlKeys + ) const workspaceId = params?.workspaceId as string const posthog = usePostHog() @@ -1204,7 +1207,7 @@ export function Files() { const item = contextMenuItemRef.current if (!item) return if (item.kind === 'folder') { - router.push(`/workspace/${workspaceId}/files?folderId=${item.folder.id}`) + void setFilesParams({ folderId: item.folder.id, new: null }) closeContextMenu() return } @@ -1214,7 +1217,7 @@ export function Files() { : `/workspace/${workspaceId}/files/${item.file.id}` ) closeContextMenu() - }, [closeContextMenu, router, workspaceId]) + }, [closeContextMenu, router, workspaceId, setFilesParams]) const handleContextMenuDownload = useCallback(() => { const item = contextMenuItemRef.current @@ -1517,7 +1520,7 @@ export function Files() { if (listRenameRef.current.editingId !== rowId && !headerRenameRef.current.editingId) { const parsed = parseRowId(rowId) if (parsed.kind === 'folder') { - router.push(`/workspace/${workspaceId}/files?folderId=${parsed.id}`) + void setFilesParams({ folderId: parsed.id, new: null }) return } router.push( @@ -1527,7 +1530,7 @@ export function Files() { ) } }, - [router, workspaceId, currentFolderId] + [router, workspaceId, currentFolderId, setFilesParams] ) const handleUploadClick = useCallback(() => { @@ -1586,8 +1589,8 @@ export function Files() { ) const handleNavigateToFiles = useCallback(() => { - router.push(`/workspace/${workspaceId}/files`) - }, [router, workspaceId]) + void setFilesParams({ folderId: null, new: null }) + }, [setFilesParams]) const loadingBreadcrumbs = useMemo( (): BreadcrumbItem[] => [ @@ -1617,7 +1620,7 @@ export function Files() { label: folder.name, onClick: isCurrentFolder ? undefined - : () => router.push(`/workspace/${workspaceId}/files?folderId=${folder.id}`), + : () => void setFilesParams({ folderId: folder.id, new: null }), editing: isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id ? { @@ -1647,8 +1650,7 @@ export function Files() { currentFolderId, folders, handleNavigateToFiles, - router, - workspaceId, + setFilesParams, canEdit, userPermissions.isLoading, breadcrumbRename.editingId, diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index 514662f7a78..ab21f2f3b72 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -9,8 +9,9 @@ export const metadata: Metadata = { } /** - * Files page entry. `Files` reads `useSearchParams`, so it must sit under a - * Suspense boundary. The fallback renders the real chrome (header + options + + * Files page entry. `Files` reads URL query params via nuqs (which uses + * `useSearchParams` internally), so it must sit under a Suspense boundary. The + * fallback renders the real chrome (header + options + * table headers) so a suspend never shows a blank frame; the route-level * `loading.tsx` covers the navigation/chunk-load transition the same way. */ diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts new file mode 100644 index 00000000000..6cdd47f8bf9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -0,0 +1,40 @@ +import { createParser, parseAsString } from 'nuqs/server' + +/** + * 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 + * existing shared links keep opening the editor in compose mode. + */ +const parseAsNewFlag = createParser({ + parse(value) { + return value === '1' || value === 'true' + }, + serialize(value) { + return value ? '1' : '' + }, +}) + +/** + * Co-located, typed URL query-param definitions for the Files feature. Both the + * client (`Files`) and any server component that reads these params consume this + * single source of truth. + * + * - `folderId` is the currently open folder; it is shareable, bookmarkable, and + * navigations between folders belong in the browser history (`history: 'push'`). + * - `new` marks a freshly-created file so the editor opens in compose mode; it is + * read once on mount and stripped as the route stabilizes. + */ +export const filesParsers = { + folderId: parseAsString, + new: parseAsNewFlag.withDefault(false), +} as const + +/** + * Shared nuqs options for files query state. Folder navigation is a destination, + * so it lands in the browser history; defaults clear from the URL to keep links + * clean. + */ +export const filesUrlKeys = { + history: 'push', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 2266d160c7a..ea2d6b900c5 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react' -import { useParams, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { Badge, ChipCombobox, ChipConfirmModal, Plus, Trash } from '@/components/emcn' import { Database } from '@/components/emcn/icons' import { SearchHighlight } from '@/components/ui/search-highlight' @@ -31,6 +32,10 @@ import { DeleteChunkModal, DocumentTagsModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' +import { + documentParsers, + documentUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params' import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' @@ -121,8 +126,10 @@ export function Document({ }: DocumentProps) { const { workspaceId } = useParams() const router = useRouter() - const searchParams = useSearchParams() - const currentPageFromURL = Number.parseInt(searchParams.get('page') || '1', 10) + const [{ page: currentPageFromURL, chunk: chunkFromURL }, setDocumentParams] = useQueryStates( + documentParsers, + documentUrlKeys + ) const userPermissions = useUserPermissionsContext() const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId) @@ -182,9 +189,7 @@ export function Document({ const [selectedChunks, setSelectedChunks] = useState>(() => new Set()) // Inline editor state - const [selectedChunkId, setSelectedChunkId] = useState(() => - searchParams.get('chunk') - ) + const [selectedChunkId, setSelectedChunkId] = useState(() => chunkFromURL) const [isCreatingNewChunk, setIsCreatingNewChunk] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') @@ -224,20 +229,14 @@ export function Document({ const goToPage = useCallback( async (page: number) => { - const params = new URLSearchParams(window.location.search) - if (page > 1) { - params.set('page', page.toString()) - } else { - params.delete('page') - } - window.history.replaceState(null, '', `?${params.toString()}`) + await setDocumentParams({ page }) if (showingSearch) { return } return initialGoToPage(page) }, - [showingSearch, initialGoToPage] + [showingSearch, initialGoToPage, setDocumentParams] ) const updateChunk = showingSearch diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts new file mode 100644 index 00000000000..8abe31b1de8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/search-params.ts @@ -0,0 +1,26 @@ +import { parseAsInteger, parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for the knowledge document + * (chunk list + inline chunk editor) page. Both the client (`Document`) and any + * server component that reads these params consume this single source of truth. + * + * - `page` is the chunk pagination page, shareable and bookmarkable. It defaults + * 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. + */ +export const documentParsers = { + page: parseAsInteger.withDefault(1), + chunk: parseAsString, +} as const + +/** + * Shared nuqs options for the document page. Pagination is a transient view + * change, so it replaces history rather than churning the back stack; defaults + * clear from the URL. + */ +export const documentUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 51572bc459e..4f3ba866166 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -6,7 +6,8 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { format } from 'date-fns' import { AlertCircle, Pencil, Plus, Tag, X } from 'lucide-react' -import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation' +import { useParams, useRouter } from 'next/navigation' +import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { Badge, @@ -31,7 +32,6 @@ import { import { Database, DatabaseX } from '@/components/emcn/icons' import { SearchHighlight } from '@/components/ui/search-highlight' import { cn } from '@/lib/core/utils/cn' -import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' import { ALL_TAG_SLOTS, type AllTagSlot, getFieldTypeForSlot } from '@/lib/knowledge/constants' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types' @@ -58,6 +58,7 @@ import { DocumentContextMenu, RenameDocumentModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' +import { addConnectorParam } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -208,9 +209,10 @@ export function KnowledgeBase({ const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) const router = useRouter() - const searchParams = useSearchParams() - const pathname = usePathname() - const addConnectorParam = searchParams.get(ADD_CONNECTOR_SEARCH_PARAM) + const [addConnectorType, setAddConnectorType] = useQueryState( + addConnectorParam.key, + addConnectorParam.parser + ) const posthog = usePostHog() useEffect(() => { @@ -286,24 +288,12 @@ export function KnowledgeBase({ const [contextMenuDocument, setContextMenuDocument] = useState(null) const [showRenameModal, setShowRenameModal] = useState(false) const [documentToRename, setDocumentToRename] = useState(null) - const showAddConnectorModal = addConnectorParam != null - const searchParamsRef = useRef(searchParams) - searchParamsRef.current = searchParams + const showAddConnectorModal = addConnectorType != null const updateAddConnectorParam = useCallback( (value: string | null) => { - const current = searchParamsRef.current - const currentValue = current.get(ADD_CONNECTOR_SEARCH_PARAM) - if (value === currentValue || (value === null && currentValue === null)) return - const next = new URLSearchParams(current.toString()) - if (value === null) { - next.delete(ADD_CONNECTOR_SEARCH_PARAM) - } else { - next.set(ADD_CONNECTOR_SEARCH_PARAM, value) - } - const qs = next.toString() - router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false }) + void setAddConnectorType(value, { history: 'replace', scroll: false }) }, - [pathname, router] + [setAddConnectorType] ) const setShowAddConnectorModal = useCallback( (open: boolean) => updateAddConnectorParam(open ? '' : null), @@ -1284,7 +1274,7 @@ export function KnowledgeBase({ onOpenChange={setShowAddConnectorModal} onConnectorTypeChange={updateAddConnectorParam} knowledgeBaseId={id} - initialConnectorType={addConnectorParam || undefined} + initialConnectorType={addConnectorType || undefined} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts new file mode 100644 index 00000000000..d51f8ae0d46 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts @@ -0,0 +1,16 @@ +import { parseAsString } from 'nuqs/server' +import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' + +/** + * Co-located, typed URL query-param definition for the knowledge base detail + * page. Both the client (`KnowledgeBase`) and any server component that reads + * this param consume this single source of truth. + * + * `addConnector` is a deep-link that pre-opens the "add connector" modal. Its + * presence (even as an empty string) opens the modal; its value seeds the + * initial connector type. Mirrors the integrations `connect` deep-link pattern. + */ +export const addConnectorParam = { + key: ADD_CONNECTOR_SEARCH_PARAM, + parser: parseAsString, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts new file mode 100644 index 00000000000..2eeb80468af --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts @@ -0,0 +1,14 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for the settings section pages. + * Both the client (`SettingsPage`) and any server component that needs to read + * these params consume this single source of truth. + * + * `mcpServerId` deep-links the MCP settings tab to a specific server so the row + * can be focused/opened from a shared link. + */ +export const mcpServerIdParam = { + key: 'mcpServerId', + parser: parseAsString, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 073907ab5f9..036fe7b2c3a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -2,10 +2,11 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' -import { useSearchParams } from 'next/navigation' +import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { captureEvent } from '@/lib/posthog/client' +import { mcpServerIdParam } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { @@ -102,8 +103,7 @@ interface SettingsPageProps { } export function SettingsPage({ section }: SettingsPageProps) { - const searchParams = useSearchParams() - const mcpServerId = searchParams.get('mcpServerId') + const [mcpServerId] = useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser) const { data: session, isPending: sessionLoading } = useSession() const posthog = usePostHog() From df0df7e1b2ddbcfc7505eb8c5d8f97e037e087b0 Mon Sep 17 00:00:00 2001 From: waleed Date: Sun, 21 Jun 2026 09:45:08 -0700 Subject: [PATCH 03/15] feat(url-state): migrate remaining view-state to nuqs + harness updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the URL the single source of truth for shareable view-state across the remaining sweep-confirmed sites: - settings/mcp: replace initialServerId prop + effect-sync with a direct useQueryState (mcpServerId, history: push); stop prop-drilling from settings - integrations: selectedCategory + debounced search; add Suspense boundary - tables: debounced search + sort/dir + row-count/owner filters (activeTable stays route state — selecting a table navigates to tables/[tableId]); wire the existing loading.tsx as the Suspense fallback - knowledge/[id]: pagination page param - settings/recently-deleted: tab + sort/dir + debounced search - settings/admin: committed search (q) + pagination offset - settings/mothership: tab + environment - skills: editingSkill object -> skillId deep-link (derive from useSkills); add Suspense boundary - files: shareFileId deep-link added to files/search-params - landing integrations + models directories: debounced search + category/ provider filter; add Suspense boundaries Harness: add a When-to-use decision table, the sort (sort+dir) convention, the selected-entity deep-link pattern, and nuqs doc links to sim-url-state.md; add the /you-might-not-need-url-state command and wire it into /cleanup. --- .claude/commands/cleanup.md | 5 +- .../commands/you-might-not-need-url-state.md | 45 +++++++++++ .claude/rules/sim-url-state.md | 47 +++++++++++ .../(landing)/integrations/(shell)/page.tsx | 5 +- .../components/integration-grid.tsx | 33 +++++++- .../(landing)/integrations/search-params.ts | 23 ++++++ .../sim/app/(landing)/models/(shell)/page.tsx | 5 +- .../models/components/model-directory.tsx | 30 +++++++- .../sim/app/(landing)/models/search-params.ts | 23 ++++++ .../workspace/[workspaceId]/files/files.tsx | 17 ++-- .../[workspaceId]/files/search-params.ts | 4 + .../integrations/integrations.tsx | 36 +++++++-- .../[workspaceId]/integrations/page.tsx | 28 ++++++- .../integrations/search-params.ts | 29 +++++++ .../[workspaceId]/knowledge/[id]/base.tsx | 11 ++- .../knowledge/[id]/search-params.ts | 22 +++++- .../settings/[section]/settings.tsx | 5 +- .../settings/components/admin/admin.tsx | 38 +++++++-- .../components/admin/search-params.ts | 21 +++++ .../settings/components/mcp/mcp.tsx | 30 ++++---- .../components/mothership/mothership.tsx | 15 +++- .../components/mothership/search-params.ts | 35 +++++++++ .../recently-deleted/recently-deleted.tsx | 62 +++++++++++++-- .../recently-deleted/search-params.ts | 47 +++++++++++ .../workspace/[workspaceId]/skills/page.tsx | 24 +++++- .../[workspaceId]/skills/search-params.ts | 21 +++++ .../workspace/[workspaceId]/skills/skills.tsx | 19 +++-- .../workspace/[workspaceId]/tables/page.tsx | 16 +++- .../[workspaceId]/tables/search-params.ts | 46 +++++++++++ .../workspace/[workspaceId]/tables/tables.tsx | 77 ++++++++++++++++--- 30 files changed, 731 insertions(+), 88 deletions(-) create mode 100644 .claude/commands/you-might-not-need-url-state.md create mode 100644 apps/sim/app/(landing)/integrations/search-params.ts create mode 100644 apps/sim/app/(landing)/models/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/admin/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/mothership/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/skills/search-params.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/search-params.ts diff --git a/.claude/commands/cleanup.md b/.claude/commands/cleanup.md index e0e0f446693..8a93303eb1e 100644 --- a/.claude/commands/cleanup.md +++ b/.claude/commands/cleanup.md @@ -1,5 +1,5 @@ --- -description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, and emcn design review +description: Run all code quality skills in sequence — effects, memo, callbacks, state, url-state, React Query, and emcn design review argument-hint: [scope] [fix=true|false] --- @@ -21,5 +21,6 @@ Run each of these skills in order on the specified scope, passing through the sc 4. `/you-might-not-need-state $ARGUMENTS` 5. `/react-query-best-practices $ARGUMENTS` 6. `/emcn-design-review $ARGUMENTS` +7. `/you-might-not-need-url-state $ARGUMENTS` -After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes. +After all skills have run, output a summary of what was found and fixed (or proposed) across all seven passes. diff --git a/.claude/commands/you-might-not-need-url-state.md b/.claude/commands/you-might-not-need-url-state.md new file mode 100644 index 00000000000..77ba1ebeccb --- /dev/null +++ b/.claude/commands/you-might-not-need-url-state.md @@ -0,0 +1,45 @@ +--- +description: Analyze and fix URL/query-param state anti-patterns — manual useSearchParams reads, hand-built query mutations, view-state trapped in useState, and objects in the URL +argument-hint: [scope] [fix=true|false] +--- + +# You Might Not Need URL State + +Arguments: +- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "app/workspace/[workspaceId]/tables/", "whole codebase" +- fix: whether to apply fixes (default: true). Set to false to only propose changes. + +User arguments: $ARGUMENTS + +## Context + +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`. + +`.claude/rules/sim-url-state.md` is the source of truth — read it first. + +## References + +Read these before analyzing: +1. `.claude/rules/sim-url-state.md` — the decision framework, conventions, debounced-input pattern, sort convention, selected-entity deep-link pattern, and the workflow-editor carve-out +2. https://nuqs.dev/docs/parsers — parsers (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`) +3. https://nuqs.dev/docs/options — `withDefault`, `history`, `shallow`, `clearOnDefault` +4. https://nuqs.dev/docs/server-side — `createSearchParamsCache` for server reads + +## Anti-patterns to detect + +1. **Manual param reads for state**: `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` used to *read* view-state. Replace with `useQueryState`/`useQueryStates` bound to a `search-params.ts`. (Read-once auth/invite/redirect tokens — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `code` — are NOT view-state; leave them on `useSearchParams`.) +2. **Hand-built query mutation**: constructing a query string + `router.replace`/`router.push` to change a param on the current path. Use a nuqs setter. (A `router.push` that changes the route *path* is fine; an outbound `new URLSearchParams` building an `href`/`window.open`/download/API URL is fine.) +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. +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 `` 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. + +## 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. diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index 2d8f9fc0d76..697c60b81e8 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -20,6 +20,15 @@ Pick exactly one home for each piece of state: Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL. +### When to use what (decision table) + +| Home | Trigger | Example | +| --- | --- | --- | +| **URL (nuqs)** | Client view-state worth a link: tab, filter, search, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination | `?tab=licenses`, `?category=Communication`, `?page=3`, `?skillId=abc` | +| **React Query** | Server/remote data fetched from an endpoint | `useMcpServers(workspaceId)`, `useSkills(workspaceId)` | +| **Zustand** | Cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, socket-synced | canvas pan/zoom, live cursor, drag state, resize widths, unsaved buffers | +| **`useState`** | Purely local single-component UI; also the snappy mirror of a debounced URL search | a hover flag, a transient dialog target, the live text of a debounced search box | + ## Anti-patterns (forbidden) - Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state. @@ -119,6 +128,38 @@ If a client param must be re-read server-side after a change, set `shallow: fals Keep local `useState` for snappy typing; push to the URL debounced, and reconcile from the URL with a ref-guarded effect so external URL changes (back/forward, deep link) flow back into the input without clobbering in-flight keystrokes. This is the established logs pattern — follow it rather than writing every keystroke to the URL. +## Sort convention (`sort` + `dir`) + +Sortable lists use **two scalar params**, never a serialized `{column,direction}` object: + +```typescript +const SORT_COLUMNS = ['name', 'created', 'updated'] as const +const SORT_DIRECTIONS = ['asc', 'desc'] as const + +export const thingsParsers = { + sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'), + dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'), +} as const +``` + +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. + +## Selected-entity deep-link (store the id, derive the object) + +To deep-link a row/modal/drawer to one entity, store **only its id** and look the object up in the already-loaded list — never serialize the object into the URL: + +```typescript +const [skillId, setSkillId] = useQueryState(skillIdParam.key, { + ...skillIdParam.parser, + history: 'push', // opening an entity is a destination; "back" closes it + clearOnDefault: true, +}) +// Derive — do not duplicate into useState or sync with an effect: +const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null +``` + +Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`. + ## Read-then-strip deep links For an ephemeral deep-link that pre-opens a modal/drawer and should not linger in the URL (e.g. integrations `?connect=oauth`, knowledge `?addConnector=`), read the param, act on it once behind a `useRef` guard, then clear it: `setParam(null, { history: 'replace', scroll: false })`. See `apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx`. @@ -138,3 +179,9 @@ Borderline candidates that *look* shareable but currently stay in Zustand becaus - **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep. Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas. + +## Docs + +- Adapters (App Router `NuqsAdapter`): https://nuqs.dev/docs/adapters +- Parsers & options (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`, `withDefault`, `history`, `shallow`, `clearOnDefault`): https://nuqs.dev/docs/parsers and https://nuqs.dev/docs/options +- Server-side reads (`createSearchParamsCache`): https://nuqs.dev/docs/server-side diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx index b311e3f315f..8a930127ed8 100644 --- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx +++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { Badge } from '@/components/emcn' import { SITE_URL } from '@/lib/core/utils/urls' @@ -205,7 +206,9 @@ export default function IntegrationsPage() { All Integrations - + + +
diff --git a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx index b1f3b0bb547..0a7213bd427 100644 --- a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx +++ b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx @@ -1,9 +1,15 @@ 'use client' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useQueryStates } from 'nuqs' import { ChipInput, Search } from '@/components/emcn' import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations' import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card' +import { + integrationsDirectoryParsers, + integrationsDirectoryUrlKeys, +} from '@/app/(landing)/integrations/search-params' +import { useDebounce } from '@/hooks/use-debounce' const PILL_BASE = 'rounded-[5px] border border-[var(--landing-border-strong)] px-[9px] py-0.5 text-[13.5px] text-[var(--landing-text)] transition-colors' as const @@ -15,8 +21,29 @@ interface IntegrationGridProps { } export function IntegrationGrid({ integrations }: IntegrationGridProps) { - const [query, setQuery] = useState('') - const [activeCategory, setActiveCategory] = useState(null) + const [{ search: urlQuery, category: urlCategory }, setDirectoryFilters] = useQueryStates( + integrationsDirectoryParsers, + integrationsDirectoryUrlKeys + ) + + const [query, setQuery] = useState(urlQuery) + const debouncedQuery = useDebounce(query, 300) + + useEffect(() => { + setDirectoryFilters({ search: debouncedQuery.length > 0 ? debouncedQuery : null }) + }, [debouncedQuery, setDirectoryFilters]) + + const lastSyncedUrlSearchRef = useRef(urlQuery) + useEffect(() => { + if (urlQuery === lastSyncedUrlSearchRef.current) return + lastSyncedUrlSearchRef.current = urlQuery + setQuery((current) => (current === urlQuery ? current : urlQuery)) + }, [urlQuery]) + + const activeCategory = urlCategory.length > 0 ? urlCategory : null + const setActiveCategory = (category: string | null) => { + setDirectoryFilters({ category }) + } const counts = new Map() for (const i of integrations) { diff --git a/apps/sim/app/(landing)/integrations/search-params.ts b/apps/sim/app/(landing)/integrations/search-params.ts new file mode 100644 index 00000000000..15006ca5d95 --- /dev/null +++ b/apps/sim/app/(landing)/integrations/search-params.ts @@ -0,0 +1,23 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for the public integrations + * directory. Both the client (`IntegrationGrid`) and any server component that + * reads these params consume this single source of truth. + * + * - `search` is the directory search term, written debounced from the local + * input (logs pattern) — never on every keystroke. + * - `category` is the active integration-type filter. Categories are derived + * from the data set, so a plain string is used; the empty default (no filter) + * clears from the URL. + */ +export const integrationsDirectoryParsers = { + search: parseAsString.withDefault(''), + category: parseAsString.withDefault(''), +} as const + +/** Filter/search view-state: clean URLs, no back-stack churn. */ +export const integrationsDirectoryUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/(landing)/models/(shell)/page.tsx b/apps/sim/app/(landing)/models/(shell)/page.tsx index 002237c6feb..1c4e92d9edb 100644 --- a/apps/sim/app/(landing)/models/(shell)/page.tsx +++ b/apps/sim/app/(landing)/models/(shell)/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' import { Badge } from '@/components/emcn' import { SITE_URL } from '@/lib/core/utils/urls' @@ -225,7 +226,9 @@ export default function ModelsPage() { All models
- + + +
diff --git a/apps/sim/app/(landing)/models/components/model-directory.tsx b/apps/sim/app/(landing)/models/components/model-directory.tsx index c0c1c46f44c..80341776272 100644 --- a/apps/sim/app/(landing)/models/components/model-directory.tsx +++ b/apps/sim/app/(landing)/models/components/model-directory.tsx @@ -1,9 +1,11 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import Link from 'next/link' +import { useQueryStates } from 'nuqs' import { Input } from '@/components/emcn' import { ChevronArrow, ProviderIcon } from '@/app/(landing)/models/components/model-primitives' +import { modelDirectoryParsers, modelDirectoryUrlKeys } from '@/app/(landing)/models/search-params' import { type CatalogModel, type CatalogProvider, @@ -12,10 +14,32 @@ import { MODEL_PROVIDERS_WITH_CATALOGS, MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS, } from '@/app/(landing)/models/utils' +import { useDebounce } from '@/hooks/use-debounce' export function ModelDirectory() { - const [query, setQuery] = useState('') - const [activeProviderId, setActiveProviderId] = useState(null) + const [{ search: urlQuery, provider: urlProvider }, setModelFilters] = useQueryStates( + modelDirectoryParsers, + modelDirectoryUrlKeys + ) + + const [query, setQuery] = useState(urlQuery) + const debouncedQuery = useDebounce(query, 300) + + useEffect(() => { + setModelFilters({ search: debouncedQuery.length > 0 ? debouncedQuery : null }) + }, [debouncedQuery, setModelFilters]) + + const lastSyncedUrlSearchRef = useRef(urlQuery) + useEffect(() => { + if (urlQuery === lastSyncedUrlSearchRef.current) return + lastSyncedUrlSearchRef.current = urlQuery + setQuery((current) => (current === urlQuery ? current : urlQuery)) + }, [urlQuery]) + + const activeProviderId = urlProvider.length > 0 ? urlProvider : null + const setActiveProviderId = (providerId: string | null) => { + setModelFilters({ provider: providerId }) + } const providerOptions = useMemo( () => diff --git a/apps/sim/app/(landing)/models/search-params.ts b/apps/sim/app/(landing)/models/search-params.ts new file mode 100644 index 00000000000..c51883689e6 --- /dev/null +++ b/apps/sim/app/(landing)/models/search-params.ts @@ -0,0 +1,23 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for the public model directory. + * Both the client (`ModelDirectory`) and any server component that reads these + * params consume this single source of truth. + * + * - `search` is the directory search term, written debounced from the local + * input (logs pattern) — never on every keystroke. + * - `provider` is the active provider filter. Provider ids are derived from the + * data set, so a plain string is used; the empty default (no filter) clears + * from the URL. + */ +export const modelDirectoryParsers = { + search: parseAsString.withDefault(''), + provider: parseAsString.withDefault(''), +} as const + +/** Filter/search view-state: clean URLs, no back-stack churn. */ +export const modelDirectoryUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 6efef184d3d..b0fec405bed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -173,10 +173,8 @@ export function Files() { const params = useParams() const router = useRouter() - const [{ folderId: currentFolderId, new: isNewFile }, setFilesParams] = useQueryStates( - filesParsers, - filesUrlKeys - ) + const [{ folderId: currentFolderId, new: isNewFile, shareFileId }, setFilesParams] = + useQueryStates(filesParsers, filesUrlKeys) const workspaceId = params?.workspaceId as string const posthog = usePostHog() @@ -274,7 +272,6 @@ export function Files() { folderIds: string[] name: string } | null>(null) - const [shareFileId, setShareFileId] = useState(null) const listRename = useInlineRename({ onSave: (rowId, name) => { @@ -306,7 +303,7 @@ export function Files() { const shareModal = shareFile ? ( !open && setShareFileId(null)} + onOpenChange={(open) => !open && setFilesParams({ shareFileId: null })} workspaceId={workspaceId} fileId={shareFile.id} fileName={shareFile.name} @@ -998,8 +995,8 @@ export function Files() { const handleShareSelected = useCallback(() => { const file = selectedFileRef.current - if (file) setShareFileId(file.id) - }, []) + if (file) setFilesParams({ shareFileId: file.id }) + }, [setFilesParams]) const handleBulkDelete = useCallback(() => { if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return @@ -1247,9 +1244,9 @@ export function Files() { const handleContextMenuShare = useCallback(() => { const item = contextMenuItemRef.current - if (item?.kind === 'file') setShareFileId(item.file.id) + if (item?.kind === 'file') setFilesParams({ shareFileId: item.file.id }) closeContextMenu() - }, [closeContextMenu]) + }, [closeContextMenu, setFilesParams]) const handleContextMenuDelete = useCallback(() => { const item = contextMenuItemRef.current diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts index 6cdd47f8bf9..11b5cc95a4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -23,10 +23,14 @@ const parseAsNewFlag = createParser({ * navigations between folders belong in the browser history (`history: 'push'`). * - `new` marks a freshly-created file so the editor opens in compose mode; it is * read once on mount and stripped as the route stabilizes. + * - `shareFileId` deep-links a file's share dialog open. The modal opens when the + * id resolves to a loaded file; closing it clears the param. Opening it is a + * destination, so (like folder navigation) it lands in the browser history. */ export const filesParsers = { folderId: parseAsString, new: parseAsNewFlag.withDefault(false), + shareFileId: parseAsString, } as const /** diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx index 6c077e94dd6..5b2339886de 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -1,8 +1,9 @@ 'use client' -import { type ComponentType, useMemo, useState } from 'react' +import { type ComponentType, useEffect, useMemo, useRef, useState } from 'react' import Link from 'next/link' import { useParams } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { ArrowRight, ChevronDown, @@ -25,11 +26,16 @@ import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/c import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore' +import { + ALL_CATEGORY, + CONNECTED_LABEL, + FEATURED_LABEL, + integrationsParsers, + integrationsUrlKeys, +} from '@/app/workspace/[workspaceId]/integrations/search-params' import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' +import { useDebounce } from '@/hooks/use-debounce' -const ALL_CATEGORY = 'All' -const FEATURED_LABEL = 'Featured' -const CONNECTED_LABEL = 'Connected' /** Slugs surfaced in the pinned Featured section, in display order. */ const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const @@ -130,8 +136,11 @@ export function Integrations() { const params = useParams() const workspaceId = (params?.workspaceId as string) || '' - const [searchTerm, setSearchTerm] = useState('') - const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY) + const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] = + useQueryStates(integrationsParsers, integrationsUrlKeys) + + const [searchTerm, setSearchTerm] = useState(urlSearchTerm) + const debouncedSearchTerm = useDebounce(searchTerm, 300) const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({ workspaceId, @@ -164,6 +173,21 @@ export function Integrations() { }) }, [oauthCredentials]) + useEffect(() => { + setIntegrationFilters({ search: debouncedSearchTerm.length > 0 ? debouncedSearchTerm : null }) + }, [debouncedSearchTerm, setIntegrationFilters]) + + const lastSyncedUrlSearchRef = useRef(urlSearchTerm) + useEffect(() => { + if (urlSearchTerm === lastSyncedUrlSearchRef.current) return + lastSyncedUrlSearchRef.current = urlSearchTerm + setSearchTerm((current) => (current === urlSearchTerm ? current : urlSearchTerm)) + }, [urlSearchTerm]) + + const setSelectedCategory = (category: string) => { + setIntegrationFilters({ category }) + } + const categoryOptions = [ ALL_CATEGORY, ...(connectedItems.length > 0 ? [CONNECTED_LABEL] : []), diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx index 81d4fe3a380..55b28b5eeab 100644 --- a/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/integrations/page.tsx @@ -1,8 +1,34 @@ +import { Suspense } from 'react' import type { Metadata } from 'next' +import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/integrations/components/integration-tabs-header' import { Integrations } from '@/app/workspace/[workspaceId]/integrations/integrations' export const metadata: Metadata = { title: 'Integrations', } -export default Integrations +/** + * Integrations page entry. `Integrations` reads URL query params via nuqs (which + * uses `useSearchParams` internally), so it must sit under a Suspense boundary. + * The fallback renders the real page chrome (background + tab header) so a + * suspend never shows a blank frame. + */ +export default async function IntegrationsPage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + + return ( + + +
+ } + > + +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts new file mode 100644 index 00000000000..4604944f7e5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts @@ -0,0 +1,29 @@ +import { parseAsString } from 'nuqs/server' + +/** Default category — the unfiltered "All" view. */ +export const ALL_CATEGORY = 'All' +/** Pinned, curated home-row section. */ +export const FEATURED_LABEL = 'Featured' +/** Connected-credentials section (only shown when the user has connections). */ +export const CONNECTED_LABEL = 'Connected' + +/** + * Co-located, typed URL query-param definitions for the Integrations gallery. + * + * - `category` selects the active integration category tab. Categories mix the + * `IntegrationType` enum values with the `All`/`Featured`/`Connected` + * pseudo-categories and are derived from the data set, so a plain string is + * used; the `All` default clears from the URL. + * - `search` is the integration search term. It is written debounced from the + * local input (logs pattern) — never on every keystroke. + */ +export const integrationsParsers = { + category: parseAsString.withDefault(ALL_CATEGORY), + search: parseAsString.withDefault(''), +} as const + +/** Filter/search view-state: clean URLs, no back-stack churn. */ +export const integrationsUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 4f3ba866166..fbd05a93e97 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -58,7 +58,11 @@ import { DocumentContextMenu, RenameDocumentModal, } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' -import { addConnectorParam } from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' +import { + addConnectorParam, + pageParam, + pageUrlKeys, +} from '@/app/workspace/[workspaceId]/knowledge/[id]/search-params' import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' @@ -280,7 +284,10 @@ export function KnowledgeBase({ const [documentToDelete, setDocumentToDelete] = useState(null) const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false) const [showConnectorsModal, setShowConnectorsModal] = useState(false) - const [currentPage, setCurrentPage] = useState(1) + const [currentPage, setCurrentPage] = useQueryState(pageParam.key, { + ...pageParam.parser, + ...pageUrlKeys, + }) const [activeSort, setActiveSort] = useState<{ column: string direction: 'asc' | 'desc' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts index d51f8ae0d46..e5d6982a7a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/search-params.ts @@ -1,10 +1,10 @@ -import { parseAsString } from 'nuqs/server' +import { parseAsInteger, parseAsString } from 'nuqs/server' import { ADD_CONNECTOR_SEARCH_PARAM } from '@/lib/credentials/client-state' /** - * Co-located, typed URL query-param definition for the knowledge base detail + * Co-located, typed URL query-param definitions for the knowledge base detail * page. Both the client (`KnowledgeBase`) and any server component that reads - * this param consume this single source of truth. + * these params consume this single source of truth. * * `addConnector` is a deep-link that pre-opens the "add connector" modal. Its * presence (even as an empty string) opens the modal; its value seeds the @@ -14,3 +14,19 @@ export const addConnectorParam = { key: ADD_CONNECTOR_SEARCH_PARAM, parser: parseAsString, } as const + +/** + * `page` is the 1-based document-list pagination index for this knowledge base. + * Distinct from the single-document subview's `page` (a different route). The + * default page (1) clears from the URL. + */ +export const pageParam = { + key: 'page', + parser: parseAsInteger.withDefault(1), +} as const + +/** Pagination view-state: clean URLs, no back-stack churn. */ +export const pageUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 036fe7b2c3a..b194b2bf359 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -2,11 +2,9 @@ import { useEffect } from 'react' import dynamic from 'next/dynamic' -import { useQueryState } from 'nuqs' import { usePostHog } from 'posthog-js/react' import { useSession } from '@/lib/auth/auth-client' import { captureEvent } from '@/lib/posthog/client' -import { mcpServerIdParam } from '@/app/workspace/[workspaceId]/settings/[section]/search-params' import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' import { @@ -103,7 +101,6 @@ interface SettingsPageProps { } export function SettingsPage({ section }: SettingsPageProps) { - const [mcpServerId] = useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser) const { data: session, isPending: sessionLoading } = useSession() const posthog = usePostHog() @@ -144,7 +141,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {effectiveSection === 'whitelabeling' && } {effectiveSection === 'byok' && } {effectiveSection === 'copilot' && } - {effectiveSection === 'mcp' && } + {effectiveSection === 'mcp' && } {effectiveSection === 'custom-tools' && } {effectiveSection === 'workflow-mcp-servers' && } {effectiveSection === 'inbox' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx index d7e74848d25..62393f2d479 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/admin/admin.tsx @@ -1,12 +1,17 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { getErrorMessage } from '@sim/utils/errors' import { useParams } from 'next/navigation' +import { useQueryStates } from 'nuqs' import { Badge, Button, ChipInput, ChipSelect, Label, Search, Switch } from '@/components/emcn' import type { MothershipEnvironment } from '@/lib/api/contracts' import { useSession } from '@/lib/auth/auth-client' import { cn } from '@/lib/core/utils/cn' +import { + adminParsers, + adminUrlKeys, +} from '@/app/workspace/[workspaceId]/settings/components/admin/search-params' import { useAdminUsers, useBanUser, @@ -41,9 +46,13 @@ export function Admin() { const impersonateUser = useImpersonateUser() const [workflowId, setWorkflowId] = useState('') - const [usersOffset, setUsersOffset] = useState(0) - const [searchInput, setSearchInput] = useState('') - const [searchQuery, setSearchQuery] = useState('') + + const [{ q: searchQuery, offset: usersOffset }, setAdminParams] = useQueryStates( + adminParsers, + adminUrlKeys + ) + + const [searchInput, setSearchInput] = useState(searchQuery) const [banUserId, setBanUserId] = useState(null) const [banReason, setBanReason] = useState('') const [impersonatingUserId, setImpersonatingUserId] = useState(null) @@ -56,10 +65,17 @@ export function Admin() { } = useAdminUsers(usersOffset, PAGE_SIZE, searchQuery) const handleSearch = () => { - setUsersOffset(0) - setSearchQuery(searchInput.trim()) + const trimmed = searchInput.trim() + setAdminParams({ q: trimmed.length > 0 ? trimmed : null, offset: null }) } + const lastSyncedSearchRef = useRef(searchQuery) + useEffect(() => { + if (searchQuery === lastSyncedSearchRef.current) return + lastSyncedSearchRef.current = searchQuery + setSearchInput((current) => (current === searchQuery ? current : searchQuery)) + }, [searchQuery]) + const totalPages = useMemo( () => Math.ceil((usersData?.total ?? 0) / PAGE_SIZE), [usersData?.total] @@ -410,7 +426,11 @@ export function Admin() {