mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(url-state): use nuqs setters and derive state instead of mirroring it (#6486)
* improvement(url-state): use nuqs setters and derive state instead of mirroring it
Wave 1 of a URL-state audit sweep.
- files: replace the last hand-built same-path query mutation with the nuqs
group setter, which no longer drops shareFileId/search/type/size/uploaded-by/sort/dir
- suspense: give six page entries their co-located loading.tsx skeleton
instead of fallback={null}
- invite: derive isNewUser/urlError/token during render so the invitation
query key is correct on first commit
- resume: derive selectedStatus/queuePosition from the query cache the
mutation already writes
- verify, logs, terminal: delete dead and duplicate state
- rules: document same-path router.replace as a query mutation, and the
loading.tsx-as-Suspense-fallback convention
* fix(invite): wait for the stored token before enabling the invitation query
An authenticated user opening an invite without a token in the URL fired the
query with a null token before the effect restored the session-stored one,
producing a transient forbidden state and a redundant request under a second
cache key. Distinguish 'storage not yet read' (undefined) from 'read and empty'
(null) and gate the query on that.
This commit is contained in:
@@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo
|
||||
## 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.
|
||||
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state. **If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip.
|
||||
- `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).
|
||||
@@ -44,7 +44,7 @@ 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`.
|
||||
- **Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal.
|
||||
|
||||
## Per-feature `search-params.ts` — single source of truth
|
||||
|
||||
@@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals
|
||||
|
||||
## Suspense boundary
|
||||
|
||||
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` 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`.
|
||||
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame.
|
||||
|
||||
**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`):
|
||||
|
||||
```typescript
|
||||
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
|
||||
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'
|
||||
|
||||
<Suspense fallback={<KnowledgeBaseLoading />}>
|
||||
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
|
||||
</Suspense>
|
||||
```
|
||||
|
||||
Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`.
|
||||
|
||||
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
|
||||
|
||||
## Debounced text inputs
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginLoading from '@/app/(auth)/login/loading'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -15,7 +16,7 @@ export default async function LoginPage() {
|
||||
await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<LoginLoading />}>
|
||||
<LoginForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { isRegistrationDisabled, isSsoEnabled } from '@/lib/core/config/env-flags'
|
||||
import SSOLoading from '@/app/(auth)/sso/loading'
|
||||
import SSOForm from '@/ee/sso/components/sso-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,7 +17,7 @@ export default async function SSOPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<SSOLoading />}>
|
||||
<SSOForm registrationDisabled={isRegistrationDisabled} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -81,7 +81,6 @@ export function useVerification({
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<VerificationStatus>('idle')
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,12 +88,6 @@ export function useVerification({
|
||||
if (storedEmail) setEmail(storedEmail)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (email && !isSendingInitialOtp && hasEmailService) {
|
||||
setIsSendingInitialOtp(true)
|
||||
}
|
||||
}, [email, isSendingInitialOtp, hasEmailService])
|
||||
|
||||
const isOtpComplete = otp.length === 6
|
||||
|
||||
async function verifyCode() {
|
||||
|
||||
@@ -46,21 +46,15 @@ function VerificationForm({
|
||||
const isInvalidOtp = status === 'error'
|
||||
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [isResendDisabled, setIsResendDisabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (countdown === 0 && isResendDisabled) {
|
||||
setIsResendDisabled(false)
|
||||
}
|
||||
}, [countdown, isResendDisabled])
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleResend = () => {
|
||||
resendCode()
|
||||
setIsResendDisabled(true)
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
@@ -128,7 +122,7 @@ function VerificationForm({
|
||||
Resend in <span className='text-[var(--text-primary)]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<AuthTextLink onClick={handleResend} disabled={isLoading || isResendDisabled}>
|
||||
<AuthTextLink onClick={handleResend} disabled={isLoading}>
|
||||
Resend
|
||||
</AuthTextLink>
|
||||
)}
|
||||
|
||||
@@ -185,9 +185,9 @@ export default function ResumeExecutionPage({
|
||||
executionId,
|
||||
selectedContextId ?? undefined
|
||||
)
|
||||
const [selectedStatus, setSelectedStatus] =
|
||||
useState<PausePointWithQueue['resumeStatus']>('paused')
|
||||
const [queuePosition, setQueuePosition] = useState<number | null | undefined>(undefined)
|
||||
const selectedStatus: PausePointWithQueue['resumeStatus'] =
|
||||
selectedDetail?.pausePoint.resumeStatus ?? 'paused'
|
||||
const queuePosition = selectedDetail?.pausePoint.queuePosition
|
||||
const resumeInputsRef = useRef<Record<string, string>>({})
|
||||
const [resumeInput, setResumeInput] = useState('')
|
||||
const [formValuesByContext, setFormValuesByContext] = useState<
|
||||
@@ -440,10 +440,7 @@ export default function ResumeExecutionPage({
|
||||
[]
|
||||
)
|
||||
|
||||
const selectedOperation = useMemo(
|
||||
() => selectedDetail?.pausePoint.response?.data?.operation || 'human',
|
||||
[selectedDetail]
|
||||
)
|
||||
const selectedOperation = selectedDetail?.pausePoint.response?.data?.operation || 'human'
|
||||
const isHumanMode = selectedOperation === 'human'
|
||||
|
||||
const inputFormatFields = useMemo(
|
||||
@@ -524,8 +521,6 @@ export default function ResumeExecutionPage({
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDetail) return
|
||||
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
|
||||
setQueuePosition(selectedDetail.pausePoint.queuePosition)
|
||||
seedFormFromDetail(selectedDetail)
|
||||
}, [selectedDetail, seedFormFromDetail])
|
||||
|
||||
@@ -604,7 +599,6 @@ export default function ResumeExecutionPage({
|
||||
})
|
||||
if (!ok) {
|
||||
setError(payload.error || 'Failed to resume execution.')
|
||||
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
|
||||
return
|
||||
}
|
||||
const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming'
|
||||
@@ -641,8 +635,6 @@ export default function ResumeExecutionPage({
|
||||
}
|
||||
}
|
||||
)
|
||||
setSelectedStatus(nextStatus)
|
||||
setQueuePosition(nextQueuePosition)
|
||||
setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId))
|
||||
setMessage(
|
||||
payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.'
|
||||
|
||||
@@ -278,35 +278,33 @@ export default function Invite({ registrationDisabled }: InviteProps) {
|
||||
const { data: session, isPending } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const [actionError, setActionError] = useState<InviteError | null>(null)
|
||||
const [urlError, setUrlError] = useState<InviteError | null>(null)
|
||||
const [isAccepting, setIsAccepting] = useState(false)
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [isNewUser, setIsNewUser] = useState(false)
|
||||
const [token, setToken] = useState<string | null>(null)
|
||||
/** `undefined` until the effect reads storage; `null` once read and empty. */
|
||||
const [storedToken, setStoredToken] = useState<string | null | undefined>(undefined)
|
||||
|
||||
const isNewUser = searchParams.get('new') === 'true'
|
||||
const errorReason = searchParams.get('error')
|
||||
const urlError = errorReason ? getInviteError(errorReason) : null
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
/**
|
||||
* Derived during render so the invitation query key is correct on the first
|
||||
* commit; an effect-set token refetches under a second key whenever the
|
||||
* session cache is already warm at mount.
|
||||
*/
|
||||
const token = tokenFromQuery ?? storedToken ?? null
|
||||
const isTokenResolved = tokenFromQuery !== null || storedToken !== undefined
|
||||
|
||||
useEffect(() => {
|
||||
const errorReason = searchParams.get('error')
|
||||
const isNew = searchParams.get('new') === 'true'
|
||||
setIsNewUser(isNew)
|
||||
|
||||
const tokenFromQuery = searchParams.get('token')
|
||||
if (tokenFromQuery) {
|
||||
setToken(tokenFromQuery)
|
||||
sessionStorage.setItem(inviteTokenStorageKey, tokenFromQuery)
|
||||
} else {
|
||||
const storedToken = sessionStorage.getItem(inviteTokenStorageKey)
|
||||
if (storedToken) {
|
||||
setToken(storedToken)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (errorReason) {
|
||||
setUrlError(getInviteError(errorReason))
|
||||
}
|
||||
}, [searchParams, inviteId, inviteTokenStorageKey])
|
||||
setStoredToken(sessionStorage.getItem(inviteTokenStorageKey))
|
||||
}, [tokenFromQuery, inviteTokenStorageKey])
|
||||
|
||||
const invitationQuery = useInvitationDetails(inviteId, token, session?.user?.id ?? null, {
|
||||
enabled: Boolean(session?.user),
|
||||
enabled: Boolean(session?.user) && isTokenResolved,
|
||||
})
|
||||
const invitation = invitationQuery.data?.invitation ?? null
|
||||
const joinPreview = invitationQuery.data?.joinPreview ?? null
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { isRegistrationDisabled } from '@/lib/core/config/env-flags'
|
||||
import Invite from '@/app/invite/[id]/invite'
|
||||
import InviteLoading from '@/app/invite/[id]/loading'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Invite',
|
||||
@@ -12,7 +13,7 @@ export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function InvitePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<InviteLoading />}>
|
||||
<Invite registrationDisabled={isRegistrationDisabled} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { Files } from '../files'
|
||||
import { Files } from '@/app/workspace/[workspaceId]/files/files'
|
||||
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Files',
|
||||
@@ -9,7 +10,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function FilesFilePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<FilesLoading />}>
|
||||
<Files />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -1534,13 +1534,9 @@ export function Files() {
|
||||
|
||||
useEffect(() => {
|
||||
if (isNewFile && fileIdFromRoute) {
|
||||
router.replace(
|
||||
currentFolderId
|
||||
? `/workspace/${workspaceId}/files/${fileIdFromRoute}?folderId=${currentFolderId}`
|
||||
: `/workspace/${workspaceId}/files/${fileIdFromRoute}`
|
||||
)
|
||||
void setFilesParams({ new: null }, { history: 'replace', scroll: false })
|
||||
}
|
||||
}, [isNewFile, fileIdFromRoute, router, workspaceId, currentFolderId])
|
||||
}, [isNewFile, fileIdFromRoute, setFilesParams])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -3,9 +3,9 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import type { Metadata } from 'next'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { Files } from '@/app/workspace/[workspaceId]/files/files'
|
||||
import FilesLoading from '@/app/workspace/[workspaceId]/files/loading'
|
||||
import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch'
|
||||
import { Files } from './files'
|
||||
import FilesLoading from './loading'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Files',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { Document } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document'
|
||||
import DocumentLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading'
|
||||
|
||||
interface DocumentPageProps {
|
||||
params: Promise<{
|
||||
@@ -24,7 +25,7 @@ export default async function DocumentChunksPage({ params, searchParams }: Docum
|
||||
const [{ id, documentId }, { kbName, docName }] = await Promise.all([params, searchParams])
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<DocumentLoading />}>
|
||||
<Document
|
||||
knowledgeBaseId={id}
|
||||
documentId={documentId}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { KnowledgeBase } from '@/app/workspace/[workspaceId]/knowledge/[id]/base'
|
||||
import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/loading'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{
|
||||
@@ -20,7 +21,7 @@ export default async function KnowledgeBasePage({ params, searchParams }: PagePr
|
||||
const [{ id }, { kbName }] = await Promise.all([params, searchParams])
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<KnowledgeBaseLoading />}>
|
||||
<KnowledgeBase id={id} knowledgeBaseName={kbName || 'Knowledge Base'} />
|
||||
</Suspense>
|
||||
)
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Suspense } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import type { Metadata } from 'next'
|
||||
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
|
||||
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
|
||||
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
|
||||
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
|
||||
import { Knowledge } from './knowledge'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Knowledge Base',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import type { ParsedFilter } from '@/lib/logs/query-parser'
|
||||
import { parseQuery } from '@/lib/logs/query-parser'
|
||||
import type {
|
||||
Suggestion,
|
||||
SuggestionGroup,
|
||||
@@ -11,21 +10,16 @@ interface UseSearchStateOptions {
|
||||
onFiltersChange: (filters: ParsedFilter[], textSearch: string) => void
|
||||
getSuggestions: (input: string) => SuggestionGroup | null
|
||||
debounceMs?: number
|
||||
initialQuery?: string
|
||||
}
|
||||
|
||||
export function useSearchState({
|
||||
onFiltersChange,
|
||||
getSuggestions,
|
||||
debounceMs = 100,
|
||||
initialQuery,
|
||||
}: UseSearchStateOptions) {
|
||||
const [initialParsed] = useState(() =>
|
||||
initialQuery ? parseQuery(initialQuery) : { filters: [] as ParsedFilter[], textSearch: '' }
|
||||
)
|
||||
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>(initialParsed.filters)
|
||||
const [appliedFilters, setAppliedFilters] = useState<ParsedFilter[]>([])
|
||||
const [currentInput, setCurrentInput] = useState('')
|
||||
const [textSearch, setTextSearch] = useState<string>(initialParsed.textSearch)
|
||||
const [textSearch, setTextSearch] = useState('')
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
export type { SortConfig, SortDirection, SortField, TerminalFilters } from '../types'
|
||||
export type { SortDirection, TerminalFilters } from '../types'
|
||||
export { useOutputPanelResize } from './use-output-panel-resize'
|
||||
export { useTerminalFilters } from './use-terminal-filters'
|
||||
export { useTerminalResize } from './use-terminal-resize'
|
||||
|
||||
+8
-31
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import type {
|
||||
SortConfig,
|
||||
SortDirection,
|
||||
TerminalFilters,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/terminal/types'
|
||||
import type { ConsoleEntry } from '@/stores/terminal'
|
||||
@@ -17,14 +17,8 @@ export function useTerminalFilters() {
|
||||
statuses: new Set(),
|
||||
})
|
||||
|
||||
const [sortConfig, setSortConfig] = useState<SortConfig>({
|
||||
field: 'timestamp',
|
||||
direction: 'desc',
|
||||
})
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
|
||||
/**
|
||||
* Toggles a block filter by block ID
|
||||
*/
|
||||
const toggleBlock = useCallback((blockId: string) => {
|
||||
setFilters((prev) => {
|
||||
const newBlockIds = new Set(prev.blockIds)
|
||||
@@ -37,9 +31,6 @@ export function useTerminalFilters() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles a status filter
|
||||
*/
|
||||
const toggleStatus = useCallback((status: 'error' | 'info') => {
|
||||
setFilters((prev) => {
|
||||
const newStatuses = new Set(prev.statuses)
|
||||
@@ -52,19 +43,10 @@ export function useTerminalFilters() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Toggles sort direction between ascending and descending
|
||||
*/
|
||||
const toggleSort = useCallback(() => {
|
||||
setSortConfig((prev) => ({
|
||||
field: prev.field,
|
||||
direction: prev.direction === 'desc' ? 'asc' : 'desc',
|
||||
}))
|
||||
setSortDirection((prev) => (prev === 'desc' ? 'asc' : 'desc'))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Clears all filters
|
||||
*/
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({
|
||||
blockIds: new Set(),
|
||||
@@ -72,12 +54,7 @@ export function useTerminalFilters() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Checks if any filters are active
|
||||
*/
|
||||
const hasActiveFilters = useMemo(() => {
|
||||
return filters.blockIds.size > 0 || filters.statuses.size > 0
|
||||
}, [filters])
|
||||
const hasActiveFilters = filters.blockIds.size > 0 || filters.statuses.size > 0
|
||||
|
||||
/**
|
||||
* Filters and sorts console entries based on current filter and sort state
|
||||
@@ -108,17 +85,17 @@ export function useTerminalFilters() {
|
||||
// Sort by executionOrder (monotonically increasing integer from server)
|
||||
result = [...result].sort((a, b) => {
|
||||
const comparison = a.executionOrder - b.executionOrder
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison
|
||||
return sortDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
[filters, hasActiveFilters, sortConfig]
|
||||
[filters, hasActiveFilters, sortDirection]
|
||||
)
|
||||
|
||||
return {
|
||||
filters,
|
||||
sortConfig,
|
||||
sortDirection,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
toggleSort,
|
||||
|
||||
+4
-4
@@ -715,7 +715,7 @@ export const Terminal = memo(function Terminal() {
|
||||
|
||||
const {
|
||||
filters,
|
||||
sortConfig,
|
||||
sortDirection,
|
||||
toggleBlock,
|
||||
toggleStatus,
|
||||
toggleSort,
|
||||
@@ -1328,10 +1328,10 @@ export const Terminal = memo(function Terminal() {
|
||||
aria-label='Sort by timestamp'
|
||||
className='!p-1.5 -m-1.5'
|
||||
>
|
||||
{sortConfig.direction === 'desc' ? (
|
||||
<ArrowDown className='h-3.5 w-3.5' />
|
||||
{sortDirection === 'desc' ? (
|
||||
<ArrowDown className='size-3.5' />
|
||||
) : (
|
||||
<ArrowUp className='h-3.5 w-3.5' />
|
||||
<ArrowUp className='size-3.5' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
|
||||
@@ -14,24 +14,11 @@ export interface ContextMenuPosition {
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort field options for terminal entries
|
||||
*/
|
||||
export type SortField = 'timestamp'
|
||||
|
||||
/**
|
||||
* Sort direction options
|
||||
*/
|
||||
export type SortDirection = 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Sort configuration for terminal entries
|
||||
*/
|
||||
export interface SortConfig {
|
||||
field: SortField
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
/**
|
||||
* Status type for console entries
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user