Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/_shell/providers/get-query-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ function makeQueryClient() {
retryOnMount: false,
},
mutations: {
retry: 1,
retry: false,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
Expand Down
14 changes: 9 additions & 5 deletions apps/sim/app/api/knowledge/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,15 @@ vi.mock('@sim/db/schema', () => ({

vi.mock('@/lib/audit/log', () => auditMock)

vi.mock('@/lib/knowledge/service', () => ({
getKnowledgeBaseById: vi.fn(),
updateKnowledgeBase: vi.fn(),
deleteKnowledgeBase: vi.fn(),
}))
vi.mock('@/lib/knowledge/service', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/knowledge/service')>()
return {
...actual,
getKnowledgeBaseById: vi.fn(),
updateKnowledgeBase: vi.fn(),
deleteKnowledgeBase: vi.fn(),
}
})

vi.mock('@/app/api/knowledge/utils', () => ({
checkKnowledgeBaseAccess: vi.fn(),
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/knowledge/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
deleteKnowledgeBase,
getKnowledgeBaseById,
KnowledgeBaseConflictError,
updateKnowledgeBase,
} from '@/lib/knowledge/service'
import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
Expand Down Expand Up @@ -166,6 +167,10 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
throw validationError
}
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error updating knowledge base`, error)
return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 })
}
Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/knowledge/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const { mockGetSession, mockDbChain } = vi.hoisted(() => {
where: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
insert: vi.fn().mockReturnThis(),
values: vi.fn().mockResolvedValue(undefined),
}
Expand Down Expand Up @@ -113,7 +114,7 @@ describe('Knowledge Base API Route', () => {
Object.values(mockDbChain).forEach((fn) => {
if (typeof fn === 'function') {
fn.mockClear()
if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values) {
if (fn !== mockDbChain.orderBy && fn !== mockDbChain.values && fn !== mockDbChain.limit) {
fn.mockReturnThis()
}
}
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/app/api/knowledge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { generateRequestId } from '@/lib/core/utils/request'
import {
createKnowledgeBase,
getKnowledgeBases,
KnowledgeBaseConflictError,
type KnowledgeBaseScope,
} from '@/lib/knowledge/service'

Expand Down Expand Up @@ -149,6 +150,10 @@ export async function POST(req: NextRequest) {
throw validationError
}
} catch (error) {
if (error instanceof KnowledgeBaseConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error creating knowledge base`, error)
return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 })
}
Expand Down
13 changes: 12 additions & 1 deletion apps/sim/app/api/table/[tableId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { deleteTable, NAME_PATTERN, renameTable, TABLE_LIMITS, type TableSchema } from '@/lib/table'
import {
deleteTable,
NAME_PATTERN,
renameTable,
TABLE_LIMITS,
TableConflictError,
type TableSchema,
} from '@/lib/table'
import { accessError, checkAccess, normalizeColumn } from '@/app/api/table/utils'

const logger = createLogger('TableDetailAPI')
Expand Down Expand Up @@ -136,6 +143,10 @@ export async function PATCH(request: NextRequest, { params }: TableRouteParams)
)
}

if (error instanceof TableConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 })
}

logger.error(`[${requestId}] Error renaming table:`, error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to rename table' },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { X } from 'lucide-react'
import { Button, Tooltip } from '@/components/emcn'
import { Button, CountdownRing, Tooltip } from '@/components/emcn'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils'
import { usePreventZoom } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
Expand All @@ -20,9 +20,6 @@ const STACK_OFFSET_PX = 3
const AUTO_DISMISS_MS = 10000
const EXIT_ANIMATION_MS = 200

const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS

const ACTION_LABELS: Record<NotificationAction['type'], string> = {
copilot: 'Fix in Copilot',
refresh: 'Refresh',
Expand All @@ -33,38 +30,17 @@ function isAutoDismissable(n: Notification): boolean {
return n.level === 'error' && !!n.workflowId
}

function CountdownRing({ onPause }: { onPause: () => void }) {
function NotificationCountdownRing({ onPause }: { onPause: () => void }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPause}
aria-label='Keep notifications visible'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] hover:bg-[var(--surface-active)]'
className='!p-[4px] -m-[2px] shrink-0 rounded-[5px] text-[var(--text-icon)] hover:bg-[var(--surface-active)]'
>
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='var(--text-icon)'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${AUTO_DISMISS_MS}ms linear forwards`,
}}
/>
</svg>
<CountdownRing duration={AUTO_DISMISS_MS} />
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
Expand Down Expand Up @@ -266,7 +242,7 @@ export const Notifications = memo(function Notifications({ embedded }: Notificat
{notification.message}
</div>
<div className='flex shrink-0 items-start gap-[2px]'>
{showCountdown && <CountdownRing onPause={pauseAll} />}
{showCountdown && <NotificationCountdownRing onPause={pauseAll} />}
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
Expand Down
1 change: 1 addition & 0 deletions apps/sim/components/emcn/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,5 +144,6 @@ export {
} from './tag-input/tag-input'
export { Textarea } from './textarea/textarea'
export { TimePicker, type TimePickerProps, timePickerVariants } from './time-picker/time-picker'
export { CountdownRing } from './toast/countdown-ring'
export { ToastProvider, toast, useToast } from './toast/toast'
export { Tooltip } from './tooltip/tooltip'
37 changes: 37 additions & 0 deletions apps/sim/components/emcn/components/toast/countdown-ring.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const RING_RADIUS = 5.5
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS

interface CountdownRingProps {
duration: number
paused?: boolean
className?: string
}

export function CountdownRing({ duration, paused = false, className }: CountdownRingProps) {
return (
<svg
width='14'
height='14'
viewBox='0 0 16 16'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={className}
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
>
<circle cx='8' cy='8' r={RING_RADIUS} stroke='currentColor' strokeWidth='1.5' opacity={0.2} />
<circle
cx='8'
cy='8'
r={RING_RADIUS}
stroke='currentColor'
strokeWidth='1.5'
strokeLinecap='round'
strokeDasharray={RING_CIRCUMFERENCE}
style={{
animation: `notification-countdown ${duration}ms linear forwards`,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared CountdownRing depends on external CSS keyframe

Low Severity

The CountdownRing component is now exported from the shared emcn component library, but it references the CSS animation name notification-countdown which is only defined in apps/sim/app/_styles/globals.css. This creates a hidden coupling — any consumer of CountdownRing outside the app context (or if the keyframe is renamed) would get a static, non-animating ring with no indication of failure. A self-contained component would define its own keyframe or accept it as a prop.

Additional Locations (1)
Fix in Cursor Fix in Web

animationPlayState: paused ? 'paused' : 'running',
}}
/>
</svg>
)
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.
41 changes: 34 additions & 7 deletions apps/sim/components/emcn/components/toast/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { X } from 'lucide-react'
import { createPortal } from 'react-dom'
import { cn } from '@/lib/core/utils/cn'
import { CountdownRing } from './countdown-ring'

const AUTO_DISMISS_MS = 0
const EXIT_ANIMATION_MS = 200
Expand Down Expand Up @@ -100,7 +101,10 @@ const VARIANT_STYLES: Record<ToastVariant, string> = {

function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
const [exiting, setExiting] = useState(false)
const [paused, setPaused] = useState(false)
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const remainingRef = useRef(t.duration)
const startRef = useRef(0)

const dismiss = useCallback(() => {
setExiting(true)
Expand All @@ -109,13 +113,33 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:

useEffect(() => {
if (t.duration > 0) {
startRef.current = Date.now()
remainingRef.current = t.duration
timerRef.current = setTimeout(dismiss, t.duration)
return () => clearTimeout(timerRef.current)
}
}, [dismiss, t.duration])

const handleMouseEnter = useCallback(() => {
if (t.duration <= 0) return
clearTimeout(timerRef.current)
remainingRef.current -= Date.now() - startRef.current
setPaused(true)
}, [t.duration])

const handleMouseLeave = useCallback(() => {
if (t.duration <= 0) return
setPaused(false)
startRef.current = Date.now()
timerRef.current = setTimeout(dismiss, Math.max(remainingRef.current, 0))
}, [dismiss, t.duration])
Comment thread
TheodoreSpeaks marked this conversation as resolved.

const hasDuration = t.duration > 0

return (
<div
onMouseEnter={hasDuration ? handleMouseEnter : undefined}
onMouseLeave={hasDuration ? handleMouseLeave : undefined}
className={cn(
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
VARIANT_STYLES[t.variant],
Expand All @@ -142,13 +166,16 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:
{t.action.label}
</button>
)}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
<div className='flex shrink-0 items-center gap-[4px]'>
Comment thread
TheodoreSpeaks marked this conversation as resolved.
Outdated
{hasDuration && <CountdownRing duration={t.duration} paused={paused} />}
<button
type='button'
onClick={dismiss}
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
>
<X className='h-[14px] w-[14px]' />
</button>
</div>
</div>
)
}
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/hooks/queries/kb/knowledge.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import type {
ChunkData,
ChunksPagination,
Expand Down Expand Up @@ -773,6 +774,9 @@ export function useUpdateKnowledgeBase(workspaceId?: string) {

return useMutation({
mutationFn: updateKnowledgeBase,
onError: (error) => {
toast.error(error.message, { duration: 5000 })
},
onSuccess: (_, { knowledgeBaseId }) => {
queryClient.invalidateQueries({
queryKey: knowledgeKeys.detail(knowledgeBaseId),
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/hooks/queries/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import type { Filter, RowData, Sort, TableDefinition, TableMetadata, TableRow } from '@/lib/table'

const logger = createLogger('TableQueries')
Expand Down Expand Up @@ -308,6 +309,9 @@ export function useRenameTable(workspaceId: string) {

return res.json()
},
onError: (error) => {
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: tableKeys.detail(variables.tableId) })
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
Expand Down
4 changes: 4 additions & 0 deletions apps/sim/hooks/queries/workspace-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast } from '@/components/emcn'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'

const logger = createLogger('WorkspaceFilesQuery')
Expand Down Expand Up @@ -245,6 +246,9 @@ export function useRenameWorkspaceFile() {

return data
},
onError: (error) => {
toast.error(error.message, { duration: 5000 })
},
onSettled: (_data, _error, variables) => {
queryClient.invalidateQueries({ queryKey: workspaceFilesKeys.lists() })
},
Expand Down
23 changes: 23 additions & 0 deletions apps/sim/lib/core/utils/pg-error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { getPostgresErrorCode } from '@/lib/core/utils/pg-error'

describe('getPostgresErrorCode', () => {
it('reads code from Error.code', () => {
const err = new Error('fail') as Error & { code: string }
err.code = '23505'
expect(getPostgresErrorCode(err)).toBe('23505')
})

it('reads code from Error.cause', () => {
const err = new Error('fail', { cause: { code: '23505' } })
expect(getPostgresErrorCode(err)).toBe('23505')
})

it('returns undefined for non-errors', () => {
expect(getPostgresErrorCode(undefined)).toBeUndefined()
expect(getPostgresErrorCode('23505')).toBeUndefined()
})
})
Loading
Loading