chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
@@ -0,0 +1,233 @@
'use client'
import { useState } from 'react'
import { Button, ChipCombobox, ChipInput, cn, FieldDivider, Label, Switch, toast } from '@sim/emcn'
import { X } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
import type { ColumnDefinition } from '@/lib/table'
import {
FieldError,
RequiredLabel,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables'
import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types'
/**
* Discriminates the two flows the column-config sidebar handles. Workflow
* configuration is a separate component (`<WorkflowSidebar>`) so this surface
* never has to branch on `isWorkflow`.
*/
export type ColumnConfig =
| { mode: 'create'; proposedName: string; type: ColumnDefinition['type'] }
| { mode: 'edit'; columnName: string }
interface ColumnConfigSidebarProps {
/** When non-null the sidebar is open. */
config: ColumnConfig | null
onClose: () => void
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
existingColumn: ColumnDefinition | null
workspaceId: string
tableId: string
/** Notify parent of a rename so it can rewrite local `columnOrder` /
* `columnWidths` keys that reference the old name. */
onColumnRename?: (oldName: string, newName: string) => void
}
/**
* Right-edge sidebar for plain (non-workflow) column configuration. Handles
* create (with type pre-chosen by the parent's "+ New column" dropdown) and
* edit. No `isWorkflow` branches — workflow-output columns route through
* `<WorkflowSidebar>` instead.
*
* Form state seeds from props via lazy `useState` initializers; the parent
* uses `key={config?.columnName ?? 'closed'}` to remount when switching
* columns, eliminating the prop-mirroring `useEffect` the previous combined
* sidebar relied on.
*/
export function ColumnConfigSidebar(props: ColumnConfigSidebarProps) {
// Mount the form body with `key` keyed on the config identity so opening a
// different column / mode remounts and re-seeds state from props.
const open = props.config !== null
return (
<aside
role='dialog'
aria-label='Configure column'
className={cn(
'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] transition-transform duration-200 ease-out',
open ? 'translate-x-0 shadow-overlay' : 'translate-x-full'
)}
>
{props.config && (
<ColumnConfigBody key={configKey(props.config)} {...props} config={props.config} />
)}
</aside>
)
}
function configKey(config: ColumnConfig): string {
return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}`
}
interface ColumnConfigBodyProps extends Omit<ColumnConfigSidebarProps, 'config'> {
config: ColumnConfig
}
function ColumnConfigBody({
config,
onClose,
existingColumn,
workspaceId,
tableId,
onColumnRename,
}: ColumnConfigBodyProps) {
const updateColumn = useUpdateColumn({ workspaceId, tableId })
const addColumn = useAddTableColumn({ workspaceId, tableId })
const [nameInput, setNameInput] = useState<string>(() =>
config.mode === 'edit' ? (existingColumn?.name ?? config.columnName) : config.proposedName
)
const [typeInput, setTypeInput] = useState<ColumnDefinition['type']>(() =>
config.mode === 'edit' ? (existingColumn?.type ?? 'string') : config.type
)
const [uniqueInput, setUniqueInput] = useState<boolean>(() =>
config.mode === 'edit' ? !!existingColumn?.unique : false
)
const [showValidation, setShowValidation] = useState(false)
const [nameError, setNameError] = useState<string | null>(null)
const saveDisabled = updateColumn.isPending || addColumn.isPending
const trimmedName = nameInput.trim()
async function handleSave() {
if (!trimmedName) {
setShowValidation(true)
return
}
try {
if (config.mode === 'create') {
await addColumn.mutateAsync({
name: trimmedName,
type: typeInput,
...(uniqueInput ? { unique: true } : {}),
})
toast.success(`Added "${trimmedName}"`)
onClose()
return
}
// `config.columnName` is the column id; compare against the current display
// name to detect an actual rename.
const renamed = trimmedName !== (existingColumn?.name ?? config.columnName)
const typeChanged = !!existingColumn && existingColumn.type !== typeInput
const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput
const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = {
...(renamed ? { name: trimmedName } : {}),
...(typeChanged ? { type: typeInput } : {}),
...(uniqueChanged ? { unique: uniqueInput } : {}),
}
if (Object.keys(updates).length === 0) {
onClose()
return
}
await updateColumn.mutateAsync({ columnName: config.columnName, updates })
if (renamed) onColumnRename?.(config.columnName, trimmedName)
toast.success(`Saved "${trimmedName}"`)
onClose()
} catch (err) {
if (isValidationError(err)) {
const nameIssue =
findValidationIssue(err, ['updates', 'name']) ??
findValidationIssue(err, ['name']) ??
findValidationIssue(err, ['columnName'])
if (nameIssue) {
setNameError(nameIssue.message)
return
}
toast.error(toError(err).message)
}
}
}
return (
<div className='flex h-full flex-col'>
<div className='flex min-h-[48px] items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
<h2 className='font-medium text-[var(--text-primary)] text-small'>Configure column</h2>
<Button
variant='ghost'
size='sm'
onClick={onClose}
className='!p-1 size-7'
aria-label='Close'
>
<X className='size-[14px]' />
</Button>
</div>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [overflow-anchor:none]'>
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel htmlFor='column-sidebar-name'>Column name</RequiredLabel>
<ChipInput
id='column-sidebar-name'
value={nameInput}
onChange={(e) => {
setNameInput(e.target.value)
if (nameError) setNameError(null)
}}
spellCheck={false}
autoComplete='off'
error={Boolean((showValidation && !trimmedName) || nameError)}
aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined}
/>
{showValidation && !trimmedName && <FieldError message='Column name is required' />}
{nameError && !(showValidation && !trimmedName) && <FieldError message={nameError} />}
</div>
{config.mode === 'edit' && (
<>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Type</RequiredLabel>
<ChipCombobox
options={PLAIN_COLUMN_TYPE_OPTIONS.map((o) => ({
label: o.label,
value: o.type,
icon: o.icon,
}))}
value={typeInput}
onChange={(v) => setTypeInput(v as ColumnDefinition['type'])}
placeholder='Select type'
maxHeight={260}
/>
</div>
</>
)}
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='column-sidebar-unique'>Unique</Label>
<Switch
id='column-sidebar-unique'
checked={uniqueInput}
onCheckedChange={(v) => setUniqueInput(!!v)}
/>
</div>
</div>
</div>
<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
<Button variant='default' size='sm' onClick={onClose}>
Cancel
</Button>
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
{saveDisabled ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,35 @@
import type React from 'react'
import {
Calendar as CalendarIcon,
PlayOutline,
TypeBoolean,
TypeJson,
TypeNumber,
TypeText,
} from '@sim/emcn/icons'
import type { ColumnDefinition } from '@/lib/table'
/**
* UI-only column type. `'workflow'` is the virtual entry users pick from the
* "+ New column" dropdown to spawn a workflow group; the resulting columns are
* stored as scalar types under the hood (none carry `'workflow'`).
*/
export type SidebarColumnType = ColumnDefinition['type'] | 'workflow'
export interface ColumnTypeOption {
type: SidebarColumnType
label: string
icon: React.ComponentType<{ className?: string }>
}
export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [
{ type: 'string', label: 'Text', icon: TypeText },
{ type: 'number', label: 'Number', icon: TypeNumber },
{ type: 'boolean', label: 'Boolean', icon: TypeBoolean },
{ type: 'date', label: 'Date', icon: CalendarIcon },
{ type: 'json', label: 'JSON', icon: TypeJson },
{ type: 'workflow', label: 'Workflow', icon: PlayOutline },
]
/** Plain column types (no workflow). Used by `<ColumnConfigSidebar>`'s type combobox in edit mode. */
export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow')
@@ -0,0 +1,8 @@
export type { ColumnConfig } from './column-config-sidebar'
export { ColumnConfigSidebar } from './column-config-sidebar'
export {
COLUMN_TYPE_OPTIONS,
type ColumnTypeOption,
PLAIN_COLUMN_TYPE_OPTIONS,
type SidebarColumnType,
} from './column-types'
@@ -0,0 +1,173 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import {
ArrowDown,
ArrowUp,
Duplicate,
Eye,
Pencil,
PlayOutline,
RefreshCw,
Square,
Trash,
} from '@sim/emcn/icons'
import type { ContextMenuState } from '../../types'
interface ContextMenuProps {
contextMenu: ContextMenuState
onClose: () => void
onEditCell: () => void
onDelete: () => void
onInsertAbove: () => void
onInsertBelow: () => void
onDuplicate: () => void
onViewExecution?: () => void
canViewExecution?: boolean
canEditCell?: boolean
selectedRowCount?: number
/** Fires every workflow group on the row(s), skipping already-completed
* cells. Mirrors the action bar's Play. */
onRunWorkflows?: () => void
/** Re-runs every workflow group on the row(s), including already-completed
* cells. Mirrors the action bar's Refresh. */
onRefreshWorkflows?: () => void
/** Cancels every running/queued execution on the row(s) the context menu is acting on. */
onStopWorkflows?: () => void
/** Total running/queued executions across the row(s) under the context menu. Drives the Stop label and visibility. */
runningInSelectionCount?: number
/** Whether the table has any workflow columns; gates the run-workflows item. */
hasWorkflowColumns?: boolean
/** True when the menu was opened on a workflow-output cell, so Run / Re-run
* act on that cell's group only (the cascade handles dependents). Switches
* the labels from row-wide ("all cells") to cell-scoped ("cell"). */
workflowCellScoped?: boolean
disableEdit?: boolean
disableInsert?: boolean
disableDelete?: boolean
}
export function ContextMenu({
contextMenu,
onClose,
onEditCell,
onDelete,
onInsertAbove,
onInsertBelow,
onDuplicate,
onViewExecution,
canViewExecution = false,
canEditCell = true,
selectedRowCount = 1,
onRunWorkflows,
onRefreshWorkflows,
onStopWorkflows,
runningInSelectionCount = 0,
hasWorkflowColumns = false,
workflowCellScoped = false,
disableEdit = false,
disableInsert = false,
disableDelete = false,
}: ContextMenuProps) {
const count = selectedRowCount.toLocaleString()
const deleteLabel = selectedRowCount > 1 ? `Delete ${count} rows` : 'Delete row'
const runLabel = workflowCellScoped
? selectedRowCount > 1
? `Run cell on ${count} rows`
: 'Run cell'
: selectedRowCount > 1
? `Run empty or failed cells on ${count} rows`
: 'Run empty or failed cells'
const refreshLabel = workflowCellScoped
? selectedRowCount > 1
? `Re-run cell on ${count} rows`
: 'Re-run cell'
: selectedRowCount > 1
? `Re-run all cells on ${count} rows`
: 'Re-run all cells'
const stopLabel =
runningInSelectionCount === 1
? 'Stop running workflow'
: `Stop ${runningInSelectionCount} running workflows`
return (
<DropdownMenu
open={contextMenu.isOpen}
onOpenChange={(open) => !open && onClose()}
modal={false}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${contextMenu.position.x}px`,
top: `${contextMenu.position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{contextMenu.columnName && canEditCell && (
<DropdownMenuItem disabled={disableEdit} onSelect={onEditCell}>
<Pencil />
Edit cell
</DropdownMenuItem>
)}
{canViewExecution && onViewExecution && (
<DropdownMenuItem onSelect={onViewExecution}>
<Eye />
View execution
</DropdownMenuItem>
)}
{hasWorkflowColumns && onRunWorkflows && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRunWorkflows}>
<PlayOutline />
{runLabel}
</DropdownMenuItem>
)}
{hasWorkflowColumns && onRefreshWorkflows && (
<DropdownMenuItem disabled={disableEdit} onSelect={onRefreshWorkflows}>
<RefreshCw />
{refreshLabel}
</DropdownMenuItem>
)}
{hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && (
<DropdownMenuItem disabled={disableEdit} onSelect={onStopWorkflows}>
<Square className='size-[14px] text-[var(--text-icon)]' />
{stopLabel}
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={disableInsert} onSelect={onInsertAbove}>
<ArrowUp />
Insert row above
</DropdownMenuItem>
<DropdownMenuItem disabled={disableInsert} onSelect={onInsertBelow}>
<ArrowDown />
Insert row below
</DropdownMenuItem>
<DropdownMenuItem disabled={disableInsert || selectedRowCount > 1} onSelect={onDuplicate}>
<Duplicate />
Duplicate row
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
{deleteLabel}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { ContextMenu } from './context-menu'
@@ -0,0 +1,388 @@
'use client'
import { useEffect, useState } from 'react'
import { Badge, Button, ChipModalTabs, cn, X } from '@sim/emcn'
import { formatDuration } from '@sim/utils/formatting'
import type { EnrichmentProviderOutcome, EnrichmentRunDetail } from '@/lib/table'
import {
adjustBgForContrast,
getBlockIconAndColor,
iconColorClass,
} from '@/app/workspace/[workspaceId]/logs/components/log-details/utils'
import { useLogDetailsResize } from '@/app/workspace/[workspaceId]/logs/hooks'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
import { useEnrichmentDetail } from '@/hooks/queries/tables'
import { formatCost } from '@/providers/utils'
import { useLogDetailsUIStore } from '@/stores/logs/store'
import { MAX_LOG_DETAILS_WIDTH_RATIO, MIN_LOG_DETAILS_WIDTH } from '@/stores/logs/utils'
type EnrichmentDetailsTab = 'result' | 'cascade'
type ResultStatus = 'matched' | 'no_match' | 'error' | 'not_run' | 'cancelled'
const RESULT_STATUS_CONFIG: Record<
ResultStatus,
{ variant: React.ComponentProps<typeof Badge>['variant']; label: string }
> = {
matched: { variant: 'green', label: 'Matched' },
no_match: { variant: 'gray', label: 'No match' },
error: { variant: 'red', label: 'Error' },
not_run: { variant: 'gray', label: 'Not run' },
cancelled: { variant: 'orange', label: 'Cancelled' },
}
/** Minimum bar width so a sub-millisecond provider still shows on the timeline. */
const MIN_BAR_PCT = 0.5
const PROVIDER_STATUS_LABEL: Record<EnrichmentProviderOutcome['status'], string> = {
matched: 'Matched',
no_match: 'No match',
skipped: 'Skipped',
error: 'Error',
not_run: 'Not run',
}
interface CascadeRow {
outcome: EnrichmentProviderOutcome
offsetPct: number
widthPct: number
}
/**
* Lays the (sequential) provider attempts on one timeline so the Cascade tab
* reads like the execution trace waterfall: each bar's offset is the time before
* it ran, its width its own duration. Skipped providers (0ms) get no bar.
*/
function buildCascadeRows(providers: EnrichmentProviderOutcome[]): CascadeRow[] {
const total = Math.max(
1,
providers.reduce((sum, p) => sum + p.durationMs, 0)
)
let cursor = 0
return providers.map((outcome) => {
const offsetMs = cursor
cursor += outcome.durationMs
const offsetPct = Math.min(100 - MIN_BAR_PCT, (offsetMs / total) * 100)
const rawWidth = (outcome.durationMs / total) * 100
const widthPct =
outcome.durationMs > 0 ? Math.max(MIN_BAR_PCT, Math.min(100 - offsetPct, rawWidth)) : 0
return { outcome, offsetPct, widthPct }
})
}
/** A provider that actually executed its tool (not skipped / never reached). */
function didRun(p: EnrichmentProviderOutcome): boolean {
return p.status !== 'skipped' && p.status !== 'not_run'
}
/**
* Derives the cell-level outcome from the cascade — mirrors the executor: a
* cancelled run is `cancelled` regardless of how far the cascade got; otherwise
* `error` only when every provider that ran errored, `not_run` when nothing
* executed (missing inputs), else a clean `no_match`.
*/
function deriveResultStatus(detail: EnrichmentRunDetail): ResultStatus {
if (detail.aborted) return 'cancelled'
if (detail.matchedProvider) return 'matched'
const ran = detail.providers.filter(didRun)
if (ran.length === 0) return 'not_run'
if (ran.every((p) => p.status === 'error')) return 'error'
return 'no_match'
}
interface DetailRowProps {
label: string
children: React.ReactNode
}
function DetailRow({ label, children }: DetailRowProps) {
return (
<div className='flex h-10 items-center justify-between gap-4 px-3 transition-colors hover-hover:bg-[var(--surface-2)]'>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
{label}
</span>
<span className='min-w-0 truncate text-right font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{children}
</span>
</div>
)
}
interface EnrichmentDetailsContentProps {
tableId: string
rowId: string
groupId: string
groupName?: string
isOpen: boolean
}
function EnrichmentDetailsContent({
tableId,
rowId,
groupId,
groupName,
isOpen,
}: EnrichmentDetailsContentProps) {
const [activeTab, setActiveTab] = useState<EnrichmentDetailsTab>('result')
const [prevKey, setPrevKey] = useState(`${rowId}:${groupId}`)
const key = `${rowId}:${groupId}`
if (prevKey !== key) {
setPrevKey(key)
setActiveTab('result')
}
const { data: detail, isLoading } = useEnrichmentDetail(tableId, rowId, groupId, {
enabled: isOpen,
})
const matchedLabel = detail?.matchedProvider
? (detail.providers.find((p) => p.id === detail.matchedProvider)?.label ??
detail.matchedProvider)
: null
const ranCount = detail ? detail.providers.filter(didRun).length : 0
const lastError = detail
? [...detail.providers].reverse().find((p) => p.status === 'error')?.error
: null
const timestamp = detail ? formatDate(detail.completedAt) : null
return (
<div className='mt-4 flex min-h-0 flex-1 flex-col'>
<ChipModalTabs
tabs={[
{ value: 'result', label: 'Result' },
{ value: 'cascade', label: 'Cascade' },
]}
value={activeTab}
onChange={(v) => setActiveTab(v as EnrichmentDetailsTab)}
/>
{isLoading ? (
<div className='flex h-full items-center justify-center px-4 text-center'>
<span className='font-medium text-[var(--text-tertiary)] text-sm'>Loading</span>
</div>
) : !detail ? (
<div className='flex h-full items-center justify-center px-4 text-center'>
<span className='font-medium text-[var(--text-tertiary)] text-sm'>
No enrichment details for this run
</span>
</div>
) : activeTab === 'result' ? (
<div className='mt-4 min-h-0 flex-1 overflow-y-auto'>
<div className='flex flex-col gap-2.5 pb-4'>
<div className='grid grid-cols-2 gap-x-3 pb-0.5'>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Timestamp
</span>
<span className='font-medium text-[var(--text-secondary)] text-sm tabular-nums'>
{timestamp ? `${timestamp.compactDate} ${timestamp.compactTime}` : '—'}
</span>
</div>
<div className='flex min-w-0 flex-col gap-0.5'>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
Enrichment
</span>
<span className='min-w-0 truncate font-medium text-[var(--text-secondary)] text-sm'>
{groupName || 'Enrichment'}
</span>
</div>
</div>
<div className='divide-y divide-[var(--border)] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--surface-2)] dark:bg-transparent'>
<DetailRow label='Status'>
<Badge
variant={RESULT_STATUS_CONFIG[deriveResultStatus(detail)].variant}
dot
size='sm'
>
{RESULT_STATUS_CONFIG[deriveResultStatus(detail)].label}
</Badge>
</DetailRow>
<DetailRow label='Duration'>
{formatDuration(detail.durationMs, { precision: 2 }) || '—'}
</DetailRow>
<DetailRow label='Total cost'>{formatCost(detail.totalCost)}</DetailRow>
<DetailRow label='Matched provider'>{matchedLabel || '—'}</DetailRow>
<DetailRow label='Providers ran'>{ranCount}</DetailRow>
</div>
{lastError && (
<div className='flex flex-col gap-1.5 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-2.5 py-2 dark:bg-transparent'>
<span className='font-medium text-[var(--text-error)] text-caption'>Error</span>
<p className='break-words text-[var(--text-secondary)] text-caption'>{lastError}</p>
</div>
)}
</div>
</div>
) : (
<div className='mt-4 min-h-0 flex-1 overflow-y-auto'>
{/* Summary strip — mirrors the trace header */}
<div className='mb-2 flex items-center gap-2 px-[2px]'>
<Badge variant={RESULT_STATUS_CONFIG[deriveResultStatus(detail)].variant} dot size='sm'>
{RESULT_STATUS_CONFIG[deriveResultStatus(detail)].label}
</Badge>
<span className='font-medium text-[var(--text-secondary)] text-caption tabular-nums'>
{formatDuration(detail.durationMs, { precision: 2 }) || '—'}
</span>
<span className='font-medium text-[var(--text-tertiary)] text-caption'>
{ranCount} {ranCount === 1 ? 'provider' : 'providers'}
</span>
{detail.totalCost > 0 && (
<span className='font-medium text-[var(--text-tertiary)] text-caption tabular-nums'>
{formatCost(detail.totalCost)}
</span>
)}
</div>
{/* Provider waterfall — each row is one cascade attempt on a shared timeline */}
<div className='flex flex-col pb-4'>
{buildCascadeRows(detail.providers).map(({ outcome, offsetPct, widthPct }) => {
const ran = didRun(outcome)
const { icon: ProviderIcon, bgColor: rawBgColor } = getBlockIconAndColor(
'tool',
outcome.toolId
)
const bgColor = adjustBgForContrast(rawBgColor)
return (
<div
key={outcome.id}
className={cn(
'relative flex min-w-0 flex-col rounded-md transition-colors',
outcome.status === 'matched'
? 'bg-[var(--surface-2)]'
: 'hover-hover:bg-[var(--surface-2)]',
!ran && 'opacity-60'
)}
>
<div className='flex min-w-0 items-center gap-1.5 px-2 pt-1.5'>
<div
className='flex size-[16px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm'
style={{ background: bgColor }}
>
{ProviderIcon && (
<ProviderIcon className={cn('size-[11px]', iconColorClass(bgColor))} />
)}
</div>
<span className='min-w-0 flex-1 truncate font-medium text-[var(--text-secondary)] text-caption'>
{outcome.label}
</span>
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption'>
{PROVIDER_STATUS_LABEL[outcome.status]}
</span>
{outcome.cost > 0 && (
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-xs tabular-nums'>
{formatCost(outcome.cost)}
</span>
)}
{ran && (
<span className='flex-shrink-0 font-medium text-[var(--text-tertiary)] text-caption tabular-nums'>
{formatDuration(outcome.durationMs, { precision: 2 }) || '—'}
</span>
)}
</div>
<div className='px-2 pt-[3px] pb-1.5'>
<div className='relative h-[3px] w-full overflow-hidden rounded-full bg-[var(--border)]'>
{widthPct > 0 && (
<div
className='absolute h-full rounded-full bg-[var(--text-tertiary)]'
style={{ left: `${offsetPct}%`, width: `${widthPct}%` }}
/>
)}
</div>
</div>
{outcome.error && (
<p className='break-words px-2 pb-1.5 text-[var(--text-tertiary)] text-xs'>
{outcome.error}
</p>
)}
</div>
)
})}
</div>
</div>
)}
</div>
)
}
interface EnrichmentDetailsProps {
tableId: string
rowId: string | null
groupId: string | null
groupName?: string
isOpen: boolean
onClose: () => void
}
/**
* Right-edge slideout showing an enrichment cell's run: a Result tab (status,
* duration, total cost, matched provider) and a Cascade tab (per-provider
* outcomes). Mirrors the log-details shell — resizable with a shared persisted
* width — minus the prev/next navigation, which is meaningless for a cell.
*/
export function EnrichmentDetails({
tableId,
rowId,
groupId,
groupName,
isOpen,
onClose,
}: EnrichmentDetailsProps) {
const panelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const { handleMouseDown } = useLogDetailsResize()
const maxVw = `${MAX_LOG_DETAILS_WIDTH_RATIO * 100}vw`
const effectiveWidth = `clamp(min(${MIN_LOG_DETAILS_WIDTH}px, ${maxVw}), ${panelWidth}px, ${maxVw})`
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose])
return (
<>
{isOpen && (
<div
className='absolute top-0 bottom-0 z-[var(--z-dropdown)] w-[8px] cursor-ew-resize'
style={{ right: `calc(${effectiveWidth} - 4px)` }}
onMouseDown={handleMouseDown}
role='separator'
aria-label='Resize enrichment details panel'
aria-orientation='vertical'
/>
)}
<div
className={cn(
'absolute top-0 right-0 bottom-0 z-[var(--z-dropdown)] overflow-hidden border-l bg-[var(--bg)] shadow-md transition-transform duration-200 ease-out',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
style={{ width: effectiveWidth }}
aria-label='Enrichment details sidebar'
>
{rowId && groupId && (
<div className='flex h-full flex-col px-3.5 pt-3'>
<div className='flex items-center justify-between'>
<h2 className='font-medium text-[var(--text-primary)] text-sm'>Enrichment Details</h2>
<Button variant='ghost' className='!p-1' onClick={onClose} aria-label='Close'>
<X className='size-[14px]' />
</Button>
</div>
<EnrichmentDetailsContent
tableId={tableId}
rowId={rowId}
groupId={groupId}
groupName={groupName}
isOpen={isOpen}
/>
</div>
)}
</div>
</>
)
}
@@ -0,0 +1 @@
export { EnrichmentDetails } from './enrichment-details'
@@ -0,0 +1,382 @@
'use client'
import { useState } from 'react'
import {
Badge,
Button,
ChipCombobox,
ChipInput,
CollapsibleCard,
FieldDivider,
Label,
Switch,
toast,
} from '@sim/emcn'
import { ArrowLeft, X } from '@sim/emcn/icons'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables'
import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table'
import { columnMatchesRef, getColumnId } from '@/lib/table/column-keys'
import { deriveOutputColumnName } from '@/lib/table/column-naming'
import { FieldError } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import type { EnrichmentConfig as EnrichmentDef } from '@/enrichments/types'
import {
useAddWorkflowGroup,
useUpdateColumn,
useUpdateWorkflowGroup,
} from '@/hooks/queries/tables'
import { RunSettingsSection } from '../workflow-sidebar/run-settings-section'
interface EnrichmentConfigProps {
enrichment: EnrichmentDef
allColumns: ColumnDefinition[]
workspaceId: string
tableId: string
onBack: () => void
onClose: () => void
/** When set, the panel edits this existing enrichment group (pre-filled,
* updates instead of creating; changed output names rename their columns). */
existingGroup?: WorkflowGroup
}
/** Pre-fill an input's column from a same-named column (case-insensitive). */
function defaultColumnFor(
input: EnrichmentDef['inputs'][number],
columns: ColumnDefinition[]
): string {
const match = columns.find(
(c) =>
c.name.toLowerCase() === input.id.toLowerCase() ||
c.name.toLowerCase() === input.name.toLowerCase()
)
return match ? getColumnId(match) : ''
}
/**
* Config panel for a code-defined enrichment. No workflow: the user maps each
* enrichment input to a table column; outputs are fixed by the enrichment.
* Saving creates an `enrichment` workflow group that the table runs per row.
*/
export function EnrichmentConfig({
enrichment,
allColumns,
workspaceId,
tableId,
onBack,
onClose,
existingGroup,
}: EnrichmentConfigProps) {
const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId })
const updateWorkflowGroup = useUpdateWorkflowGroup({ workspaceId, tableId })
const updateColumn = useUpdateColumn({ workspaceId, tableId })
const isEditing = Boolean(existingGroup)
/** Output's column (edit mode). Persisted `columnName` refs hold a stable
* column id (name for legacy groups), so resolve id-or-name to the column. */
const outputColumn = (outputId: string): ColumnDefinition | undefined => {
const ref = existingGroup?.outputs.find((o) => o.outputId === outputId)?.columnName
return ref === undefined ? undefined : allColumns.find((c) => columnMatchesRef(c, ref))
}
/** Output column's persisted display name (edit mode), used to detect renames. */
const originalOutputName = (outputId: string): string | undefined => outputColumn(outputId)?.name
const [inputMappings, setInputMappings] = useState<Record<string, string>>(() => {
if (existingGroup) {
const seed: Record<string, string> = {}
for (const m of existingGroup.inputMappings ?? []) {
const col = allColumns.find((c) => columnMatchesRef(c, m.columnName))
seed[m.inputName] = col ? getColumnId(col) : m.columnName
}
return seed
}
const seed: Record<string, string> = {}
for (const input of enrichment.inputs) {
const col = defaultColumnFor(input, allColumns)
if (col) seed[input.id] = col
}
return seed
})
// Per-output column names. Editable in both modes — edit mode seeds the
// existing column names and renames changed ones on save.
const [outputNames, setOutputNames] = useState<Record<string, string>>(() => {
const seed: Record<string, string> = {}
if (existingGroup) {
for (const o of existingGroup.outputs) {
if (!o.outputId) continue
const col = allColumns.find((c) => columnMatchesRef(c, o.columnName))
seed[o.outputId] = col?.name ?? o.columnName
}
return seed
}
const taken = new Set(allColumns.map((c) => c.name))
for (const o of enrichment.outputs) {
const colName = deriveOutputColumnName(o.name, taken)
taken.add(colName)
seed[o.id] = colName
}
return seed
})
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const [autoRun, setAutoRun] = useState(() => existingGroup?.autoRun ?? false)
const [deps, setDeps] = useState<string[]>(() => existingGroup?.dependencies?.columns ?? [])
const [showValidation, setShowValidation] = useState(false)
const columnOptions = allColumns.map((c) => ({ label: c.name, value: getColumnId(c) }))
const missingRequired = enrichment.inputs.some((i) => i.required && !inputMappings[i.id])
const depsValid = !autoRun || deps.length > 0
/** Per-output column-name validation (both modes). Excludes the output's own
* current column so renaming to its existing name isn't flagged. */
function outputNameError(outputId: string): string | null {
const value = (outputNames[outputId] ?? '').trim()
if (!value) return 'Required'
const lower = value.toLowerCase()
const ownOriginal = originalOutputName(outputId)?.toLowerCase()
if (
allColumns.some((c) => c.name.toLowerCase() === lower && c.name.toLowerCase() !== ownOriginal)
)
return 'Column already exists'
const dup = enrichment.outputs.some(
(o) => o.id !== outputId && (outputNames[o.id] ?? '').trim().toLowerCase() === lower
)
return dup ? 'Duplicate name' : null
}
const outputsInvalid = enrichment.outputs.some((o) => outputNameError(o.id) !== null)
const saveDisabled =
addWorkflowGroup.isPending ||
updateWorkflowGroup.isPending ||
updateColumn.isPending ||
(showValidation && missingRequired) ||
!depsValid ||
outputsInvalid
async function handleSave() {
if (missingRequired || (autoRun && deps.length === 0) || outputsInvalid) {
setShowValidation(true)
return
}
const inputMappingsList = Object.entries(inputMappings)
.filter(([, columnName]) => Boolean(columnName))
.map(([inputName, columnName]) => ({ inputName, columnName }))
if (existingGroup) {
try {
// Apply the group edit (mappings / deps / auto-run) first so it lands
// even if a later column rename fails. Renames run after and cascade
// into the group's output refs server-side.
await updateWorkflowGroup.mutateAsync({
groupId: existingGroup.id,
name: enrichment.name,
dependencies: { columns: deps },
inputMappings: inputMappingsList,
autoRun,
})
for (const o of enrichment.outputs) {
const col = outputColumn(o.id)
const next = (outputNames[o.id] ?? '').trim()
if (col && next && next !== col.name) {
await updateColumn.mutateAsync({
columnName: getColumnId(col),
updates: { name: next },
})
}
}
toast.success(`Updated "${enrichment.name}"`)
onClose()
} catch (err) {
toast.error(toError(err).message)
}
return
}
const groupId = generateId()
const taken = new Set(allColumns.map((c) => c.name))
const outputColumns: AddWorkflowGroupBodyInput['outputColumns'] = []
const outputs: WorkflowGroupOutput[] = []
for (const o of enrichment.outputs) {
const desired = (outputNames[o.id] ?? '').trim() || o.name
const colName = deriveOutputColumnName(desired, taken)
taken.add(colName)
outputColumns.push({
name: colName,
type: o.type,
required: false,
unique: false,
workflowGroupId: groupId,
})
outputs.push({ blockId: '', path: '', outputId: o.id, columnName: colName })
}
const group: WorkflowGroup = {
id: groupId,
workflowId: '',
enrichmentId: enrichment.id,
name: enrichment.name,
type: 'enrichment',
dependencies: { columns: deps },
outputs,
inputMappings: inputMappingsList,
autoRun,
}
try {
await addWorkflowGroup.mutateAsync({ group, outputColumns })
toast.success(`Added "${enrichment.name}"`)
onClose()
} catch (err) {
toast.error(toError(err).message)
}
}
return (
<div className='flex h-full flex-col'>
<div className='flex min-h-[48px] items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
<div className='flex min-w-0 items-center gap-1.5'>
<Button
variant='ghost'
size='sm'
onClick={onBack}
className='!p-1 size-7 flex-none'
aria-label='Back to enrichments'
>
<ArrowLeft className='size-[14px]' />
</Button>
<h2 className='truncate font-medium text-[var(--text-primary)] text-small'>
{enrichment.name}
</h2>
</div>
<Button
variant='ghost'
size='sm'
onClick={onClose}
className='!p-1 size-7 flex-none'
aria-label='Close'
>
<X className='size-[14px]' />
</Button>
</div>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [overflow-anchor:none]'>
<div className='flex flex-col gap-[9.5px]'>
<Label className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>Inputs</Label>
{enrichment.inputs.length === 0 ? (
<p className='pl-0.5 text-[var(--text-tertiary)] text-caption'>
This enrichment needs no inputs.
</p>
) : (
<div className='flex flex-col gap-2'>
{enrichment.inputs.map((input) => (
<CollapsibleCard
key={input.id}
title={input.required ? `${input.name} *` : input.name}
badge={
<Badge variant='type' size='sm'>
{input.type}
</Badge>
}
collapsed={collapsed[input.id] ?? false}
onToggleCollapse={() =>
setCollapsed((prev) => ({ ...prev, [input.id]: !prev[input.id] }))
}
>
<Label className='text-small'>Column</Label>
<ChipCombobox
searchable
searchPlaceholder='Search columns…'
className='w-full'
dropdownWidth='trigger'
maxHeight={240}
disabled={columnOptions.length === 0}
emptyMessage='No columns.'
placeholder='Select a column'
options={columnOptions}
value={inputMappings[input.id] ?? ''}
onChange={(columnName: string) =>
setInputMappings((prev) => ({ ...prev, [input.id]: columnName }))
}
/>
{showValidation && input.required && !inputMappings[input.id] && (
<FieldError message='Required' />
)}
</CollapsibleCard>
))}
</div>
)}
</div>
<FieldDivider />
<div className='flex flex-col gap-[9.5px]'>
<Label className='pl-0.5'>Output columns</Label>
<div className='flex flex-col gap-2'>
{enrichment.outputs.map((output) => {
const outErr = showValidation ? outputNameError(output.id) : null
return (
<CollapsibleCard
key={output.id}
title={output.name}
badge={
<Badge variant='type' size='sm'>
{output.type}
</Badge>
}
collapsed={collapsed[`out:${output.id}`] ?? false}
onToggleCollapse={() =>
setCollapsed((prev) => ({
...prev,
[`out:${output.id}`]: !prev[`out:${output.id}`],
}))
}
>
<Label className='text-small'>Column name</Label>
<ChipInput
value={outputNames[output.id] ?? ''}
onChange={(e) =>
setOutputNames((prev) => ({ ...prev, [output.id]: e.target.value }))
}
spellCheck={false}
autoComplete='off'
error={Boolean(outErr)}
/>
{outErr && <FieldError message={outErr} />}
</CollapsibleCard>
)
})}
</div>
</div>
<FieldDivider />
<div className='flex items-center justify-between pl-0.5'>
<Label htmlFor='enrichment-auto-run'>Auto-run</Label>
<Switch
id='enrichment-auto-run'
checked={autoRun}
onCheckedChange={(v) => setAutoRun(!!v)}
/>
</div>
{autoRun && (
<>
<FieldDivider />
<RunSettingsSection
depOptions={allColumns}
deps={deps}
onChangeDeps={setDeps}
error={showValidation && deps.length === 0 ? 'Select at least one column' : null}
/>
</>
)}
</div>
<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
<Button variant='default' size='sm' onClick={onClose}>
Cancel
</Button>
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
{isEditing ? 'Update' : 'Save'}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,176 @@
'use client'
import { useState } from 'react'
import { Button, ChipInput, cn } from '@sim/emcn'
import { Search, X } from '@sim/emcn/icons'
import type { ColumnDefinition, WorkflowGroup } from '@/lib/table'
import { ALL_ENRICHMENTS } from '@/enrichments'
import { getEnrichment } from '@/enrichments/registry'
import type { EnrichmentConfig as EnrichmentDef } from '@/enrichments/types'
import { EnrichmentConfig } from './enrichment-config'
interface EnrichmentsSidebarProps {
open: boolean
onClose: () => void
allColumns: ColumnDefinition[]
workspaceId: string
tableId: string
/** When set, the sidebar opens straight into this enrichment group's config
* in edit mode (skips the catalog list). */
editGroup?: WorkflowGroup
}
/**
* Right-edge panel for the enrichments flow. Lists the code-defined enrichment
* registry and, once one is picked, swaps in its config panel in the *same*
* sliding panel (input mapping + outputs), which creates an enrichment column.
*/
export function EnrichmentsSidebar({ open, ...rest }: EnrichmentsSidebarProps) {
return (
<aside
role='dialog'
aria-label='Enrichments'
className={cn(
'absolute top-0 right-0 bottom-0 z-[var(--z-modal)] flex w-[400px] flex-col overflow-hidden border-[var(--border)] border-l bg-[var(--bg)] transition-transform duration-200 ease-out',
open ? 'translate-x-0 shadow-overlay' : 'translate-x-full'
)}
>
{open && <EnrichmentsSidebarBody {...rest} />}
</aside>
)
}
function EnrichmentsSidebarBody({
onClose,
allColumns,
workspaceId,
tableId,
editGroup,
}: Omit<EnrichmentsSidebarProps, 'open'>) {
const [selected, setSelected] = useState<EnrichmentDef | null>(null)
const [query, setQuery] = useState('')
// Edit mode: open the picked enrichment's config directly, pre-filled from the
// existing group. No catalog list / back-to-list step.
const editEnrichment = editGroup ? getEnrichment(editGroup.enrichmentId) : undefined
if (editGroup && editEnrichment) {
return (
<EnrichmentConfig
enrichment={editEnrichment}
existingGroup={editGroup}
allColumns={allColumns}
workspaceId={workspaceId}
tableId={tableId}
onBack={onClose}
onClose={onClose}
/>
)
}
// Editing a group whose enrichment was removed from the registry — surface it
// rather than silently dropping into the "new enrichment" catalog.
if (editGroup && !editEnrichment) {
return (
<div className='flex h-full flex-col'>
<div className='flex min-h-[48px] items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
<h2 className='font-medium text-[var(--text-primary)] text-small'>Enrichment</h2>
<Button
variant='ghost'
size='sm'
onClick={onClose}
className='!p-1 size-7 flex-none'
aria-label='Close'
>
<X className='size-[14px]' />
</Button>
</div>
<div className='flex flex-1 items-center justify-center px-6 text-center'>
<p className='text-[var(--text-tertiary)] text-small'>
This enrichment ("{editGroup.enrichmentId}") is no longer available. Delete the column
and add a current enrichment.
</p>
</div>
</div>
)
}
if (selected) {
return (
<EnrichmentConfig
enrichment={selected}
allColumns={allColumns}
workspaceId={workspaceId}
tableId={tableId}
onBack={() => setSelected(null)}
onClose={onClose}
/>
)
}
const normalized = query.trim().toLowerCase()
const filtered = normalized
? ALL_ENRICHMENTS.filter(
(e) =>
e.name.toLowerCase().includes(normalized) ||
e.description.toLowerCase().includes(normalized)
)
: ALL_ENRICHMENTS
return (
<div className='flex h-full flex-col'>
<div className='flex min-h-[48px] items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
<h2 className='font-medium text-[var(--text-primary)] text-small'>Enrichments</h2>
<Button
variant='ghost'
size='sm'
onClick={onClose}
className='!p-1 size-7 flex-none'
aria-label='Close'
>
<X className='size-[14px]' />
</Button>
</div>
<div className='px-2 pt-3'>
<ChipInput
icon={Search}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='Search'
spellCheck={false}
autoComplete='off'
/>
</div>
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 py-3 [overflow-anchor:none]'>
{filtered.length === 0 ? (
<p className='px-1 pt-2 text-[var(--text-tertiary)] text-small'>No enrichments found.</p>
) : (
<ul className='flex flex-col'>
{filtered.map((enrichment) => {
const Icon = enrichment.icon
return (
<li key={enrichment.id}>
<button
type='button'
onClick={() => setSelected(enrichment)}
className='flex w-full items-start gap-2.5 rounded-md px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-hover)]'
>
<Icon className='mt-0.5 size-[14px] flex-none text-[var(--text-icon)]' />
<span className='flex min-w-0 flex-col gap-0.5'>
<span className='truncate font-medium text-[var(--text-primary)] text-small'>
{enrichment.name}
</span>
<span className='whitespace-normal break-words text-[var(--text-tertiary)] text-caption'>
{enrichment.description}
</span>
</span>
</button>
</li>
)
})}
</ul>
)}
</div>
</div>
)
}
@@ -0,0 +1 @@
export { EnrichmentsSidebar } from './enrichments-sidebar'
@@ -0,0 +1,12 @@
export * from './column-config-sidebar'
export * from './context-menu'
export * from './enrichment-details'
export * from './enrichments-sidebar'
export * from './new-column-dropdown'
export * from './row-modal'
export * from './run-status-control'
export * from './sidebar-fields'
export * from './table-action-bar'
export * from './table-filter'
export * from './table-grid'
export * from './workflow-sidebar'
@@ -0,0 +1 @@
export { NewColumnDropdown } from './new-column-dropdown'
@@ -0,0 +1,92 @@
'use client'
import {
ChipChevronDown,
chipContentIconClass,
chipContentLabelClass,
chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Plus,
} from '@sim/emcn'
import { Sparkles } from 'lucide-react'
import type { ColumnDefinition } from '@/lib/table'
import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar'
const CELL_HEADER =
'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle'
interface NewColumnDropdownProps {
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
trigger: 'header' | 'inline-header'
disabled: boolean
onPickType: (type: ColumnDefinition['type']) => void
onPickWorkflow: () => void
onPickEnrichment: () => void
}
/**
* "+ New column" dropdown — the single entry point for creating a column.
* Lists every column type plus "Workflow" and "Enrichments"; picking a type
* opens the right sidebar pre-seeded.
*/
export function NewColumnDropdown({
trigger,
disabled,
onPickType,
onPickWorkflow,
onPickEnrichment,
}: NewColumnDropdownProps) {
const menu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
{trigger === 'header' ? (
<button type='button' className={chipVariants()} disabled={disabled}>
<Plus className={chipContentIconClass} />
<span className={chipContentLabelClass}>New column</span>
<ChipChevronDown />
</button>
) : (
<button
type='button'
className='flex h-[20px] cursor-pointer items-center gap-2 outline-none'
disabled={disabled}
>
<Plus className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-medium text-[var(--text-body)] text-small'>New column</span>
</button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
<>
<DropdownMenuItem onSelect={onPickEnrichment}>
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
Enrichments
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
{COLUMN_TYPE_OPTIONS.map((option) => {
const Icon = option.icon
const onSelect =
option.type === 'workflow'
? onPickWorkflow
: () => onPickType(option.type as ColumnDefinition['type'])
return (
<DropdownMenuItem key={option.type} onSelect={onSelect}>
<Icon className='size-[14px] text-[var(--text-icon)]' />
{option.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
// The in-table trigger lives inside a `<tr>` so it must be a `<th>`. The
// header trigger lives in the page header so it sits inline.
return trigger === 'inline-header' ? <th className={CELL_HEADER}>{menu}</th> : menu
}
@@ -0,0 +1 @@
export { RowModal } from './row-modal'
@@ -0,0 +1,290 @@
'use client'
import { useId, useState } from 'react'
import {
Checkbox,
ChipConfirmModal,
ChipDatePicker,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
ChipTimePicker,
Label,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams } from 'next/navigation'
import type { ColumnDefinition, TableInfo, TableRow } from '@/lib/table'
import { useTimezone } from '@/hooks/queries/general-settings'
import { useDeleteTableRow, useDeleteTableRows, useUpdateTableRow } from '@/hooks/queries/tables'
import {
cleanCellValue,
dateValueToLocalParts,
formatValueForInput,
localPartsToDateValue,
todayLocalCalendarDate,
} from '../../utils'
const logger = createLogger('RowModal')
export interface RowModalProps {
mode: 'edit' | 'delete'
isOpen: boolean
onClose: () => void
table: TableInfo
row?: TableRow
rowIds?: string[]
onSuccess: () => void
}
function cleanRowData(
columns: ColumnDefinition[],
rowData: Record<string, unknown>,
timeZone: string
): Record<string, unknown> {
const cleanData: Record<string, unknown> = {}
columns.forEach((col) => {
const value = rowData[col.name]
try {
cleanData[col.name] = cleanCellValue(value, col, timeZone)
} catch {
throw new Error(`Invalid JSON for field: ${col.name}`)
}
})
return cleanData
}
/**
* Modal for editing a row's values or confirming row deletion.
*
* `rowData` is initialized from the `row` prop at mount time only. Both call-sites
* conditionally mount this component per open, so each open gets fresh state. If a
* call-site ever keeps it mounted across target-row changes, it must supply a `key`
* prop (e.g. the row id) so React remounts with the new row's values.
*/
export function RowModal({ mode, isOpen, onClose, table, row, rowIds, onSuccess }: RowModalProps) {
const params = useParams()
const workspaceId = params.workspaceId as string
const tableId = table.id
const schema = table?.schema
const columns = schema?.columns || []
const timeZone = useTimezone()
const [rowData, setRowData] = useState<Record<string, unknown>>(() =>
mode === 'edit' && row ? row.data : {}
)
const [error, setError] = useState<string | null>(null)
const updateRowMutation = useUpdateTableRow({ workspaceId, tableId })
const deleteRowMutation = useDeleteTableRow({ workspaceId, tableId })
const deleteRowsMutation = useDeleteTableRows({ workspaceId, tableId })
const isSubmitting =
updateRowMutation.isPending || deleteRowMutation.isPending || deleteRowsMutation.isPending
const handleFormSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
setError(null)
try {
const cleanData = cleanRowData(columns, rowData, timeZone)
if (row) {
await updateRowMutation.mutateAsync({ rowId: row.id, data: cleanData })
}
onSuccess()
} catch (err) {
logger.error('Failed to edit row:', err)
setError(getErrorMessage(err, 'Failed to edit row'))
}
}
const handleDelete = async () => {
setError(null)
const idsToDelete = rowIds ?? (row ? [row.id] : [])
try {
if (idsToDelete.length === 1) {
await deleteRowMutation.mutateAsync(idsToDelete[0])
} else {
await deleteRowsMutation.mutateAsync(idsToDelete)
}
onSuccess()
} catch (err) {
logger.error('Failed to delete row(s):', err)
setError(getErrorMessage(err, 'Failed to delete row(s)'))
}
}
const handleClose = () => {
setError(null)
onClose()
}
if (mode === 'delete') {
const deleteCount = rowIds?.length ?? (row ? 1 : 0)
const isSingleRow = deleteCount === 1
return (
<ChipConfirmModal
open={isOpen}
onOpenChange={handleClose}
srTitle={`Delete ${isSingleRow ? 'Row' : `${deleteCount} Rows`}`}
title={`Delete ${isSingleRow ? 'Row' : `${deleteCount} Rows`}`}
text={[
`Are you sure you want to delete ${isSingleRow ? 'this row' : `these ${deleteCount} rows`}? `,
{
text: `This will permanently remove all data in ${isSingleRow ? 'this row' : 'these rows'}.`,
error: true,
},
' This action cannot be undone.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: isSubmitting,
pendingLabel: 'Deleting...',
}}
>
<ChipModalError>{error}</ChipModalError>
</ChipConfirmModal>
)
}
return (
<ChipModal open={isOpen} onOpenChange={handleClose} srTitle='Edit Row' size='lg'>
<ChipModalHeader onClose={handleClose}>Edit Row</ChipModalHeader>
<ChipModalBody>
<p className='px-2 text-[var(--text-tertiary)] text-small'>
Update values for {table?.name ?? 'table'}
</p>
<form onSubmit={handleFormSubmit} className='contents'>
<button type='submit' hidden disabled={isSubmitting} />
{columns.map((column) => (
<ColumnField
key={column.name}
column={column}
value={rowData[column.name]}
onChange={(value) => setRowData((prev) => ({ ...prev, [column.name]: value }))}
/>
))}
</form>
<ChipModalError>{error}</ChipModalError>
</ChipModalBody>
<ChipModalFooter
onCancel={handleClose}
cancelDisabled={isSubmitting}
primaryAction={{
label: isSubmitting ? 'Updating...' : 'Update Row',
onClick: () => handleFormSubmit(),
disabled: isSubmitting,
}}
/>
</ChipModal>
)
}
interface ColumnFieldProps {
column: ColumnDefinition
value: unknown
onChange: (value: unknown) => void
}
function ColumnField({ column, value, onChange }: ColumnFieldProps) {
const checkboxId = useId()
const timeZone = useTimezone()
const title = (
<>
{column.name}
{column.unique && (
<span className='ml-1.5 font-normal text-[var(--text-tertiary)] text-xs'>(unique)</span>
)}
</>
)
const hint = `Type: ${column.type}${column.required ? '' : ' (optional)'}`
if (column.type === 'boolean') {
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<div className='flex items-center gap-2'>
<Checkbox
id={checkboxId}
checked={Boolean(value)}
onCheckedChange={(checked) => onChange(checked === true)}
/>
<Label
htmlFor={checkboxId}
className='font-normal text-[var(--text-tertiary)] text-small'
>
{value ? 'True' : 'False'}
</Label>
</div>
</ChipModalField>
)
}
if (column.type === 'json') {
return (
<ChipModalField
type='textarea'
title={title}
required={column.required}
hint={hint}
mono
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder='{"key": "value"}'
rows={4}
/>
)
}
if (column.type === 'date') {
const parts = dateValueToLocalParts(formatValueForInput(value, 'date'))
return (
<ChipModalField type='custom' title={title} required={column.required} hint={hint}>
<div className='flex items-center gap-2'>
<ChipDatePicker
value={parts.day ?? undefined}
today={todayLocalCalendarDate(timeZone)}
onChange={(day) => onChange(localPartsToDateValue(day, parts.time, timeZone))}
placeholder='Select date'
flush
className='flex-1'
/>
<ChipTimePicker
value={parts.time?.slice(0, 5)}
onChange={(time) =>
onChange(
localPartsToDateValue(parts.day ?? todayLocalCalendarDate(timeZone), time, timeZone)
)
}
placeholder='Add time'
flush
className='w-[110px]'
/>
</div>
</ChipModalField>
)
}
return (
<ChipModalField
type='input'
title={title}
required={column.required}
hint={hint}
inputType={column.type === 'number' ? 'number' : 'text'}
value={formatValueForInput(value, column.type)}
onChange={onChange}
placeholder={`Enter ${column.name}`}
/>
)
}
@@ -0,0 +1 @@
export { RunStatusControl } from './run-status-control'
@@ -0,0 +1,52 @@
'use client'
import { memo } from 'react'
import { Button } from '@sim/emcn'
import { Loader, Square } from '@sim/emcn/icons'
interface RunStatusControlProps {
running: number
/** No cell has been claimed by a worker yet — everything counted is queued
* or pending, so labeling it "running" would be dishonest. Renders
* "Queueing" without a count instead. */
queueing: boolean
onStopAll: () => void
isStopping: boolean
}
/**
* Run-status + Stop-all control rendered in the page header's leading actions
* row when any workflow runs are active. Matches the in-cell running indicator
* (Loader + tertiary text) for consistency.
*/
export const RunStatusControl = memo(function RunStatusControl({
running,
queueing,
onStopAll,
isStopping,
}: RunStatusControlProps) {
return (
<div className='flex items-center gap-1.5'>
<div className='flex items-center gap-1.5 px-1 text-[var(--text-tertiary)] text-caption'>
<Loader animate className='size-[14px] shrink-0' />
{queueing ? (
<span>Queueing</span>
) : (
<>
<span className='tabular-nums'>{running}</span>
<span>running</span>
</>
)}
</div>
<Button
variant='subtle'
className='px-2 py-1 text-caption'
onClick={onStopAll}
disabled={isStopping}
>
<Square className='mr-1.5 size-[14px]' />
{isStopping ? 'Stopping…' : 'Stop all'}
</Button>
</div>
)
})
@@ -0,0 +1 @@
export { FieldError, RequiredLabel } from './sidebar-fields'
@@ -0,0 +1,30 @@
'use client'
import type React from 'react'
import { Label } from '@sim/emcn'
/**
* Field label with a trailing required marker, matching the sidebar field
* rhythm shared by the column-config and workflow sidebars.
*/
export function RequiredLabel({
htmlFor,
children,
}: {
htmlFor?: string
children: React.ReactNode
}) {
return (
<Label htmlFor={htmlFor} className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>
{children}
<span className='ml-0.5'>*</span>
</Label>
)
}
/**
* Inline validation error rendered under a sidebar field.
*/
export function FieldError({ message }: { message: string }) {
return <p className='pl-0.5 text-[var(--text-error)] text-caption'>{message}</p>
}
@@ -0,0 +1 @@
export { TableActionBar } from './table-action-bar'
@@ -0,0 +1,169 @@
'use client'
import type React from 'react'
import { Button, cn, Tooltip } from '@sim/emcn'
import { Eye, PlayOutline, RefreshCw, Square } from '@sim/emcn/icons'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
interface TableActionBarProps {
/** Number of (row × group) cells the run/stop buttons would target. Drives
* the bar's leading label ("N cells"). */
selectedCellCount: number
/** Total running/queued workflow cells in the selection. Drives Stop. */
runningCount: number
/** Whether the table has any workflow columns. The bar hides entirely when
* there are none — Run/Stop have nothing to act on. */
hasWorkflowColumns: boolean
/** Show the Play (incomplete-mode) button — true when any selected cell is
* empty / errored / cancelled. */
showPlay: boolean
/** Show the Refresh (all-mode) button — true when any selected cell is
* already completed. */
showRefresh: boolean
/** Smart run: fire workflows only on cells that are empty / errored /
* cancelled. Maps to server `runMode: 'incomplete'`. */
onPlay: () => void
/** Forceful re-run: fire workflows on every selected cell, including
* completed ones. Maps to server `runMode: 'all'`. */
onRefresh: () => void
/** Cancel running/queued cells in the selection. */
onStopWorkflows: () => void
/** When the user has highlighted exactly one workflow cell (or N adjacent
* cells in the same row + group), surface a "View execution" affordance
* alongside the run buttons. Omit when no single-execution view applies. */
onViewExecution?: () => void
/** Disables actions while a bulk mutation is in flight. */
isLoading?: boolean
/** Additional className for the floating wrapper — used to lift the bar
* above bottom-anchored UI like a pagination row. */
className?: string
}
/**
* Floating action bar shown at the bottom of the table when one or more
* workflow cells are highlighted. Play / Refresh visibility is data-driven:
* Play appears when there's anything empty/failed in the selection; Refresh
* appears when there's anything already completed; both when the selection is
* mixed.
*
* Rendered with `position: absolute` inside the table's container (not
* `fixed`) so it scopes to the table's bounds — important for embedded mode,
* where the table sits inside a panel and a fixed-positioned bar would land
* centered on the whole viewport instead of the panel.
*/
export function TableActionBar({
selectedCellCount,
runningCount,
hasWorkflowColumns,
showPlay,
showRefresh,
onPlay,
onRefresh,
onStopWorkflows,
onViewExecution,
isLoading = false,
className,
}: TableActionBarProps) {
const visible =
hasWorkflowColumns &&
selectedCellCount > 0 &&
(showPlay || showRefresh || runningCount > 0 || Boolean(onViewExecution))
const stopLabel =
runningCount === 1 ? 'Stop running workflow' : `Stop ${runningCount} running workflows`
const playLabel =
selectedCellCount === 1 ? 'Run cell' : `Run ${selectedCellCount} empty or failed cells`
const refreshLabel = selectedCellCount === 1 ? 'Re-run cell' : `Re-run ${selectedCellCount} cells`
return (
<LazyMotion features={domAnimation}>
<AnimatePresence>
{visible && (
<m.div
key='table-action-bar'
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
className={cn(
'-translate-x-1/2 pointer-events-none absolute bottom-6 left-1/2 z-50 transform',
className
)}
>
<div className='pointer-events-auto flex items-center gap-2 rounded-[10px] border border-[var(--border)] bg-[var(--surface-2)] px-2 py-1.5'>
<span className='px-1 text-[var(--text-secondary)] text-small'>
{selectedCellCount === 1
? 'Selected 1 workflow cell'
: `Selected ${selectedCellCount} workflow cells`}
</span>
<div className='flex items-center gap-[5px]'>
{showPlay && (
<ActionIconButton label={playLabel} onClick={onPlay} disabled={isLoading}>
<PlayOutline className='size-[12px]' />
</ActionIconButton>
)}
{showRefresh && (
<ActionIconButton label={refreshLabel} onClick={onRefresh} disabled={isLoading}>
<RefreshCw className='size-[12px]' />
</ActionIconButton>
)}
{runningCount > 0 && (
<ActionIconButton
label={stopLabel}
onClick={onStopWorkflows}
disabled={isLoading}
>
<Square className='size-[12px]' />
</ActionIconButton>
)}
{onViewExecution && (
<ActionIconButton
label='View execution'
onClick={onViewExecution}
disabled={isLoading}
>
<Eye className='size-[12px]' />
</ActionIconButton>
)}
</div>
</div>
</m.div>
)}
</AnimatePresence>
</LazyMotion>
)
}
interface ActionIconButtonProps {
/** Tooltip text, also used as the button's accessible label. */
label: string
onClick: () => void
disabled: boolean
children: React.ReactNode
}
/**
* Tooltip-wrapped icon button sharing the action bar's brand-hover chrome,
* so the chrome string lives in one place.
*/
function ActionIconButton({ label, onClick, disabled, children }: ActionIconButtonProps) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onClick}
disabled={disabled}
className='hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg bg-[var(--surface-5)] p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]'
aria-label={label}
>
{children}
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{label}</Tooltip.Content>
</Tooltip.Root>
)
}
@@ -0,0 +1 @@
export { TableFilter } from './table-filter'
@@ -0,0 +1,218 @@
'use client'
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { Button, ChipDropdown, ChipInput } from '@sim/emcn'
import { Plus, X } from '@sim/emcn/icons'
import { generateShortId } from '@sim/utils/id'
import type { ColumnDefinition, Filter, FilterRule } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { COMPARISON_OPERATORS, VALUELESS_OPERATORS } from '@/lib/table/query-builder/constants'
import { filterRulesToFilter, filterToRules } from '@/lib/table/query-builder/converters'
interface TableFilterProps {
columns: ColumnDefinition[]
filter: Filter | null
onApply: (filter: Filter | null) => void
onClose: () => void
}
export function TableFilter({ columns, filter, onApply, onClose }: TableFilterProps) {
const [rules, setRules] = useState<FilterRule[]>(() => {
const fromFilter = filterToRules(filter)
return fromFilter.length > 0 ? fromFilter : [createRule(columns)]
})
const rulesRef = useRef(rules)
rulesRef.current = rules
// `value` is the filter field key (column id); `label` is what the user sees.
const columnOptions = useMemo(
() => columns.map((col) => ({ value: getColumnId(col), label: col.name })),
[columns]
)
const handleAdd = useCallback(() => {
setRules((prev) => [...prev, createRule(columns)])
}, [columns])
const handleRemove = useCallback(
(id: string) => {
const next = rulesRef.current.filter((r) => r.id !== id)
if (next.length === 0) {
onApply(null)
onClose()
setRules([createRule(columns)])
} else {
setRules(next)
}
},
[columns, onApply, onClose]
)
const handleUpdate = useCallback((id: string, field: keyof FilterRule, value: string) => {
setRules((prev) => prev.map((r) => (r.id === id ? { ...r, [field]: value } : r)))
}, [])
const handleToggleLogical = useCallback((id: string) => {
setRules((prev) =>
prev.map((r) =>
r.id === id ? { ...r, logicalOperator: r.logicalOperator === 'and' ? 'or' : 'and' } : r
)
)
}, [])
const handleApply = useCallback(() => {
const validRules = rulesRef.current.filter(
(r) => r.column && (r.value || VALUELESS_OPERATORS.has(r.operator))
)
onApply(filterRulesToFilter(validRules))
}, [onApply])
const handleClear = useCallback(() => {
setRules([createRule(columns)])
onApply(null)
}, [columns, onApply])
return (
<div className='border-[var(--border)] border-b bg-[var(--bg)] px-4 py-2'>
<div className='flex flex-col gap-1'>
{rules.map((rule, index) => (
<FilterRuleRow
key={rule.id}
rule={rule}
isFirst={index === 0}
columns={columnOptions}
onUpdate={handleUpdate}
onRemove={handleRemove}
onApply={handleApply}
onToggleLogical={handleToggleLogical}
/>
))}
<div className='mt-1 flex items-center justify-between'>
<Button
variant='ghost'
size='sm'
onClick={handleAdd}
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
>
<Plus className='mr-1 size-[10px]' />
Add filter
</Button>
<div className='flex items-center gap-1.5'>
{filter !== null && (
<Button
variant='ghost'
size='sm'
onClick={handleClear}
className='px-2 py-1 text-[var(--text-secondary)] text-xs'
>
Clear filters
</Button>
)}
<Button variant='default' size='sm' onClick={handleApply} className='text-xs'>
Apply filter
</Button>
</div>
</div>
</div>
</div>
)
}
interface FilterRuleRowProps {
rule: FilterRule
isFirst: boolean
columns: Array<{ value: string; label: string }>
onUpdate: (id: string, field: keyof FilterRule, value: string) => void
onRemove: (id: string) => void
onApply: () => void
onToggleLogical: (id: string) => void
}
const FilterRuleRow = memo(function FilterRuleRow({
rule,
isFirst,
columns,
onUpdate,
onRemove,
onApply,
onToggleLogical,
}: FilterRuleRowProps) {
// Keep a stale column id selectable/visible (e.g. after the column was
// removed) instead of falling back to the placeholder while the rule still
// filters on it.
const columnOptions =
rule.column && !columns.some((col) => col.value === rule.column)
? [...columns, { value: rule.column, label: rule.column }]
: columns
return (
<div className='flex items-center gap-1.5'>
{isFirst ? (
<span className='w-[42px] shrink-0 text-right text-[var(--text-muted)] text-xs'>Where</span>
) : (
<button
onClick={() => onToggleLogical(rule.id)}
className='w-[42px] shrink-0 rounded-full py-0.5 text-right font-medium text-[10px] text-[var(--text-muted)] uppercase tracking-wide transition-colors hover:text-[var(--text-secondary)]'
>
{rule.logicalOperator}
</button>
)}
<ChipDropdown
options={columnOptions}
value={rule.column}
onChange={(value) => onUpdate(rule.id, 'column', value)}
placeholder='Column'
align='start'
matchTriggerWidth={false}
className='min-w-[100px]'
/>
<ChipDropdown
options={COMPARISON_OPERATORS}
value={rule.operator}
onChange={(value) => onUpdate(rule.id, 'operator', value)}
placeholder='Operator'
align='start'
matchTriggerWidth={false}
className='min-w-[90px]'
/>
{VALUELESS_OPERATORS.has(rule.operator) ? (
<div className='h-[30px] flex-1' />
) : (
<ChipInput
value={rule.value}
onChange={(e) => onUpdate(rule.id, 'value', e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onApply()
}}
placeholder='Enter a value'
className='flex-1'
/>
)}
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(rule.id)}
className='!p-1 size-7 shrink-0'
aria-label='Remove filter'
>
<X className='size-[12px]' />
</Button>
</div>
)
})
function createRule(columns: ColumnDefinition[]): FilterRule {
return {
id: generateShortId(),
logicalOperator: 'and',
column: columns[0] ? getColumnId(columns[0]) : '',
operator: 'eq',
value: '',
}
}
@@ -0,0 +1,73 @@
'use client'
import type { RowExecutionMetadata } from '@/lib/table'
import type { SaveReason } from '../../../types'
import type { DisplayColumn } from '../types'
import { CellRender, resolveCellRender } from './cell-render'
import { InlineEditor } from './inline-editors'
interface CellContentProps {
value: unknown
exec?: RowExecutionMetadata
column: DisplayColumn
/** Current workspace id — lets string cells holding an in-workspace resource
* URL render as a tagged-resource chip instead of a plain external link. */
workspaceId: string
isEditing: boolean
initialCharacter?: string | null
onSave: (value: unknown, reason: SaveReason) => void
onCancel: () => void
/**
* Human-readable labels for unmet deps on this row+group, used to render a
* "Waiting" pill when the cell hasn't run because something it depends on
* is empty. `undefined` (or empty) means no waiting state.
*/
waitingOnLabels?: string[]
/** Column is an enrichment output — a completed-but-empty cell renders "Not found". */
isEnrichmentOutput?: boolean
}
/**
* Glue layer: maps cell inputs to a typed `CellRenderKind` (via the pure
* resolver) and renders the corresponding JSX (via the dumb renderer). The
* inline editor sits on top when `isEditing` is true. Adding a new cell
* appearance is a three-step mechanical change in the colocated files.
*/
export function CellContent({
value,
exec,
column,
workspaceId,
isEditing,
initialCharacter,
onSave,
onCancel,
waitingOnLabels,
isEnrichmentOutput,
}: CellContentProps) {
const kind = resolveCellRender({
value,
exec,
column,
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId: workspaceId,
})
return (
<>
{isEditing && (
<div className='absolute inset-0 z-10 flex items-center px-0'>
<InlineEditor
value={value}
column={column}
initialCharacter={initialCharacter ?? undefined}
onSave={onSave}
onCancel={onCancel}
/>
</div>
)}
<CellRender kind={kind} isEditing={isEditing} />
</>
)
}
@@ -0,0 +1,516 @@
'use client'
import type React from 'react'
import { useEffect, useRef, useState } from 'react'
import { Badge, Checkbox, cn, Tooltip } from '@sim/emcn'
import { parse } from 'tldts'
import type { RowExecutionMetadata } from '@/lib/table'
import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
import { storageToDisplay } from '../../../utils'
import type { DisplayColumn } from '../types'
import { SimResourceCell, type SimResourceType } from './sim-resource-cell'
export type CellRenderKind =
// Workflow-output cells
| { kind: 'value'; text: string }
| { kind: 'block-error'; message: string }
| { kind: 'running' }
| { kind: 'pending-upstream'; paused: boolean }
| { kind: 'queued' }
| { kind: 'cancelled' }
| { kind: 'error'; message: string | null }
| { kind: 'waiting'; labels: string[] }
| { kind: 'not-found' }
| { kind: 'no-output' }
// Plain typed cells
| { kind: 'boolean'; checked: boolean }
| { kind: 'json'; text: string }
| { kind: 'date'; text: string }
| { kind: 'url'; text: string; href: string; domain: string }
| {
kind: 'sim-resource'
workspaceId: string
resourceType: SimResourceType
resourceId: string
href: string
}
| { kind: 'text'; text: string }
// Universal fallback
| { kind: 'empty' }
interface ResolveCellRenderInput {
value: unknown
exec: RowExecutionMetadata | undefined
column: DisplayColumn
waitingOnLabels: string[] | undefined
/** Column is an enrichment-group output — a completed-but-empty cell renders
* "Not found" rather than a blank, since the enrichment ran and matched nothing. */
isEnrichmentOutput?: boolean
/** Current workspace id — a URL pointing to a resource in this workspace
* renders as a tagged-resource chip rather than a plain external link. */
currentWorkspaceId?: string
}
export function resolveCellRender({
value,
exec,
column,
waitingOnLabels,
isEnrichmentOutput,
currentWorkspaceId,
}: ResolveCellRenderInput): CellRenderKind {
const isNull = value === null || value === undefined
const isEmpty = isNull || value === ''
if (column.workflowGroupId) {
const blockId = column.outputBlockId
const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined
const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false
const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0)
if (blockError) return { kind: 'block-error', message: blockError }
const inFlight =
exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending'
if (inFlight && blockRunning) return { kind: 'running' }
// Value wins over pending-upstream: a finished column stays finished even
// while other blocks in the group are still running. An empty string is not
// a value — it falls through so a completed enrichment can show "Not found".
// A value that's wholly a resource/URL string renders as a chip/link (any
// column type — workflow output is free-form); otherwise the plain `value`
// kind keeps the typewriter reveal for streaming text.
if (!isEmpty) {
const text = stringifyValue(value)
return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'value', text }
}
// Enrichment outputs share an empty blockId, so `runningBlockIds` can never
// match them — the group-level `running` status is the only "worker picked
// this up" signal an enrichment cell has. Checked after the value so a
// rerun keeps showing the previous output until the new result lands.
if (isEnrichmentOutput && exec?.status === 'running') return { kind: 'running' }
if (inFlight && !(groupHasBlockErrors && !blockRunning)) {
// A `pending` cell whose jobId starts with `paused-` is mid-pause
// (workflow yielded for human-in-the-loop). Render as Pending rather
// than Queued so the user can tell it's not just waiting to start.
const isPaused =
exec?.status === 'pending' &&
typeof exec.jobId === 'string' &&
exec.jobId.startsWith('paused-')
if (isPaused) return { kind: 'pending-upstream', paused: true }
if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' }
return { kind: 'pending-upstream', paused: false }
}
// Waiting wins over a stale terminal status — show the actionable state.
if (waitingOnLabels && waitingOnLabels.length > 0) {
return { kind: 'waiting', labels: waitingOnLabels }
}
if (exec?.status === 'cancelled') return { kind: 'cancelled' }
if (exec?.status === 'error') return { kind: 'error', message: exec.error }
// Enrichment ran to completion but matched nothing → "Not found".
if (isEnrichmentOutput && exec?.status === 'completed') return { kind: 'not-found' }
// Workflow output: the group's run completed but this block produced no
// value for the cell → grey "No output" (distinct from a never-run blank).
if (exec?.status === 'completed') return { kind: 'no-output' }
return { kind: 'empty' }
}
if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) }
if (isNull) return { kind: 'empty' }
if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) }
if (column.type === 'date') return { kind: 'date', text: String(value) }
if (column.type === 'string') {
const text = stringifyValue(value)
return resolveLinkKind(text, currentWorkspaceId) ?? { kind: 'text', text }
}
return { kind: 'text', text: stringifyValue(value) }
}
function stringifyValue(value: unknown): string {
if (typeof value === 'string') return value
if (value === null || value === undefined) return ''
return JSON.stringify(value)
}
/** Returns a `sim-resource` cell kind when `text` is a URL pointing to a
* resource in the current workspace, else null. Shared by plain string cells
* and workflow-output value cells so both surface in-workspace resource links
* as tagged chips. */
function resolveSimResourceKind(
text: string,
currentWorkspaceId: string | undefined
): Extract<CellRenderKind, { kind: 'sim-resource' }> | null {
if (!currentWorkspaceId) return null
const resource = extractSimResourceInfo(text)
if (!resource || resource.workspaceId !== currentWorkspaceId) return null
return {
kind: 'sim-resource',
workspaceId: resource.workspaceId,
resourceType: resource.resourceType,
resourceId: resource.resourceId,
href: resource.href,
}
}
/**
* Promotes a cell value that is wholly a resource/URL string to a chip
* (in-workspace resource) or a favicon link, else null. Shared by plain string
* cells and workflow-output value cells. Workflow outputs apply this regardless
* of `column.type` (their type defaults to `json`, so gating on `string` would
* miss URL outputs); a stringified object never matches the whole-string URL
* check, so it stays JSON/text.
*/
function resolveLinkKind(
text: string,
currentWorkspaceId: string | undefined
): Extract<CellRenderKind, { kind: 'sim-resource' } | { kind: 'url' }> | null {
const simKind = resolveSimResourceKind(text, currentWorkspaceId)
if (simKind) return simKind
const urlInfo = extractUrlInfo(text)
if (urlInfo) return { kind: 'url', text, href: urlInfo.href, domain: urlInfo.domain }
return null
}
const BARE_DOMAIN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
function extractUrlInfo(text: string): { href: string; domain: string } | null {
const trimmed = text.trim()
if (!trimmed) return null
if (/^https?:\/\//i.test(trimmed)) {
try {
const url = new URL(trimmed)
return { href: trimmed, domain: url.hostname }
} catch {
return null
}
}
if (BARE_DOMAIN_RE.test(trimmed)) {
const parsed = parse(trimmed)
if (!parsed.isIcann) return null
return { href: `https://${trimmed}`, domain: trimmed }
}
return null
}
/** Maps a workspace route section to the sim resource kind it addresses. */
const SIM_RESOURCE_SECTIONS: Record<string, SimResourceType> = {
w: 'workflow',
tables: 'table',
knowledge: 'knowledge',
files: 'file',
}
/**
* Recognizes a `/workspace/{id}/{section}/{resourceId}` URL (absolute or
* relative) pointing to a sim resource and returns its descriptor. The href is
* the pathname so the link stays within the current deployment. Returns null
* for anything that isn't a single-segment resource route.
*/
function extractSimResourceInfo(
text: string
): { workspaceId: string; resourceType: SimResourceType; resourceId: string; href: string } | null {
const trimmed = text.trim()
if (!trimmed) return null
let pathname: string
if (/^https?:\/\//i.test(trimmed)) {
try {
pathname = new URL(trimmed).pathname
} catch {
return null
}
} else if (trimmed.startsWith('/')) {
pathname = trimmed.split(/[?#]/)[0]
} else {
return null
}
const match = pathname.match(/^\/workspace\/([^/]+)\/(w|tables|knowledge|files)\/([^/]+)\/?$/)
if (!match) return null
const [, workspaceId, section, resourceId] = match
return { workspaceId, resourceType: SIM_RESOURCE_SECTIONS[section], resourceId, href: pathname }
}
interface CellRenderProps {
kind: CellRenderKind
isEditing: boolean
}
export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null {
const valueText = kind.kind === 'value' ? kind.text : null
const revealedValueText = useTypewriter(valueText)
switch (kind.kind) {
case 'value':
return (
<span
className={cn(
'block overflow-clip text-ellipsis text-[var(--text-primary)]',
isEditing && 'invisible'
)}
>
{revealedValueText ?? kind.text}
</span>
)
case 'block-error':
case 'error': {
if (!kind.message) {
return (
<Wrap isEditing={isEditing}>
<StatusBadge status='error' />
</Wrap>
)
}
return (
<Wrap isEditing={isEditing}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span>
<StatusBadge status='error' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{kind.message}</Tooltip.Content>
</Tooltip.Root>
</Wrap>
)
}
case 'running':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip tip='The block is running.'>
<StatusBadge status='running' />
</BadgeTooltip>
</Wrap>
)
case 'pending-upstream':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip
tip={
kind.paused
? 'Paused — waiting on input to resume.'
: 'Waiting on an earlier block in this run.'
}
>
<StatusBadge status='pending' />
</BadgeTooltip>
</Wrap>
)
case 'cancelled':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip tip='Stopped before finishing — re-run to retry.'>
<StatusBadge status='cancelled' />
</BadgeTooltip>
</Wrap>
)
case 'queued':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip tip='In line to run — starts when a batch slot opens.'>
<Badge variant='gray' dot size='sm'>
Queued
</Badge>
</BadgeTooltip>
</Wrap>
)
case 'waiting':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip tip={`Waiting on ${kind.labels.map((l) => `"${l}"`).join(', ')}`}>
<Badge variant='gray' dot size='sm'>
Waiting
</Badge>
</BadgeTooltip>
</Wrap>
)
case 'boolean':
return (
<div
data-boolean-cell-toggle
className={cn(
'flex min-h-[20px] w-full items-center justify-center',
isEditing && 'invisible'
)}
>
<Checkbox size='sm' checked={kind.checked} className='pointer-events-none' />
</div>
)
case 'json':
return (
<span
className={cn(
'block overflow-clip text-ellipsis text-[var(--text-primary)]',
isEditing && 'invisible'
)}
>
{kind.text}
</span>
)
case 'date':
return (
<span className={cn('text-[var(--text-primary)]', isEditing && 'invisible')}>
{storageToDisplay(kind.text, { seconds: true })}
</span>
)
case 'url':
return (
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(kind.domain)}&sz=16`}
alt=''
width={12}
height={12}
className='shrink-0 rounded-[2px]'
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
<a
href={kind.href}
target='_blank'
rel='noopener noreferrer'
className={cn(
'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 transition-colors hover-hover:text-[var(--text-secondary)]',
isEditing && 'pointer-events-none'
)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{kind.text}
</a>
</span>
)
case 'sim-resource':
return (
<SimResourceCell
workspaceId={kind.workspaceId}
resourceType={kind.resourceType}
resourceId={kind.resourceId}
href={kind.href}
isEditing={isEditing}
/>
)
case 'text':
return (
<span
className={cn(
'block overflow-clip text-ellipsis text-[var(--text-primary)]',
isEditing && 'invisible'
)}
>
{kind.text}
</span>
)
case 'not-found':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip tip='No match found for this row.'>
<Badge variant='gray' dot size='sm'>
Not found
</Badge>
</BadgeTooltip>
</Wrap>
)
case 'no-output':
return (
<Wrap isEditing={isEditing}>
<BadgeTooltip tip='Finished with no value for this cell.'>
<Badge variant='gray' dot size='sm'>
No output
</Badge>
</BadgeTooltip>
</Wrap>
)
case 'empty':
return null
default: {
const _exhaustive: never = kind
return _exhaustive
}
}
}
function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) {
if (!isEditing) return <>{children}</>
return <div className='invisible'>{children}</div>
}
/** Hover explanation for a status badge. The `<span>` trigger is required —
* Badge/StatusBadge don't forward refs, so the tooltip anchors to a wrapper. */
function BadgeTooltip({ tip, children }: { tip: string; children: React.ReactNode }) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span>{children}</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{tip}</Tooltip.Content>
</Tooltip.Root>
)
}
const TYPEWRITER_MS_PER_CHAR = 15
/**
* Reveals `text` character-by-character when it changes after the first render;
* the initial render (mount / scroll-in) shows it statically. The slice is
* derived from elapsed time during render rather than held in state, so it is
* never `null` and never the full string on the frame `text` changes — which is
* what prevents the caller's `?? kind.text` fallback from flashing the whole
* value for a frame. `prevText` is state (not a ref) so a discarded render rolls
* it back and re-detects the change on the committed render.
*/
function useTypewriter(text: string | null): string | null {
const [prevText, setPrevText] = useState<string | null>(text)
const [, forceFrame] = useState(0)
const mountedRef = useRef(false)
// Reveal-clock start; 0 = show statically (mount / cleared / empty).
const startRef = useRef(0)
if (prevText !== text) {
setPrevText(text)
startRef.current =
mountedRef.current && text !== null && text.length > 0 ? performance.now() : 0
}
useEffect(() => {
mountedRef.current = true
}, [])
useEffect(() => {
if (startRef.current === 0 || text === null) return
let raf = 0
const tick = () => {
const chars = Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR)
forceFrame((f) => f + 1)
if (chars < text.length) raf = requestAnimationFrame(tick)
}
raf = requestAnimationFrame(tick)
return () => cancelAnimationFrame(raf)
}, [text])
if (text === null) return null
if (startRef.current === 0) return text
const chars = Math.min(
text.length,
Math.floor((performance.now() - startRef.current) / TYPEWRITER_MS_PER_CHAR)
)
return text.slice(0, chars)
}
@@ -0,0 +1,284 @@
'use client'
import type React from 'react'
import { useEffect, useEffectEvent, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Button } from '@sim/emcn'
import type { TableRow as TableRowType } from '@/lib/table'
import { useTimezone } from '@/hooks/queries/general-settings'
import type { EditingCell, SaveReason } from '../../../types'
import {
cleanCellValue,
displayToStorage,
formatValueForInput,
storageToDisplay,
} from '../../../utils'
import type { DisplayColumn } from '../types'
interface ExpandedCellPopoverProps {
expandedCell: EditingCell | null
onClose: () => void
rows: TableRowType[]
columns: DisplayColumn[]
onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void
canEdit: boolean
scrollContainer: HTMLElement | null
}
const EXPANDED_CELL_MIN_WIDTH = 420
const EXPANDED_CELL_HEIGHT = 280
/**
* Anchored cell editor. Floats over the double-clicked cell, minimum width
* {@link EXPANDED_CELL_MIN_WIDTH}, fixed height, internally scrollable.
*
* Workflow and boolean cells are read-only here — workflow cells are driven
* by the scheduler, booleans toggle inline.
*/
export function ExpandedCellPopover({
expandedCell,
onClose,
rows,
columns,
onSave,
canEdit,
scrollContainer,
}: ExpandedCellPopoverProps) {
const rootRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null)
const target = useMemo(() => {
if (!expandedCell) return null
const row = rows.find((r) => r.id === expandedCell.rowId)
// Match the specific visual column the user double-clicked on. Fanned-out
// workflow columns share `name` across siblings, so prefer `key` when set.
const matchByKey = expandedCell.columnKey
? (c: DisplayColumn) => c.key === expandedCell.columnKey
: (c: DisplayColumn) => c.key === expandedCell.columnName
const column = columns.find(matchByKey)
if (!row || !column) return null
const colIndex = columns.findIndex(matchByKey)
return { row, column, colIndex, value: row.data[column.key] }
}, [expandedCell, rows, columns])
const isBooleanCell = target?.column.type === 'boolean'
// Workflow-output cells are editable in the expanded view too — the user
// can override the workflow's value. Booleans toggle inline; the expanded
// popover only handles text-shaped inputs.
const isEditable = Boolean(target) && canEdit && !isBooleanCell
const displayText = useMemo(() => {
if (!target) return ''
const { value } = target
if (value == null) return ''
if (typeof value === 'string') return value
return JSON.stringify(value, null, 2)
}, [target])
useLayoutEffect(() => {
if (!expandedCell || !target) {
setRect(null)
return
}
const selector = `[data-table-scroll] [data-row-id="${target.row.id}"][data-col="${target.colIndex}"]`
const el = document.querySelector<HTMLElement>(selector)
if (!el) {
setRect(null)
return
}
const r = el.getBoundingClientRect()
setRect({ top: r.top, left: r.left, width: r.width })
// Focus textarea on open so typing works immediately.
requestAnimationFrame(() => textareaRef.current?.focus())
}, [expandedCell, target])
const onCloseEvent = useEffectEvent(onClose)
useEffect(() => {
if (!expandedCell) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
onCloseEvent()
}
}
const handleMouseDown = (e: MouseEvent) => {
if (!rootRef.current) return
if (rootRef.current.contains(e.target as Node)) return
onCloseEvent()
}
window.addEventListener('keydown', handleKey)
window.addEventListener('mousedown', handleMouseDown)
return () => {
window.removeEventListener('keydown', handleKey)
window.removeEventListener('mousedown', handleMouseDown)
}
}, [expandedCell])
// Close on table scroll — re-anchoring mid-scroll is more jarring than dismissing.
useEffect(() => {
if (!expandedCell || !scrollContainer) return
const handler = () => onCloseEvent()
scrollContainer.addEventListener('scroll', handler, { passive: true })
return () => scrollContainer.removeEventListener('scroll', handler)
}, [expandedCell, scrollContainer])
if (!expandedCell || !target || !rect) return null
const width = Math.max(rect.width, EXPANDED_CELL_MIN_WIDTH)
// Clamp to viewport. Prefer anchoring at the cell's left edge; if the popover
// would overflow right, align its right edge with the cell's right edge
// (mirroring Radix/menu flip behavior). Same idea for bottom-of-viewport.
const VIEWPORT_PAD = 8
const cellRight = rect.left + rect.width
const overflowsRight = rect.left + width > window.innerWidth - VIEWPORT_PAD
const left = overflowsRight
? Math.max(VIEWPORT_PAD, cellRight - width)
: Math.max(VIEWPORT_PAD, rect.left)
const overflowsBottom = rect.top + EXPANDED_CELL_HEIGHT > window.innerHeight - VIEWPORT_PAD
const top = overflowsBottom
? Math.max(VIEWPORT_PAD, window.innerHeight - EXPANDED_CELL_HEIGHT - VIEWPORT_PAD)
: rect.top
return (
<div
ref={rootRef}
role='dialog'
aria-label={`Expanded view of ${target.column.name}`}
className='fixed z-50 flex flex-col overflow-hidden rounded-md border border-[var(--border-1)] bg-[var(--bg)] shadow-md'
style={{ top, left, width, height: EXPANDED_CELL_HEIGHT }}
>
{isEditable ? (
<ExpandedCellEditor
key={`${expandedCell.rowId}:${expandedCell.columnKey ?? expandedCell.columnName}`}
initialValue={
target.column.type === 'date'
? storageToDisplay(formatValueForInput(target.value, 'date'), { seconds: true })
: formatValueForInput(target.value, target.column.type)
}
column={target.column}
rowId={target.row.id}
onSave={onSave}
onClose={onClose}
textareaRef={textareaRef}
/>
) : (
<>
<div className='min-h-0 flex-1 overflow-auto px-2.5 py-2'>
{displayText ? (
<pre className='whitespace-pre-wrap break-words font-sans text-[var(--text-primary)] text-small'>
{displayText}
</pre>
) : (
<span className='text-[var(--text-tertiary)] text-small'>(empty)</span>
)}
</div>
<div className='flex items-center justify-end border-[var(--border)] border-t bg-[var(--surface-2)] px-2 py-1.5'>
<Button variant='ghost' size='sm' onClick={onClose}>
Close
</Button>
</div>
</>
)}
</div>
)
}
interface ExpandedCellEditorProps {
initialValue: string
column: DisplayColumn
rowId: string
onSave: ExpandedCellPopoverProps['onSave']
onClose: () => void
textareaRef: React.RefObject<HTMLTextAreaElement | null>
}
/**
* Editable body of the popover. Keyed on the edited cell so the draft
* survives unrelated row refetches (SSE cache patches, polling) while the
* popover is open, and resets only when the target cell changes.
*/
function ExpandedCellEditor({
initialValue,
column,
rowId,
onSave,
onClose,
textareaRef,
}: ExpandedCellEditorProps) {
const [draftValue, setDraftValue] = useState(initialValue)
const [parseError, setParseError] = useState<string | null>(null)
const timeZone = useTimezone()
const handleSave = () => {
// Untouched draft → close without writing. For dates this also avoids
// re-stamping the stored offset with this viewer's zone.
if (draftValue === initialValue) {
onClose()
return
}
// Only date columns go through `displayToStorage` — it now parses many
// date shapes, so a number draft like "2024" must not reach it.
const raw =
column.type === 'date' ? (displayToStorage(draftValue, timeZone) ?? draftValue) : draftValue
let cleaned: unknown
try {
cleaned = cleanCellValue(raw, column, timeZone)
} catch {
setParseError('Invalid JSON')
return
}
/** `cleanCellValue` nulls unparseable dates/numbers instead of throwing — reject rather than silently clear. */
if (
cleaned === null &&
draftValue.trim() !== '' &&
(column.type === 'date' || column.type === 'number')
) {
setParseError(column.type === 'date' ? 'Invalid date' : 'Invalid number')
return
}
onSave(rowId, column.key, cleaned, 'blur')
onClose()
}
const handleTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSave()
}
}
return (
<>
<textarea
ref={textareaRef}
value={draftValue}
onChange={(e) => {
setDraftValue(e.target.value)
setParseError(null)
}}
onKeyDown={handleTextareaKeyDown}
className='min-h-0 flex-1 resize-none bg-transparent px-2.5 py-2 font-sans text-[var(--text-primary)] text-small outline-none placeholder:text-[var(--text-muted)]'
spellCheck={false}
autoCorrect='off'
/>
<div className='flex items-center justify-between border-[var(--border)] border-t bg-[var(--surface-2)] px-2 py-1.5'>
{parseError ? (
<span className='text-[var(--text-error)] text-caption'>{parseError}</span>
) : (
<span className='text-[var(--text-tertiary)] text-caption'>
<kbd className='font-mono'></kbd> save · <kbd className='font-mono'>esc</kbd> cancel
</span>
)}
<div className='flex items-center gap-1.5'>
<Button variant='ghost' size='sm' onClick={onClose}>
Cancel
</Button>
<Button size='sm' variant='primary' onClick={handleSave}>
Save
</Button>
</div>
</div>
</>
)
}
@@ -0,0 +1,4 @@
export { CellContent } from './cell-content'
export { CellRender, type CellRenderKind, resolveCellRender } from './cell-render'
export { ExpandedCellPopover } from './expanded-cell-popover'
export { InlineEditor } from './inline-editors'
@@ -0,0 +1,332 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Calendar, cn, Popover, PopoverAnchor, PopoverContent, toast } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { isCalendarDateString } from '@/lib/table/dates'
import { useTimezone } from '@/hooks/queries/general-settings'
import type { SaveReason } from '../../../types'
import {
cleanCellValue,
dateValueToLocalParts,
displayToStorage,
formatValueForInput,
storageToDisplay,
todayLocalCalendarDate,
} from '../../../utils'
interface InlineEditorProps {
value: unknown
column: ColumnDefinition
initialCharacter?: string
onSave: (value: unknown, reason: SaveReason) => void
onCancel: () => void
}
/** Redirect wheel gestures over an inline editor to the surrounding table scroll container. */
function handleEditorWheel(e: React.WheelEvent<HTMLInputElement>) {
e.preventDefault()
const container = e.currentTarget.closest('[data-table-scroll]') as HTMLElement | null
if (container) {
container.scrollBy(e.deltaX, e.deltaY)
}
}
/**
* Inline editor for `date` columns — text input + popover with a calendar and
* a time field. Picking a day on a date-only value commits immediately (the
* pick fully determines the value); when the value carries a time, picker
* edits update the draft in place — the day pick keeps the time-of-day
* (including seconds), the time field keeps the day — and Enter/blur commits.
*/
function InlineDateEditor({
value,
column,
initialCharacter,
onSave,
onCancel,
}: InlineEditorProps) {
const inputRef = useRef<HTMLInputElement>(null)
const popoverRef = useRef<HTMLDivElement>(null)
const doneRef = useRef(false)
const blurTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
/** Timestamp of the last pointerdown inside the popover — blur-save skips
* and refocuses while a popover interaction is in flight (covers browsers
* where buttons don't take focus on click). */
const popoverPointerAtRef = useRef(0)
const timeZone = useTimezone()
const storedValue = formatValueForInput(value, column.type)
const initialDraft =
initialCharacter !== undefined
? initialCharacter
: storageToDisplay(storedValue, { seconds: true })
const [draft, setDraft] = useState(initialDraft)
const [invalid, setInvalid] = useState(false)
/** Picker commits mutate the draft from timeouts/child handlers; reading it
* through a ref keeps the scheduled blur-save from saving a stale draft. */
const draftRef = useRef(draft)
draftRef.current = draft
/** The calendar works on wall times; feed it the draft's literal wall
* representation. */
const draftParts = dateValueToLocalParts(displayToStorage(draft, timeZone) ?? storedValue)
const pickerValue = draftParts.day
? draftParts.time
? `${draftParts.day}T${draftParts.time}`
: draftParts.day
: undefined
useEffect(() => {
const input = inputRef.current
if (!input) return
input.focus()
if (initialCharacter !== undefined) {
const len = input.value.length
input.setSelectionRange(len, len)
} else {
input.select()
}
}, [])
useEffect(() => () => clearTimeout(blurTimeoutRef.current), [])
const doSave = useCallback(
(reason: SaveReason, storageVal?: string) => {
if (doneRef.current) return
clearTimeout(blurTimeoutRef.current)
const current = draftRef.current
// Untouched draft → re-save the stored value byte-identical. Re-parsing
// the display form would re-stamp the offset with THIS viewer's zone,
// silently shifting the instant of a value someone else wrote.
if (storageVal === undefined && initialCharacter === undefined && current === initialDraft) {
doneRef.current = true
onSave(storedValue || null, reason)
return
}
const raw = storageVal ?? displayToStorage(current, timeZone) ?? current
if (raw && Number.isNaN(Date.parse(raw))) {
if (reason === 'blur') {
if (!invalid) toast.error('Invalid date')
doneRef.current = true
onCancel()
} else {
toast.error('Invalid date')
setInvalid(true)
inputRef.current?.focus()
}
return
}
doneRef.current = true
onSave(raw || null, reason)
},
[invalid, onSave, onCancel, timeZone, initialDraft, initialCharacter, storedValue]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
doSave('enter')
} else if (e.key === 'Tab') {
e.preventDefault()
doSave(e.shiftKey ? 'shift-tab' : 'tab')
} else if (e.key === 'Escape') {
e.preventDefault()
doneRef.current = true
clearTimeout(blurTimeoutRef.current)
onCancel()
}
},
[doSave, onCancel]
)
const handlePopoverPointerDown = useCallback(() => {
popoverPointerAtRef.current = Date.now()
}, [])
/** Saves on blur unless focus (or an in-flight pointer interaction) is still
* inside the editor's input/popover system. */
const scheduleBlurSave = useCallback(() => {
clearTimeout(blurTimeoutRef.current)
blurTimeoutRef.current = setTimeout(() => {
const active = document.activeElement
if (active && (active === inputRef.current || popoverRef.current?.contains(active))) return
if (Date.now() - popoverPointerAtRef.current < 300) {
inputRef.current?.focus()
return
}
doSave('blur')
}, 200)
}, [doSave])
/**
* The calendar (with `showTime`) owns the day/time merge and emits either a
* bare `YYYY-MM-DD` (no time — the pick fully determines the value, commit
* immediately) or a local `YYYY-MM-DDTHH:mm[:ss]` wall time (update the
* draft and keep editing).
*/
const handlePickerChange = useCallback(
(picked: string) => {
clearTimeout(blurTimeoutRef.current)
if (isCalendarDateString(picked)) {
doSave('enter', picked)
return
}
const canonical = displayToStorage(picked, timeZone)
if (!canonical) return
setDraft(storageToDisplay(canonical, { seconds: true }))
setInvalid(false)
inputRef.current?.focus()
},
[doSave, timeZone]
)
const handlePickerOpenChange = useCallback((open: boolean) => {
if (!open && !doneRef.current) {
clearTimeout(blurTimeoutRef.current)
inputRef.current?.focus()
}
}, [])
return (
<>
<input
ref={inputRef}
type='text'
value={draft}
onChange={(e) => {
setDraft(e.target.value)
setInvalid(false)
}}
onKeyDown={handleKeyDown}
onBlur={scheduleBlurSave}
placeholder='mm/dd/yyyy'
className={cn(
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
invalid && 'text-[var(--text-error)]'
)}
/>
<Popover open onOpenChange={handlePickerOpenChange}>
<PopoverAnchor className='absolute top-full left-0 size-0' />
<PopoverContent
ref={popoverRef}
align='start'
sideOffset={4}
className='w-auto p-0'
onPointerDownCapture={handlePopoverPointerDown}
onBlurCapture={scheduleBlurSave}
>
<Calendar
value={pickerValue}
onChange={handlePickerChange}
showTime
today={todayLocalCalendarDate(timeZone)}
/>
</PopoverContent>
</Popover>
</>
)
}
/** Inline editor for `string`/`number`/`json` columns — single-line text input. Number columns use `type="number"` so the browser rejects non-numeric input. */
function InlineTextEditor({
value,
column,
initialCharacter,
onSave,
onCancel,
}: InlineEditorProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [draft, setDraft] = useState(() =>
initialCharacter !== undefined ? initialCharacter : formatValueForInput(value, column.type)
)
const [invalid, setInvalid] = useState(false)
const doneRef = useRef(false)
useEffect(() => {
const input = inputRef.current
if (!input) return
input.focus()
if (initialCharacter !== undefined) {
const len = input.value.length
input.setSelectionRange(len, len)
} else {
input.select()
}
}, [])
const rejectDraft = (message: string, reason: SaveReason) => {
if (reason === 'blur') {
if (!invalid) toast.error(message)
doneRef.current = true
onCancel()
} else {
toast.error(message)
setInvalid(true)
inputRef.current?.focus()
}
}
const doSave = (reason: SaveReason) => {
if (doneRef.current) return
let cleaned: unknown
try {
cleaned = cleanCellValue(draft, column)
} catch {
rejectDraft('Invalid JSON', reason)
return
}
if (column.type === 'number' && cleaned === null && draft.trim() !== '') {
rejectDraft('Invalid number', reason)
return
}
doneRef.current = true
onSave(cleaned, reason)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
doSave('enter')
} else if (e.key === 'Tab') {
e.preventDefault()
doSave(e.shiftKey ? 'shift-tab' : 'tab')
} else if (e.key === 'Escape') {
e.preventDefault()
doneRef.current = true
onCancel()
}
}
const isNumber = column.type === 'number'
return (
<input
ref={inputRef}
type='text'
inputMode={isNumber ? 'decimal' : undefined}
value={draft ?? ''}
onChange={(e) => {
setDraft(e.target.value)
setInvalid(false)
}}
onKeyDown={handleKeyDown}
onWheel={handleEditorWheel}
onBlur={() => doSave('blur')}
className={cn(
'w-full min-w-0 select-text border-none bg-transparent p-0 text-[var(--text-primary)] text-small outline-none',
invalid && 'text-[var(--text-error)]'
)}
/>
)
}
/** Dispatches to the right editor variant based on the column type. */
export function InlineEditor(props: InlineEditorProps) {
if (props.column.type === 'date') {
return <InlineDateEditor {...props} />
}
return <InlineTextEditor {...props} />
}
@@ -0,0 +1,103 @@
'use client'
import { useMemo } from 'react'
import { cn } from '@sim/emcn'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import type { ChatMessageContext } from '@/app/workspace/[workspaceId]/home/types'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
/** Sim resource kinds a table cell URL can point to within the current workspace. */
export type SimResourceType = 'workflow' | 'table' | 'knowledge' | 'file'
const FALLBACK_LABEL: Record<SimResourceType, string> = {
workflow: 'Workflow',
table: 'Table',
knowledge: 'Knowledge base',
file: 'File',
}
interface SimResourceCellProps {
/** Always the current workspace — the resolver only emits this kind for same-workspace URLs. */
workspaceId: string
resourceType: SimResourceType
resourceId: string
/** In-app pathname the resource link navigates to. */
href: string
isEditing: boolean
}
/**
* Renders a cell whose value is a URL pointing to a sim resource in the current
* workspace as a tagged-resource chip — the same icon used for @-style resource
* mentions, plus the resource's name as a link.
* Only the list matching `resourceType` is fetched; the other queries stay
* disabled so a sim-resource cell subscribes to a single shared list.
*/
export function SimResourceCell({
workspaceId,
resourceType,
resourceId,
href,
isEditing,
}: SimResourceCellProps) {
const { data: workflows = [] } = useWorkflows(
resourceType === 'workflow' ? workspaceId : undefined
)
const { data: tables = [] } = useTablesList(resourceType === 'table' ? workspaceId : undefined)
const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
enabled: resourceType === 'knowledge',
})
const { data: files = [] } = useWorkspaceFiles(workspaceId, 'active', {
enabled: resourceType === 'file',
})
const workflow =
resourceType === 'workflow' ? workflows.find((w) => w.id === resourceId) : undefined
const name = useMemo(() => {
switch (resourceType) {
case 'workflow':
return workflow?.name
case 'table':
return tables.find((t) => t.id === resourceId)?.name
case 'knowledge':
return knowledgeBases.find((kb) => kb.id === resourceId)?.name
case 'file':
return files.find((f) => f.id === resourceId)?.name
}
}, [resourceType, resourceId, workflow, tables, knowledgeBases, files])
const label = name ?? FALLBACK_LABEL[resourceType]
const context: ChatMessageContext =
resourceType === 'workflow'
? { kind: 'workflow', label, workflowId: resourceId }
: resourceType === 'table'
? { kind: 'table', label, tableId: resourceId }
: resourceType === 'knowledge'
? { kind: 'knowledge', label, knowledgeId: resourceId }
: { kind: 'file', label, fileId: resourceId }
return (
<span className={cn('flex min-w-0 items-center gap-1.5', isEditing && 'invisible')}>
<ContextMentionIcon
context={context}
className='size-[14px] shrink-0 text-[var(--text-icon)]'
/>
<a
href={href}
className={cn(
'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70',
isEditing && 'pointer-events-none'
)}
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => e.stopPropagation()}
>
{label}
</a>
</span>
)
}
@@ -0,0 +1,25 @@
/** Tailwind class applied to selected rows / columns / cells. */
export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]'
/** Default column width in pixels. Used as a fallback when a column hasn't
* been measured yet and as the initial width for newly-added columns. */
export const COL_WIDTH = 160
/** Width of the "add column" placeholder column in pixels. */
export const ADD_COL_WIDTH = 120
/** Column config sidebar width in pixels — drives both the sidebar's own width
* and the table's reserved padding-right while a sidebar is open. */
export const COLUMN_SIDEBAR_WIDTH = 400
export const CELL =
'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none'
export const CELL_CHECKBOX =
'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-0 py-[7px] align-middle select-none'
export const CELL_HEADER_CHECKBOX =
'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-0 py-[7px] align-middle'
/** Fixed height (not min-) so a Badge-rendered status pill doesn't make the row grow vs a plain-text neighbor. */
export const CELL_CONTENT =
'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small'
export const SELECTION_OVERLAY =
'pointer-events-none absolute -top-px -right-px -bottom-px z-[5] border-[2px] border-[var(--selection)]'
@@ -0,0 +1,400 @@
'use client'
import React from 'react'
import { Button, Checkbox, cn, handleKeyboardActivation } from '@sim/emcn'
import { PlayOutline, Square } from '@sim/emcn/icons'
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
import { getUnmetGroupDeps } from '@/lib/table/deps'
import type { SaveReason } from '../../types'
import { CellContent } from './cells'
import {
CELL,
CELL_CHECKBOX,
CELL_CONTENT,
SELECTION_OVERLAY,
SELECTION_TINT_BG,
} from './constants'
import type { DisplayColumn } from './types'
import { type NormalizedSelection, resolveCellExec } from './utils'
export interface DataRowProps {
row: TableRowType
columns: DisplayColumn[]
/** Current workspace id — forwarded to cells so in-workspace resource URLs
* render as tagged-resource chips. */
workspaceId: string
rowIndex: number
isFirstRow: boolean
editingColumnName: string | null
initialCharacter: string | null
pendingCellValue: Record<string, unknown> | null
normalizedSelection: NormalizedSelection | null
onClick: (rowId: string, columnName: string, options?: { toggleBoolean?: boolean }) => void
onDoubleClick: (rowId: string, columnName: string, columnKey: string) => void
onSave: (rowId: string, columnName: string, value: unknown, reason: SaveReason) => void
onCancel: () => void
onContextMenu: (e: React.MouseEvent, row: TableRowType) => void
onCellMouseDown: (rowIndex: number, colIndex: number, shiftKey: boolean) => void
onCellMouseEnter: (rowIndex: number, colIndex: number) => void
isRowChecked: boolean
/** Keyboard (space/enter) toggle of the row checkbox. */
onRowToggle: (rowIndex: number, shiftKey: boolean) => void
/** Pointer-down on the gutter — toggles the row and arms gutter drag-select. */
onRowMouseDown: (rowIndex: number, shiftKey: boolean) => void
/** Pointer entering the gutter cell — extends an in-progress gutter drag. */
onRowMouseEnter: (rowIndex: number) => void
/** Number of workflow cells in this row currently in a running/queued state. */
runningCount: number
/** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */
hasWorkflowColumns: boolean
/** Width of the centered row-number/checkbox region in px, derived from the table's row-count digit count. */
numRegionWidth: number
onStopRow: (rowId: string) => void
onRunRow: (rowId: string) => void
/**
* The table's workflow groups, used to compute per-row "Waiting on …" labels
* for empty workflow-output cells whose group has unmet dependencies.
*/
workflowGroups: WorkflowGroup[]
/**
* Active dispatches on the table — rows in scope ahead of the dispatcher's
* cursor render as `Queued` until the dispatcher pre-stamps them. Preserves
* queued indicators across page refresh during long Run-all dispatches.
*/
activeDispatches: ActiveDispatch[] | undefined
/** Pixel `left` value for each pinned column key; absent keys are not pinned. */
pinnedOffsets?: Map<string, number>
/** Key of the rightmost pinned column, used to render a separator shadow. */
lastPinnedColKey?: string | null
}
function cellRangeRowChanged(
rowIndex: number,
colCount: number,
prev: NormalizedSelection | null,
next: NormalizedSelection | null
): boolean {
const pIn = prev !== null && rowIndex >= prev.startRow && rowIndex <= prev.endRow
const nIn = next !== null && rowIndex >= next.startRow && rowIndex <= next.endRow
const pAnchor = prev !== null && rowIndex === prev.anchorRow
const nAnchor = next !== null && rowIndex === next.anchorRow
if (!pIn && !nIn && !pAnchor && !nAnchor) return false
if (pIn !== nIn || pAnchor !== nAnchor) return true
if (pIn && nIn) {
if (prev!.startCol !== next!.startCol || prev!.endCol !== next!.endCol) return true
if ((rowIndex === prev!.startRow) !== (rowIndex === next!.startRow)) return true
if ((rowIndex === prev!.endRow) !== (rowIndex === next!.endRow)) return true
const pMulti = prev!.startRow !== prev!.endRow || prev!.startCol !== prev!.endCol
const nMulti = next!.startRow !== next!.endRow || next!.startCol !== next!.endCol
if (pMulti !== nMulti) return true
const pFull = prev!.startCol === 0 && prev!.endCol === colCount - 1
const nFull = next!.startCol === 0 && next!.endCol === colCount - 1
if (pFull !== nFull) return true
}
if (pAnchor && nAnchor && prev!.anchorCol !== next!.anchorCol) return true
return false
}
function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean {
if (
prev.row !== next.row ||
prev.columns !== next.columns ||
prev.workspaceId !== next.workspaceId ||
prev.rowIndex !== next.rowIndex ||
prev.isFirstRow !== next.isFirstRow ||
prev.editingColumnName !== next.editingColumnName ||
prev.pendingCellValue !== next.pendingCellValue ||
prev.onClick !== next.onClick ||
prev.onDoubleClick !== next.onDoubleClick ||
prev.onSave !== next.onSave ||
prev.onCancel !== next.onCancel ||
prev.onContextMenu !== next.onContextMenu ||
prev.onCellMouseDown !== next.onCellMouseDown ||
prev.onCellMouseEnter !== next.onCellMouseEnter ||
prev.isRowChecked !== next.isRowChecked ||
prev.onRowToggle !== next.onRowToggle ||
prev.onRowMouseDown !== next.onRowMouseDown ||
prev.onRowMouseEnter !== next.onRowMouseEnter ||
prev.runningCount !== next.runningCount ||
prev.hasWorkflowColumns !== next.hasWorkflowColumns ||
prev.numRegionWidth !== next.numRegionWidth ||
prev.onStopRow !== next.onStopRow ||
prev.onRunRow !== next.onRunRow ||
prev.workflowGroups !== next.workflowGroups ||
prev.activeDispatches !== next.activeDispatches ||
prev.pinnedOffsets !== next.pinnedOffsets ||
prev.lastPinnedColKey !== next.lastPinnedColKey
) {
return false
}
if (
(prev.editingColumnName !== null || next.editingColumnName !== null) &&
prev.initialCharacter !== next.initialCharacter
) {
return false
}
return !cellRangeRowChanged(
prev.rowIndex,
prev.columns.length,
prev.normalizedSelection,
next.normalizedSelection
)
}
export const DataRow = React.memo(function DataRow({
row,
columns,
workspaceId,
rowIndex,
isFirstRow,
editingColumnName,
initialCharacter,
pendingCellValue,
normalizedSelection,
isRowChecked,
onClick,
onDoubleClick,
onSave,
onCancel,
onContextMenu,
onCellMouseDown,
onCellMouseEnter,
onRowToggle,
onRowMouseDown,
onRowMouseEnter,
runningCount,
hasWorkflowColumns,
numRegionWidth,
onStopRow,
onRunRow,
workflowGroups,
activeDispatches,
pinnedOffsets,
lastPinnedColKey,
}: DataRowProps) {
const sel = normalizedSelection
/**
* Per-row "Waiting on …" labels keyed by group id. A group has labels iff
* at least one of its dependencies is unmet for this row — drives the
* "Waiting" pill rendered by `CellContent` for empty workflow-output cells.
* Computed once per render rather than per cell so all cells in a group
* share the same array reference.
*/
const waitingByGroupId = React.useMemo(() => {
if (workflowGroups.length === 0) return null
// Deps are stored as column ids; the "Waiting on …" pill shows display names.
const nameByColumnId = new Map(columns.map((c) => [c.key, c.name]))
const map = new Map<string, string[]>()
for (const group of workflowGroups) {
// autoRun=false groups never fire from the scheduler — there's nothing
// to wait on. The cell stays empty until the user clicks Run manually.
if (group.autoRun === false) continue
const unmet = getUnmetGroupDeps(group, row)
if (unmet.columns.length === 0) continue
map.set(
group.id,
unmet.columns.map((id) => nameByColumnId.get(id) ?? id)
)
}
return map
}, [workflowGroups, row, columns])
const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol)
const isRowSelected = isRowChecked
/**
* Whether the selection's left edge sits at column 0 for this row. The blue
* edge is drawn inside the sticky checkbox cell — over its gray right
* border — rather than as the col-0 overlay's `border-l`, so the sticky
* cell can never paint over it and the gray/blue lines never double up at
* the column boundary. The strip overlaps the row gridlines (`-top-px` /
* `-bottom-px`) so consecutive selected rows form one continuous line.
*/
const rowInRange = sel !== null && rowIndex >= sel.startRow && rowIndex <= sel.endRow
const isLeftEdgeSelected = isRowChecked || (isMultiCell && rowInRange && sel!.startCol === 0)
return (
<tr onContextMenu={(e) => onContextMenu(e, row)}>
<td
className={cn(CELL_CHECKBOX, 'cursor-pointer')}
onMouseEnter={() => onRowMouseEnter(rowIndex)}
>
{isLeftEdgeSelected && (
<div
className={cn(
'-right-px -bottom-px pointer-events-none absolute w-px bg-[var(--selection)]',
isFirstRow ? 'top-0' : '-top-px'
)}
/>
)}
<div className={cn('flex items-center justify-start', hasWorkflowColumns && 'gap-1.5')}>
<div
role='checkbox'
tabIndex={0}
aria-checked={isRowSelected}
aria-label={`Select row ${rowIndex + 1}`}
className='group/checkbox flex h-[20px] shrink-0 items-center justify-center'
style={{ width: numRegionWidth }}
onMouseDown={(e) => {
if (e.button !== 0) return
onRowMouseDown(rowIndex, e.shiftKey)
}}
onKeyDown={(event) =>
handleKeyboardActivation(event, () => onRowToggle(rowIndex, event.shiftKey))
}
>
<span
className={cn(
'text-[var(--text-tertiary)] text-xs tabular-nums',
isRowSelected ? 'hidden' : 'block group-hover/checkbox:hidden'
)}
>
{rowIndex + 1}
</span>
<div
className={cn(
'items-center justify-center',
isRowSelected ? 'flex' : 'hidden group-hover/checkbox:flex'
)}
>
<Checkbox size='sm' checked={isRowSelected} className='pointer-events-none' />
</div>
</div>
{hasWorkflowColumns && (
<Button
type='button'
variant='ghost'
size='sm'
aria-label={runningCount > 0 ? `Stop ${runningCount} running` : 'Run row'}
title={runningCount > 0 ? `Stop ${runningCount} running` : 'Run row'}
className='size-[20px] shrink-0 p-0 text-[var(--text-primary)] hover-hover:bg-[var(--surface-2)]'
onClick={() => {
if (runningCount > 0) {
onStopRow(row.id)
} else {
onRunRow(row.id)
}
}}
>
{runningCount > 0 ? (
<Square className='size-[12px]' />
) : (
<PlayOutline className='size-[12px]' />
)}
</Button>
)}
</div>
</td>
{columns.map((column, colIndex) => {
const inRange =
sel !== null &&
rowIndex >= sel.startRow &&
rowIndex <= sel.endRow &&
colIndex >= sel.startCol &&
colIndex <= sel.endCol
const isAnchor = sel !== null && rowIndex === sel.anchorRow && colIndex === sel.anchorCol
const isEditing = editingColumnName === column.key
const isHighlighted = inRange || isRowChecked
const isTopEdge = inRange ? rowIndex === sel!.startRow : isRowChecked
const isBottomEdge = inRange ? rowIndex === sel!.endRow : isRowChecked
const isLeftEdge = inRange ? colIndex === sel!.startCol : colIndex === 0
const isRightEdge = inRange ? colIndex === sel!.endCol : colIndex === columns.length - 1
const pinnedLeft = pinnedOffsets?.get(column.key)
const isPinnedCell = pinnedLeft !== undefined
const isPinnedSeparator = column.key === lastPinnedColKey
return (
<td
key={column.key}
data-row={rowIndex}
data-row-id={row.id}
data-col={colIndex}
className={cn(
CELL,
(isHighlighted || isAnchor || isEditing) && 'relative',
isPinnedCell && 'z-[6] bg-[var(--bg)]',
isPinnedSeparator && '[box-shadow:2px_0_0_0_var(--border)]'
)}
style={isPinnedCell ? { position: 'sticky', left: pinnedLeft } : undefined}
onMouseDown={(e) => {
if (e.button !== 0 || isEditing) return
onCellMouseDown(rowIndex, colIndex, e.shiftKey)
}}
onMouseEnter={() => onCellMouseEnter(rowIndex, colIndex)}
onClick={(e) =>
onClick(row.id, column.key, {
toggleBoolean:
!e.shiftKey &&
Boolean((e.target as HTMLElement).closest('[data-boolean-cell-toggle]')),
})
}
onDoubleClick={() => onDoubleClick(row.id, column.key, column.key)}
>
{isHighlighted && (isMultiCell || isRowChecked) && (
<div
className={cn(
'-top-px -right-px -bottom-px pointer-events-none absolute z-[4]',
colIndex === 0 ? 'left-0' : '-left-px',
SELECTION_TINT_BG,
isFirstRow && isTopEdge && 'top-0',
isTopEdge && 'border-t border-t-[var(--selection)]',
isBottomEdge && 'border-b border-b-[var(--selection)]',
isLeftEdge && colIndex !== 0 && 'border-l border-l-[var(--selection)]',
isRightEdge && 'border-r border-r-[var(--selection)]'
)}
/>
)}
{isAnchor && (
<div
className={cn(
SELECTION_OVERLAY,
colIndex === 0 ? 'left-0' : '-left-px',
isFirstRow && 'top-0'
)}
/>
)}
<div className={CELL_CONTENT}>
<CellContent
workspaceId={workspaceId}
value={
pendingCellValue && column.key in pendingCellValue
? pendingCellValue[column.key]
: row.data[column.key]
}
exec={resolveCellExec(
row,
column.workflowGroupId
? workflowGroups.find((g) => g.id === column.workflowGroupId)
: undefined,
activeDispatches
)}
column={column}
isEditing={isEditing}
initialCharacter={isEditing ? initialCharacter : undefined}
onSave={(value, reason) => onSave(row.id, column.key, value, reason)}
onCancel={onCancel}
waitingOnLabels={
column.workflowGroupId
? (waitingByGroupId?.get(column.workflowGroupId) ?? undefined)
: undefined
}
isEnrichmentOutput={
column.workflowGroupId
? workflowGroups.find((g) => g.id === column.workflowGroupId)?.type ===
'enrichment'
: false
}
/>
</div>
</td>
)
})}
</tr>
)
}, dataRowPropsAreEqual)
@@ -0,0 +1,360 @@
'use client'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { cn } from '@sim/emcn'
import { ChevronDown } from '@sim/emcn/icons'
import type { WorkflowGroup } from '@/lib/table'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { COL_WIDTH, SELECTION_TINT_BG } from '../constants'
import type { ColumnSourceInfo, DisplayColumn } from '../types'
import { ColumnTypeIcon } from './column-type-icon'
import { ColumnOptionsMenu } from './workflow-group-meta-cell'
interface ColumnHeaderMenuProps {
column: DisplayColumn
colIndex: number
readOnly?: boolean
isRenaming: boolean
isColumnSelected: boolean
renameValue: string
onRenameValueChange: (value: string) => void
onRenameSubmit: () => void
onRenameCancel: () => void
onColumnSelect: (colIndex: number, shiftKey: boolean) => void
onInsertLeft: (columnName: string) => void
onInsertRight: (columnName: string) => void
onDeleteColumn: (columnName: string) => void
onResizeStart: (columnKey: string) => void
onResize: (columnKey: string, width: number) => void
onResizeEnd: () => void
onAutoResize: (columnKey: string) => void
onDragStart?: (columnName: string) => void
onDragOver?: (columnName: string, side: 'left' | 'right') => void
onDragEnd?: () => void
onDragLeave?: () => void
workflows?: WorkflowMetadata[]
workflowGroups?: WorkflowGroup[]
/** Source-info entry for workflow-output columns; supplies the producing
* block's icon component. The block's color is intentionally not used. */
sourceInfo?: ColumnSourceInfo
onOpenConfig: (columnName: string) => void
/** Opens a popup preview of the column's underlying workflow. Surfaced in
* the chevron menu for workflow-output columns. */
onViewWorkflow?: (workflowId: string) => void
/** Whether this column is currently pinned to the left. */
isPinned?: boolean
/** Toggle the pinned state for this column. */
onPinToggle?: (columnName: string) => void
/** Left offset in pixels when pinned (drives `position: sticky`). */
stickyLeft?: number
/** Whether this is the rightmost pinned column (renders a separator shadow). */
isLastPinned?: boolean
}
/**
* One column's header cell: rename / chevron menu / drag-handle / resize-grip.
* Handles its own pointer-capture for drag and resize because both interact
* with sibling DOM elements outside this th's natural bubbling path.
*/
export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
column,
colIndex,
readOnly,
isRenaming,
isColumnSelected,
renameValue,
onRenameValueChange,
onRenameSubmit,
onRenameCancel,
onColumnSelect,
onInsertLeft,
onInsertRight,
onDeleteColumn,
onResizeStart,
onResize,
onResizeEnd,
onAutoResize,
onDragStart,
onDragOver,
onDragEnd,
onDragLeave,
workflows,
workflowGroups,
sourceInfo,
onOpenConfig,
onViewWorkflow,
isPinned,
onPinToggle,
stickyLeft,
isLastPinned,
}: ColumnHeaderMenuProps) {
const renameInputRef = useRef<HTMLInputElement>(null)
const didDragRef = useRef(false)
const [menuOpen, setMenuOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 })
const ownGroup =
column.workflowGroupId && workflowGroups
? workflowGroups.find((g) => g.id === column.workflowGroupId)
: undefined
const configuredWorkflow = ownGroup
? workflows?.find((w) => w.id === ownGroup.workflowId)
: undefined
// Workflow-output column with siblings → "Hide column" (non-destructive,
// re-addable from sidebar). Last output of a group → "Delete column"
// (removes the entire group). Plain column → undefined (default "Delete column").
const deleteLabel = ownGroup
? ownGroup.outputs.length > 1
? 'Hide column'
: 'Delete column'
: undefined
useEffect(() => {
if (isRenaming && renameInputRef.current) {
renameInputRef.current.focus()
renameInputRef.current.select()
}
}, [isRenaming])
const handleResizePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault()
e.stopPropagation()
const startX = e.clientX
const th = (e.currentTarget as HTMLElement).closest('th')
const startWidth = th ? th.getBoundingClientRect().width : COL_WIDTH
const target = e.currentTarget as HTMLElement
target.setPointerCapture(e.pointerId)
onResizeStart(column.key)
const handlePointerMove = (ev: PointerEvent) => {
onResize(column.key, startWidth + (ev.clientX - startX))
}
const cleanup = () => {
target.removeEventListener('pointermove', handlePointerMove)
target.removeEventListener('pointerup', cleanup)
target.removeEventListener('pointercancel', cleanup)
onResizeEnd()
}
target.addEventListener('pointermove', handlePointerMove)
target.addEventListener('pointerup', cleanup)
target.addEventListener('pointercancel', cleanup)
},
[column.key, onResizeStart, onResize, onResizeEnd]
)
const handleDragStart = useCallback(
(e: React.DragEvent) => {
if (readOnly || isRenaming) {
e.preventDefault()
return
}
didDragRef.current = true
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', column.key)
// Workflow-output columns drag as a whole group, so the ghost shows
// the group's name (falling back to the workflow's name, then the
// column slug) rather than the individual column slug.
const ghostLabel = ownGroup?.name ?? configuredWorkflow?.name ?? column.name
const ghost = document.createElement('div')
ghost.textContent = ghostLabel
ghost.style.cssText =
'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)'
document.body.appendChild(ghost)
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost))
onDragStart?.(column.key)
},
[column.key, column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart]
)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midX = rect.left + rect.width / 2
const side = e.clientX < midX ? 'left' : 'right'
onDragOver?.(column.key, side)
},
[column.key, onDragOver]
)
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
}, [])
const handleDragEnd = useCallback(() => {
didDragRef.current = false
onDragEnd?.()
}, [onDragEnd])
const handleDragLeave = useCallback(
(e: React.DragEvent) => {
const th = e.currentTarget as HTMLElement
const related = e.relatedTarget as Node | null
if (related && th.contains(related)) return
// Don't clear when the cursor is moving to another column header — the
// next dragover will set the right target. Clearing here causes the
// drop indicator to flicker between sibling columns of a workflow
// group (and any adjacent column hop in general).
if (related && related instanceof Element && related.closest('th')) return
onDragLeave?.()
},
[onDragLeave]
)
function handleHeaderClick(e: React.MouseEvent) {
if (didDragRef.current) {
didDragRef.current = false
return
}
if (isRenaming) return
onColumnSelect(colIndex, e.shiftKey)
if (!e.shiftKey) {
onOpenConfig(column.key)
}
}
function handleChevronClick(e: React.MouseEvent) {
e.stopPropagation()
const rect = (e.currentTarget as HTMLElement).closest('th')?.getBoundingClientRect()
if (rect) {
setMenuPosition({ x: rect.left, y: rect.bottom })
}
setMenuOpen(true)
}
function handleContextMenu(e: React.MouseEvent) {
if (readOnly || isRenaming) return
e.preventDefault()
setMenuPosition({ x: e.clientX, y: e.clientY })
setMenuOpen(true)
}
// Column whose workflow source block was deleted — the header icon swaps to
// `WorkflowX` with an explanatory tooltip.
const blockMissing = Boolean(sourceInfo?.blockMissing)
return (
<th
className={cn(
'group relative border-[var(--border)] border-r border-b bg-[var(--bg)] p-0 text-left align-middle',
stickyLeft !== undefined && 'z-[11]',
isLastPinned && '[box-shadow:2px_0_0_0_var(--border)]'
)}
style={stickyLeft !== undefined ? { position: 'sticky', left: stickyLeft } : undefined}
draggable={!readOnly && !isRenaming}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
onDragLeave={handleDragLeave}
onContextMenu={handleContextMenu}
>
{/* Selection tint as a separate overlay so the th's opaque `--bg` stays
intact — `bg-[rgba(...)]` would otherwise replace `bg-[var(--bg)]`,
letting the sticky thead leak rows from below through it. */}
{isColumnSelected && (
<div
className={cn('pointer-events-none absolute inset-0', SELECTION_TINT_BG)}
aria-hidden='true'
/>
)}
{isRenaming ? (
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<ColumnTypeIcon
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
blockIconInfo={sourceInfo?.blockIconInfo}
blockMissing={blockMissing}
/>
<input
ref={renameInputRef}
type='text'
value={renameValue}
onChange={(e) => onRenameValueChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onRenameSubmit()
if (e.key === 'Escape') onRenameCancel()
}}
onBlur={onRenameSubmit}
className='ml-1.5 min-w-0 flex-1 border-0 bg-transparent p-0 font-medium text-[var(--text-primary)] text-small outline-none focus:outline-none focus:ring-0'
/>
</div>
) : readOnly ? (
<div className='flex h-full w-full min-w-0 items-center px-2 py-[7px]'>
<ColumnTypeIcon
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
blockIconInfo={sourceInfo?.blockIconInfo}
blockMissing={blockMissing}
/>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
{column.workflowGroupId ? column.headerLabel : column.name}
</span>
</div>
) : (
<div className='flex h-full w-full min-w-0 items-center'>
<button
type='button'
className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none'
onClick={handleHeaderClick}
draggable={false}
>
<ColumnTypeIcon
type={column.type}
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
blockIconInfo={sourceInfo?.blockIconInfo}
blockMissing={blockMissing}
/>
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
{column.workflowGroupId ? column.headerLabel : column.name}
</span>
</button>
<button
type='button'
className='flex h-full shrink-0 cursor-pointer items-center pr-2.5 pl-0.5 text-[var(--text-muted)] opacity-0 transition-opacity hover:text-[var(--text-primary)] group-hover:opacity-100'
onClick={handleChevronClick}
draggable={false}
aria-label='Column options'
>
<ChevronDown className='size-[10px] shrink-0' />
</button>
<ColumnOptionsMenu
open={menuOpen}
onOpenChange={setMenuOpen}
position={menuPosition}
column={column}
deleteLabel={deleteLabel}
onOpenConfig={onOpenConfig}
onInsertLeft={onInsertLeft}
onInsertRight={onInsertRight}
onDeleteColumn={onDeleteColumn}
onViewWorkflow={
onViewWorkflow && ownGroup ? () => onViewWorkflow(ownGroup.workflowId) : undefined
}
isPinned={isPinned}
onPinToggle={onPinToggle}
/>
</div>
)}
<div
className='-right-[3px] absolute top-0 z-[1] h-full w-[6px] cursor-col-resize'
draggable={false}
onDragStart={(e) => e.stopPropagation()}
onPointerDown={handleResizePointerDown}
onDoubleClick={(e) => {
e.preventDefault()
e.stopPropagation()
onAutoResize(column.key)
}}
/>
</th>
)
})
@@ -0,0 +1,75 @@
'use client'
import type React from 'react'
import { Tooltip } from '@sim/emcn'
import {
Calendar as CalendarIcon,
PlayOutline,
TypeBoolean,
TypeJson,
TypeNumber,
TypeText,
WorkflowX,
} from '@sim/emcn/icons'
import type { BlockIconInfo } from '../types'
export const COLUMN_TYPE_ICONS: Record<string, React.ElementType> = {
string: TypeText,
number: TypeNumber,
boolean: TypeBoolean,
date: CalendarIcon,
json: TypeJson,
}
interface ColumnTypeIconProps {
type: string
/** True for workflow-output columns; renders the producing block's icon
* (or a workflow fallback) instead of the scalar type icon. Workflow
* columns ARE stored as scalar types, so without this `type` would
* otherwise resolve to e.g. `string` and read identically to a plain
* text column. */
isWorkflowColumn?: boolean
/** Block-icon info from the source-info builder, used for workflow columns
* to surface the producing block's icon. The block's color is intentionally
* ignored — icons render in the plain `text-[var(--text-icon)]` tone like
* every other column-type icon, no per-block tint. */
blockIconInfo?: BlockIconInfo
/** Workflow-output column whose source block no longer exists in the
* workflow — renders the `WorkflowX` "not found" icon with a tooltip. */
blockMissing?: boolean
}
/**
* Tiny icon shown next to a column header. Workflow-output columns get the
* producing block's icon (falling back to `PlayOutline`); plain columns get
* their scalar type icon. Both render in the same `text-[var(--text-icon)]`
* tone — no per-workflow color, no colored swatch. A workflow column whose
* source block was deleted renders a `WorkflowX` with an explanatory tooltip.
*/
export function ColumnTypeIcon({
type,
isWorkflowColumn,
blockIconInfo,
blockMissing,
}: ColumnTypeIconProps) {
if (isWorkflowColumn) {
if (blockMissing) {
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<span className='flex shrink-0 items-center'>
<WorkflowX className='size-3 shrink-0 text-[var(--text-icon)]' />
</span>
</Tooltip.Trigger>
<Tooltip.Content side='top'>
This column's source block no longer exists in the workflow.
</Tooltip.Content>
</Tooltip.Root>
)
}
const Icon = blockIconInfo?.icon ?? PlayOutline
return <Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
}
const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText
return <Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
}
@@ -0,0 +1,3 @@
export { ColumnHeaderMenu } from './column-header-menu'
export { COLUMN_TYPE_ICONS, ColumnTypeIcon } from './column-type-icon'
export { ColumnOptionsMenu, WorkflowGroupMetaCell } from './workflow-group-meta-cell'
@@ -0,0 +1,505 @@
'use client'
import type React from 'react'
import { useRef, useState } from 'react'
import {
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@sim/emcn'
import {
ArrowLeft,
ArrowRight,
Eye,
EyeOff,
Pencil,
Pin,
PinOff,
PlayOutline,
Trash,
Workflow,
} from '@sim/emcn/icons'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import type { WorkflowGroupType } from '@/lib/table'
import { getEnrichment } from '@/enrichments/registry'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import { SELECTION_TINT_BG } from '../constants'
import type { DisplayColumn } from '../types'
/** Fixed row-cap presets for the "Run N empty rows" shortcuts. Shared by the
* group-header options menu and the inline quick-run dropdown so the two
* surfaces stay in sync. */
const LIMITED_RUN_PRESETS = [10, 1000] as const
/** Labels for the table-scoped run items. With an active filter the run is
* scoped to matching rows, so the labels say "filtered rows" to make the
* narrowed target visible. Shared by both menu surfaces. */
function runMenuLabels(hasActiveFilter: boolean) {
const rows = hasActiveFilter ? 'filtered rows' : 'rows'
return {
all: `Run all ${rows}`,
incomplete: `Run empty ${rows}`,
limited: (max: number) => `Run ${max.toLocaleString()} empty ${rows}`,
}
}
interface ColumnOptionsMenuProps {
open: boolean
onOpenChange: (open: boolean) => void
position: { x: number; y: number }
column: DisplayColumn
/** Override for the destructive item's label. Defaults to "Delete column"
* for both plain columns and workflow groups. Use "Hide column" when the
* destructive action is non-lossy (workflow-output column where removing
* it leaves the group with siblings). */
deleteLabel?: string
onOpenConfig: (columnName: string) => void
onInsertLeft: (columnName: string) => void
onInsertRight: (columnName: string) => void
onDeleteColumn: (columnName: string) => void
/** When provided (i.e. menu opened from a workflow-group meta header), the
* "Delete" item deletes the entire workflow group rather than the single
* column. Wins over `onDeleteColumn` for the destructive action. */
onDeleteGroup?: () => void
/** When provided, the menu is being opened from a workflow-group header and
* exposes group-level run actions above the column actions. */
onRunColumnAll?: () => void
onRunColumnIncomplete?: () => void
/** Runs only the first `max` empty/unrun rows. Surfaces fixed "Run N rows"
* shortcuts so users can sample a large table without firing every row. */
onRunColumnLimited?: (max: number) => void
/** When set, surfaces a "Run N selected rows" item above Run all. */
onRunColumnSelected?: () => void
selectedRowCount?: number
/** Table-scoped run items honor the active filter; when true the labels say
* "filtered rows" so the narrowed scope is visible. */
hasActiveFilter?: boolean
/** When set, the menu surfaces a "View workflow" item that opens a popup
* preview of the configured workflow. */
onViewWorkflow?: () => void
/** Whether this column is currently pinned to the left. */
isPinned?: boolean
/** Toggle the pinned state of this column. */
onPinToggle?: (columnName: string) => void
}
/**
* Shared column-options dropdown rendered next to the column header chevron
* AND on right-click of the workflow group meta cell. Anchors to a fixed
* position passed in (so callers can place it under the chevron, or at the
* cursor for context-menu use). Rename / change type / unique live in the
* column sidebar (opened by Edit column).
*/
export function ColumnOptionsMenu({
open,
onOpenChange,
position,
column,
deleteLabel,
onOpenConfig,
onInsertLeft,
onInsertRight,
onDeleteColumn,
onDeleteGroup,
onRunColumnAll,
onRunColumnIncomplete,
onRunColumnLimited,
onRunColumnSelected,
selectedRowCount = 0,
hasActiveFilter = false,
onViewWorkflow,
isPinned,
onPinToggle,
}: ColumnOptionsMenuProps) {
const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete)
const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0
const runLabels = runMenuLabels(hasActiveFilter)
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden='true'
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
className='max-h-none'
onCloseAutoFocus={(e) => e.preventDefault()}
>
{showRunActions && (
<>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<PlayOutline />
Run
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{showRunSelected && (
<DropdownMenuItem onSelect={() => onRunColumnSelected?.()}>
{`Run ${selectedRowCount} selected ${selectedRowCount === 1 ? 'row' : 'rows'}`}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onRunColumnAll?.()}>
{runLabels.all}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onRunColumnIncomplete?.()}>
{runLabels.incomplete}
</DropdownMenuItem>
{onRunColumnLimited &&
LIMITED_RUN_PRESETS.map((max) => (
<DropdownMenuItem key={max} onSelect={() => onRunColumnLimited(max)}>
{runLabels.limited(max)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
</>
)}
{onViewWorkflow && (
<DropdownMenuItem onSelect={() => onViewWorkflow()}>
<Eye />
View workflow
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onOpenConfig(column.key)}>
<Pencil />
Edit column
</DropdownMenuItem>
{onPinToggle && (
<DropdownMenuItem onSelect={() => onPinToggle(column.key)}>
{isPinned ? <PinOff /> : <Pin />}
{isPinned ? 'Unpin column' : 'Pin column'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => onInsertLeft(column.key)}>
<ArrowLeft />
Insert column left
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onInsertRight(column.key)}>
<ArrowRight />
Insert column right
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => (onDeleteGroup ? onDeleteGroup() : onDeleteColumn(column.key))}
>
{deleteLabel === 'Hide column' ? <EyeOff /> : <Trash />}
{deleteLabel ?? 'Delete column'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
interface WorkflowGroupMetaCellProps {
workflowId: string
groupId: string
/** When `'enrichment'`, the cell shows the enrichment's name + icon instead
* of a backing workflow's skeleton icon + name. */
groupType?: WorkflowGroupType
/** Registry id for enrichment groups (resolves name/icon fallback). */
enrichmentId?: string
/** Persisted group name (the enrichment name at creation). */
groupName?: string
size: number
startColIndex: number
columnName: string
/** Underlying logical column — needed for the right-click options menu. */
column?: DisplayColumn
workflows?: WorkflowMetadata[]
isGroupSelected: boolean
onSelectGroup: (startColIndex: number, size: number) => void
onOpenConfig: (columnName: string) => void
onRunColumn?: (groupId: string, mode?: RunMode, rowIds?: string[], limit?: RunLimit) => void
onInsertLeft?: (columnName: string) => void
onInsertRight?: (columnName: string) => void
onDeleteColumn?: (columnName: string) => void
/** Right-click delete on the group header drops the entire workflow group. */
onDeleteGroup?: (groupId: string) => void
/** Row ids in the user's current multi-row selection; when non-empty the
* run menu adds a "Run N selected rows" option. */
selectedRowIds?: string[] | null
/** True when the grid has an active filter — table-scoped run items apply
* only to matching rows and are labeled "filtered rows". */
hasActiveFilter?: boolean
/** Opens a popup preview of the underlying workflow. */
onViewWorkflow?: (workflowId: string) => void
/** When set, the meta cell becomes draggable and forwards events through
* the same column-reorder pipeline used by individual workflow column
* headers. The whole group moves together because downstream code groups
* fan-out siblings by `workflowGroupId`. */
onDragStart?: (columnName: string) => void
onDragOver?: (columnName: string, side: 'left' | 'right') => void
onDragEnd?: () => void
onDragLeave?: () => void
readOnly?: boolean
/** Left offset in pixels when pinned (drives `position: sticky`). */
stickyLeft?: number
/** Whether this is the rightmost pinned column group (renders a separator shadow). */
isLastPinned?: boolean
/** Whether this column group is currently pinned to the left. */
isPinned?: boolean
/** Toggle the pinned state for this column group. */
onPinToggle?: (columnName: string) => void
}
/**
* Spans a fanned-out workflow column group in the table's meta header row.
* Renders the workflow skeleton icon + name so the grouping across N sibling
* columns reads as one unit.
*/
export function WorkflowGroupMetaCell({
workflowId,
groupId,
groupType,
enrichmentId,
groupName,
size,
startColIndex,
columnName,
column,
workflows,
isGroupSelected,
onSelectGroup,
onOpenConfig,
onRunColumn,
onInsertLeft,
onInsertRight,
onDeleteColumn,
onDeleteGroup,
selectedRowIds,
hasActiveFilter = false,
onViewWorkflow,
onDragStart,
onDragOver,
onDragEnd,
onDragLeave,
readOnly,
stickyLeft,
isLastPinned,
isPinned,
onPinToggle,
}: WorkflowGroupMetaCellProps) {
const isEnrichment = groupType === 'enrichment'
const enrichment = isEnrichment ? getEnrichment(enrichmentId) : undefined
const EnrichmentIcon = enrichment?.icon
const wf = workflows?.find((w) => w.id === workflowId)
const name = isEnrichment
? (groupName ?? enrichment?.name ?? 'Enrichment')
: (wf?.name ?? 'Workflow')
const [optionsMenuOpen, setOptionsMenuOpen] = useState(false)
const [optionsMenuPosition, setOptionsMenuPosition] = useState({ x: 0, y: 0 })
const [runMenuOpen, setRunMenuOpen] = useState(false)
const didDragRef = useRef(false)
const selectedCount = selectedRowIds?.length ?? 0
const runLabels = runMenuLabels(hasActiveFilter)
function handleRunAll() {
if (groupId) onRunColumn?.(groupId, 'all')
}
function handleRunIncomplete() {
if (groupId) onRunColumn?.(groupId, 'incomplete')
}
function handleRunSelected() {
if (groupId && selectedRowIds && selectedRowIds.length > 0) {
onRunColumn?.(groupId, 'all', selectedRowIds)
}
}
function handleRunLimited(max: number) {
if (groupId) onRunColumn?.(groupId, 'incomplete', undefined, { type: 'rows', max })
}
function handleContextMenu(e: React.MouseEvent) {
if (!column) return
e.preventDefault()
e.stopPropagation()
setOptionsMenuPosition({ x: e.clientX, y: e.clientY })
setOptionsMenuOpen(true)
}
function selectGroupAndOpenConfig(e: React.MouseEvent<HTMLTableCellElement>) {
// Ignore clicks that landed on an interactive child (badge, play button,
// dropdown items rendered via portal). Only the bare meta-cell area
// should select the group + open the config sidebar.
const target = e.target as HTMLElement
if (target.closest('button, [role="menuitem"], [role="menu"]')) return
// Drag-vs-click guard: when a drag just ended on this cell, swallow the
// synthetic click so we don't accidentally pop open the sidebar.
if (didDragRef.current) {
didDragRef.current = false
return
}
onSelectGroup(startColIndex, size)
if (columnName) onOpenConfig(columnName)
}
function handleDragStart(e: React.DragEvent) {
if (readOnly || !onDragStart || !columnName) {
e.preventDefault()
return
}
didDragRef.current = true
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', columnName)
const ghost = document.createElement('div')
ghost.textContent = name
ghost.style.cssText =
'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)'
document.body.appendChild(ghost)
e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2)
requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost))
onDragStart(columnName)
}
function handleDragOver(e: React.DragEvent) {
if (!onDragOver || !columnName) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const midX = rect.left + rect.width / 2
const side = e.clientX < midX ? 'left' : 'right'
onDragOver(columnName, side)
}
function handleDragEnd() {
didDragRef.current = false
onDragEnd?.()
}
function handleDragLeave(e: React.DragEvent) {
const th = e.currentTarget as HTMLElement
const related = e.relatedTarget as Node | null
if (related && th.contains(related)) return
if (related && related instanceof Element && related.closest('th')) return
onDragLeave?.()
}
function handleDrop(e: React.DragEvent) {
e.preventDefault()
}
const isDraggable = !readOnly && Boolean(onDragStart)
return (
<th
colSpan={size}
onClick={selectGroupAndOpenConfig}
onContextMenu={handleContextMenu}
draggable={isDraggable}
onDragStart={isDraggable ? handleDragStart : undefined}
onDragOver={isDraggable ? handleDragOver : undefined}
onDragEnd={isDraggable ? handleDragEnd : undefined}
onDragLeave={isDraggable ? handleDragLeave : undefined}
onDrop={isDraggable ? handleDrop : undefined}
className={cn(
'group relative cursor-pointer border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[5px] text-left align-middle before:pointer-events-none before:absolute before:top-0 before:bottom-0 before:left-[-1px] before:w-px before:bg-[var(--border)] before:content-[""]',
stickyLeft !== undefined && 'z-[11]',
isLastPinned && '[box-shadow:2px_0_0_0_var(--border)]'
)}
style={stickyLeft !== undefined ? { position: 'sticky', left: stickyLeft } : undefined}
>
{/* Selection tint as a separate overlay so the th's opaque `--bg` stays
intact — see column-header-menu for the same fix. */}
{isGroupSelected && (
<div
className={cn('pointer-events-none absolute inset-0', SELECTION_TINT_BG)}
aria-hidden='true'
/>
)}
<div className='flex h-[18px] min-w-0 items-center gap-1.5'>
{isEnrichment && EnrichmentIcon ? (
<EnrichmentIcon className='size-[12px] shrink-0 text-[var(--text-icon)]' />
) : (
<Workflow className='size-[12px] shrink-0 text-[var(--text-icon)]' />
)}
<span className='min-w-0 truncate font-medium text-[11px] text-[var(--text-secondary)]'>
{name}
</span>
{onRunColumn && (
<DropdownMenu open={runMenuOpen} onOpenChange={setRunMenuOpen}>
<DropdownMenuTrigger asChild>
<button
type='button'
className='flex size-[16px] shrink-0 cursor-pointer items-center justify-center rounded-sm text-[var(--text-muted)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--text-primary)]'
onClick={(e) => e.stopPropagation()}
aria-label='Run group'
title='Run group'
>
<PlayOutline className='size-[10px]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{selectedCount > 0 && (
<DropdownMenuItem onSelect={handleRunSelected}>
{`Run ${selectedCount} selected ${selectedCount === 1 ? 'row' : 'rows'}`}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={handleRunAll}>{runLabels.all}</DropdownMenuItem>
<DropdownMenuItem onSelect={handleRunIncomplete}>
{runLabels.incomplete}
</DropdownMenuItem>
{LIMITED_RUN_PRESETS.map((max) => (
<DropdownMenuItem key={max} onSelect={() => handleRunLimited(max)}>
{runLabels.limited(max)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{column && onInsertLeft && onInsertRight && onDeleteColumn && (
<ColumnOptionsMenu
open={optionsMenuOpen}
onOpenChange={setOptionsMenuOpen}
position={optionsMenuPosition}
column={column}
onOpenConfig={onOpenConfig}
onInsertLeft={onInsertLeft}
onInsertRight={onInsertRight}
onDeleteColumn={onDeleteColumn}
onDeleteGroup={onDeleteGroup ? () => onDeleteGroup(groupId) : undefined}
onRunColumnAll={onRunColumn ? handleRunAll : undefined}
onRunColumnIncomplete={onRunColumn ? handleRunIncomplete : undefined}
onRunColumnLimited={onRunColumn ? handleRunLimited : undefined}
onRunColumnSelected={onRunColumn && selectedCount > 0 ? handleRunSelected : undefined}
selectedRowCount={selectedCount}
hasActiveFilter={hasActiveFilter}
onViewWorkflow={onViewWorkflow ? () => onViewWorkflow(workflowId) : undefined}
isPinned={isPinned}
onPinToggle={onPinToggle}
/>
)}
</th>
)
}
@@ -0,0 +1 @@
export { type SelectionSnapshot, TableGrid } from './table-grid'
@@ -0,0 +1,108 @@
'use client'
import type React from 'react'
import { Button, ChipInput } from '@sim/emcn'
import { Loader, X } from '@sim/emcn/icons'
import { ChevronDown, ChevronUp } from 'lucide-react'
export interface TableFindProps {
query: string
onQueryChange: (query: string) => void
/** Run the search (dirty Enter / search button). */
onSubmit: () => void
onNext: () => void
onPrev: () => void
onClose: () => void
/** Number of matches after dropping columns not in the current view. */
count: number
/** 0-based index of the active match, or -1 when there are none. */
currentIndex: number
/** Whether the server capped the match set. */
truncated: boolean
isLoading: boolean
/** Whether the input differs from the last submitted term. */
isDirty: boolean
inputRef: React.RefObject<HTMLInputElement | null>
}
export function TableFind({
query,
onQueryChange,
onSubmit,
onNext,
onPrev,
onClose,
count,
currentIndex,
truncated,
isLoading,
isDirty,
inputRef,
}: TableFindProps) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
onPrev()
} else if (isDirty) {
onSubmit()
} else {
onNext()
}
return
}
if (e.key === 'Escape') {
e.preventDefault()
onClose()
}
}
const hasMatches = count > 0
const label =
count === 0 ? 'No results' : `${currentIndex + 1} of ${count}${truncated ? '+' : ''}`
return (
<div className='absolute top-2 right-2 z-[20] flex items-center gap-1.5 rounded-lg border border-[var(--border)] bg-[var(--surface-1)] p-1 shadow-medium'>
<ChipInput
ref={inputRef}
value={query}
placeholder='Search'
className='w-[200px]'
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
<span className='flex min-w-[64px] shrink-0 items-center justify-end whitespace-nowrap px-1 text-[var(--text-muted)] text-xs tabular-nums'>
{isLoading ? <Loader className='size-[12px] animate-spin' /> : label}
</span>
<Button
variant='ghost'
className='size-8 shrink-0 p-0'
aria-label='Previous match'
title='Previous match (Shift+Enter)'
disabled={!hasMatches}
onClick={onPrev}
>
<ChevronUp className='size-[14px] text-[var(--text-icon)]' />
</Button>
<Button
variant='ghost'
className='size-8 shrink-0 p-0'
aria-label='Next match'
title='Next match (Enter)'
disabled={!hasMatches}
onClick={onNext}
>
<ChevronDown className='size-[14px] text-[var(--text-icon)]' />
</Button>
<Button
variant='ghost'
className='size-8 shrink-0 p-0'
aria-label='Close find'
title='Close (Esc)'
onClick={onClose}
>
<X className='size-[14px] text-[var(--text-icon)]' />
</Button>
</div>
)
}
@@ -0,0 +1,76 @@
'use client'
import React from 'react'
import { Button, Checkbox, cn } from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
import { ADD_COL_WIDTH, CELL_HEADER_CHECKBOX, COL_WIDTH } from './constants'
import type { DisplayColumn } from './types'
export const TableColGroup = React.memo(function TableColGroup({
columns,
columnWidths,
checkboxColWidth,
}: {
columns: DisplayColumn[]
columnWidths: Record<string, number>
checkboxColWidth: number
}) {
return (
<colgroup>
<col style={{ width: checkboxColWidth }} />
{columns.map((col) => (
<col key={col.key} style={{ width: columnWidths[col.key] ?? COL_WIDTH }} />
))}
<col style={{ width: ADD_COL_WIDTH }} />
</colgroup>
)
})
export const SelectAllCheckbox = React.memo(function SelectAllCheckbox({
checked,
onCheckedChange,
numRegionWidth,
}: {
checked: boolean | 'indeterminate'
onCheckedChange: () => void
numRegionWidth: number
}) {
return (
<th
className={cn(CELL_HEADER_CHECKBOX, 'cursor-pointer')}
role='checkbox'
aria-checked={checked === 'indeterminate' ? 'mixed' : checked}
tabIndex={0}
onMouseDown={(e) => {
if (e.button !== 0) return
onCheckedChange()
}}
onKeyDown={(e) => {
if (e.key !== ' ' && e.key !== 'Enter') return
e.preventDefault()
onCheckedChange()
}}
>
<div className='flex items-center justify-center' style={{ width: numRegionWidth }}>
<Checkbox size='sm' checked={checked} className='pointer-events-none' />
</div>
</th>
)
})
export const AddRowButton = React.memo(function AddRowButton({ onClick }: { onClick: () => void }) {
return (
<div className='px-2 py-[7px]'>
<Button
type='button'
variant='ghost'
size='sm'
className='h-[20px] gap-2 p-0 text-[var(--text-body)]'
onClick={onClick}
>
<Plus className='size-[14px] shrink-0 text-[var(--text-icon)]' />
<span className='font-medium text-small'>New row</span>
</Button>
</div>
)
})
@@ -0,0 +1,37 @@
import type React from 'react'
import type { ColumnDefinition } from '@/lib/table'
export interface BlockIconInfo {
icon: React.ComponentType<{ className?: string }>
color: string
}
export interface ColumnSourceInfo {
blockIconInfo?: BlockIconInfo
blockName?: string
/** Workflow loaded but the column's source block no longer exists — the
* header renders a "Not found" badge. Only set for loaded states. */
blockMissing?: boolean
}
/**
* One visual column in the rendered grid. With the flat schema there's exactly
* one DisplayColumn per ColumnDefinition — no fan-out. Workflow grouping is
* derived from `column.workflowGroupId` and rendered as a meta-header banner.
*/
export interface DisplayColumn extends ColumnDefinition {
/** Stable per-visual-column identifier (= column.name). */
key: string
/** Block id producing this column's value (workflow-output columns only). */
outputBlockId?: string
/** Pluck path the workflow ran for this column. */
outputPath?: string
/** Number of consecutive sibling columns sharing this group (1 for plain). */
groupSize: number
/** colIndex of the first sibling within `displayColumns`. */
groupStartColIndex: number
/** Header label shown above this visual column. */
headerLabel: string
/** True when this is the leftmost sibling of its group (or non-grouped). */
isGroupStart: boolean
}
@@ -0,0 +1,334 @@
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import type {
ColumnDefinition,
RowExecutionMetadata,
RowExecutions,
TableRow as TableRowType,
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { areGroupDepsSatisfied, areOutputsFilled } from '@/lib/table/deps'
import type { DeletedRowSnapshot } from '@/stores/table/types'
import type { DisplayColumn } from './types'
/**
* `all` means "every row matching the active filter" — including rows not yet loaded by the
* virtualized grid. `excluded` holds rows deselected after a select-all, so the pair maps directly
* onto the async delete job's `{ filter, excludeRowIds }`.
*/
export type RowSelection =
| { kind: 'none' }
| { kind: 'some'; ids: Set<string> }
| { kind: 'all'; excluded?: Set<string> }
export const ROW_SELECTION_NONE: RowSelection = { kind: 'none' }
export const ROW_SELECTION_ALL: RowSelection = { kind: 'all' }
export function rowSelectionIncludes(sel: RowSelection, id: string): boolean {
if (sel.kind === 'all') return !sel.excluded?.has(id)
if (sel.kind === 'some') return sel.ids.has(id)
return false
}
export function rowSelectionIsEmpty(sel: RowSelection): boolean {
if (sel.kind === 'none') return true
if (sel.kind === 'some') return sel.ids.size === 0
return false
}
export function rowSelectionMaterialize(sel: RowSelection, rows: TableRowType[]): Set<string> {
if (sel.kind === 'all')
return new Set(rows.filter((r) => !sel.excluded?.has(r.id)).map((r) => r.id))
if (sel.kind === 'some') return new Set(sel.ids)
return new Set<string>()
}
export function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]): boolean {
if (rows.length === 0) return false
if (sel.kind === 'all') return !rows.some((r) => sel.excluded?.has(r.id))
if (sel.kind === 'none') return false
if (sel.ids.size < rows.length) return false
for (const r of rows) if (!sel.ids.has(r.id)) return false
return true
}
/** Returns sticky row-number column dimensions sized to the digit count of `rowCount`. */
export function checkboxColLayout(
rowCount: number,
hasWorkflowCols: boolean
): { colWidth: number; numRegionWidth: number } {
const digits = rowCount > 0 ? Math.floor(Math.log10(rowCount)) + 1 : 1
const numWidth = Math.max(20, digits * 8 + 4)
// Region the number/checkbox is centered within (digit width + 12px breathing
// room, min 32). The select-all header checkbox centers in the same region so it
// lines up with the per-row checkboxes.
const numRegionWidth = Math.max(32, numWidth + 12)
// Workflow tables add a 20px run/stop button (+6px gap, +4px pad) to the right of
// the region; the checkbox stays centered in the space that remains.
const colWidth = numRegionWidth + (hasWorkflowCols ? 30 : 0)
return { colWidth, numRegionWidth }
}
export interface CellCoord {
rowIndex: number
colIndex: number
}
export interface NormalizedSelection {
startRow: number
endRow: number
startCol: number
endCol: number
anchorRow: number
anchorCol: number
}
/** A run of consecutive `displayColumns` rendered together in the meta header row. */
export type HeaderGroup =
| { kind: 'plain'; size: 1; startColIndex: number }
| {
kind: 'workflow'
size: number
startColIndex: number
groupId: string
workflowId: string
}
/**
* Flat schema → one DisplayColumn per ColumnDefinition. Pre-pass computes
* `groupSize` and `groupStartColIndex` for every consecutive run of columns
* sharing a `workflowGroupId`. Validation guarantees cohesion; the renderer
* just walks sequentially.
*/
export function expandToDisplayColumns(
columns: ColumnDefinition[],
workflowGroups: WorkflowGroup[]
): DisplayColumn[] {
const out: DisplayColumn[] = []
const groupById = new Map(workflowGroups.map((g) => [g.id, g]))
for (let i = 0; i < columns.length; ) {
const column = columns[i]
const gid = column.workflowGroupId
if (gid) {
let size = 1
while (i + size < columns.length && columns[i + size].workflowGroupId === gid) {
size++
}
const group = groupById.get(gid)
// Pre-index outputs by column name for O(1) lookup. First output wins on a
// duplicate columnName, exactly matching the previous `Array.find()` behavior.
const outputByColumnName = new Map<string, WorkflowGroup['outputs'][number]>()
if (group) {
for (const o of group.outputs) {
if (!outputByColumnName.has(o.columnName)) outputByColumnName.set(o.columnName, o)
}
}
const startIdx = out.length
for (let k = 0; k < size; k++) {
const child = columns[i + k]
const output = outputByColumnName.get(getColumnId(child))
out.push({
...child,
key: getColumnId(child),
outputBlockId: output?.blockId,
outputPath: output?.path,
groupSize: size,
groupStartColIndex: startIdx,
headerLabel: child.name,
isGroupStart: k === 0,
})
}
i += size
} else {
out.push({
...column,
key: getColumnId(column),
groupSize: 1,
groupStartColIndex: out.length,
headerLabel: column.name,
isGroupStart: true,
})
i += 1
}
}
return out
}
export function buildHeaderGroups(
displayColumns: DisplayColumn[],
workflowGroups: WorkflowGroup[]
): HeaderGroup[] {
const groupById = new Map(workflowGroups.map((g) => [g.id, g]))
const groups: HeaderGroup[] = []
for (let i = 0; i < displayColumns.length; ) {
const col = displayColumns[i]
if (col.workflowGroupId && col.isGroupStart) {
const group = groupById.get(col.workflowGroupId)
if (group) {
groups.push({
kind: 'workflow',
size: col.groupSize,
startColIndex: i,
groupId: col.workflowGroupId,
workflowId: group.workflowId,
})
i += col.groupSize
continue
}
}
groups.push({ kind: 'plain', size: 1, startColIndex: i })
i += 1
}
return groups
}
/** Reads the per-group execution state for a row, defaulting to empty. */
export function readExecution(
row: { executions?: RowExecutions } | null | undefined,
groupId: string | undefined
): RowExecutionMetadata | undefined {
if (!groupId) return undefined
return row?.executions?.[groupId]
}
/**
* Resolves a cell's execution state with the "about to run" overlay applied:
* for cells in an active dispatch's scope ahead of its cursor whose deps are
* already satisfied, returns a synthetic `pending` exec so the renderer
* shows `Queued`. Cells with a real DB exec always win — the overlay only
* fills the gap between dispatch start and the dispatcher's per-row pending
* stamp. Cells with unmet deps still render as `Waiting` (the renderer
* computes that from `waitingOnLabels`).
*/
export function resolveCellExec(
row: TableRowType,
group: WorkflowGroup | undefined,
activeDispatches: ActiveDispatch[] | undefined
): RowExecutionMetadata | undefined {
if (!group) return undefined
const real = row.executions?.[group.id]
if (real) return real
if (!activeDispatches || activeDispatches.length === 0) return undefined
if (areOutputsFilled(group, row)) return undefined
if (!areGroupDepsSatisfied(group, row)) return undefined
for (const d of activeDispatches) {
// Capped dispatches run only the first N eligible rows ahead of the
// cursor, and this per-row resolver can't tell which rows fall within the
// budget — rendering every ahead-of-cursor row as Queued would massively
// over-count. The dispatcher's real per-row pending stamps (arriving via
// cell SSE) cover the actual rows instead.
if (d.limit) continue
if (!d.scope.groupIds.includes(group.id)) continue
// Auto-fire dispatches (row writes / schema changes) scope every group but
// the dispatcher honors `autoRun: false` per-cell ('autoRun-off'), so those
// cells never actually run — don't optimistically paint them Queued. Manual
// runs (Run all / Run column) bypass autoRun and DO run them, so keep the
// overlay's Queued there.
if (!d.isManualRun && group.autoRun === false) continue
if (d.scope.rowIds && !d.scope.rowIds.includes(row.id)) continue
if (row.position <= d.cursor) continue
return {
status: 'pending',
executionId: null,
jobId: null,
workflowId: group.workflowId,
error: null,
}
}
return undefined
}
export interface ExecStatusMix {
hasIncompleteOrFailed: boolean
hasCompleted: boolean
hasInFlight: boolean
}
/**
* Walks `(rowIdSet × groupIds)` exec statuses on `rows` and reports which
* status buckets are present. Short-circuits once all three buckets are
* observed and once every selected row has been visited. Drives Play /
* Refresh / Stop visibility on the action bar and the context menu — both
* surfaces use the same shape so they stay in sync.
*/
export function classifyExecStatusMix(
rows: TableRowType[],
rowIdSet: ReadonlySet<string>,
groupIds: readonly string[]
): ExecStatusMix {
const result: ExecStatusMix = {
hasIncompleteOrFailed: false,
hasCompleted: false,
hasInFlight: false,
}
if (rowIdSet.size === 0 || groupIds.length === 0) return result
const target = rowIdSet.size
let seen = 0
for (const row of rows) {
if (!rowIdSet.has(row.id)) continue
seen++
for (const groupId of groupIds) {
const status = readExecution(row, groupId)?.status
if (status === 'queued' || status === 'running' || status === 'pending') {
result.hasInFlight = true
} else if (status === 'completed') {
result.hasCompleted = true
} else {
result.hasIncompleteOrFailed = true
}
if (result.hasInFlight && result.hasCompleted && result.hasIncompleteOrFailed) {
return result
}
}
if (seen === target) break
}
return result
}
export function moveCell(
anchor: CellCoord,
colCount: number,
totalRows: number,
direction: 1 | -1
): CellCoord {
let newCol = anchor.colIndex + direction
let newRow = anchor.rowIndex
if (newCol >= colCount) {
newCol = 0
newRow = Math.min(totalRows - 1, newRow + 1)
} else if (newCol < 0) {
newCol = colCount - 1
newRow = Math.max(0, newRow - 1)
}
return { rowIndex: newRow, colIndex: newCol }
}
export function computeNormalizedSelection(
anchor: CellCoord | null,
focus: CellCoord | null
): NormalizedSelection | null {
if (!anchor) return null
const f = focus ?? anchor
return {
startRow: Math.min(anchor.rowIndex, f.rowIndex),
endRow: Math.max(anchor.rowIndex, f.rowIndex),
startCol: Math.min(anchor.colIndex, f.colIndex),
endCol: Math.max(anchor.colIndex, f.colIndex),
anchorRow: anchor.rowIndex,
anchorCol: anchor.colIndex,
}
}
export function collectRowSnapshots(rows: Iterable<TableRowType>): DeletedRowSnapshot[] {
const snapshots: DeletedRowSnapshot[] = []
for (const row of rows) {
snapshots.push({
rowId: row.id,
data: { ...row.data },
position: row.position,
orderKey: row.orderKey,
})
}
return snapshots
}
@@ -0,0 +1,6 @@
export {
type WorkflowConfig,
WorkflowSidebar,
WorkflowSidebarBody,
type WorkflowSidebarBodyProps,
} from './workflow-sidebar'
@@ -0,0 +1,84 @@
'use client'
import { useState } from 'react'
import { Badge, ChipCombobox, CollapsibleCard, Label } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import type { InputFormatField } from '@/lib/workflows/types'
interface InputMappingSectionProps {
/** The workflow Start block's input fields. Each gets one collapsible row. */
inputFields: InputFormatField[]
/** Columns the user can feed into an input (all table columns). */
columnOptions: ColumnDefinition[]
/** Current mapping: input field name → table column name. */
value: Record<string, string>
onChange: (next: Record<string, string>) => void
}
/**
* "Workflow inputs" panel: maps each of the workflow's Start-block input fields
* to the table column whose per-row value feeds it. Each field renders as a
* collapsible card — header shows the field name + type badge, the body holds
* the column picker — mirroring the workflow editor's input-mapping rows.
*/
export function InputMappingSection({
inputFields,
columnOptions,
value,
onChange,
}: InputMappingSectionProps) {
const namedFields = inputFields.filter((f): f is InputFormatField & { name: string } =>
Boolean(f.name?.trim())
)
const columns = columnOptions.map((c) => ({ label: c.name, value: getColumnId(c) }))
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const toggle = (name: string) => setCollapsed((prev) => ({ ...prev, [name]: !prev[name] }))
return (
<div className='flex flex-col gap-[9.5px]'>
<Label className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>
Workflow inputs
</Label>
{namedFields.length === 0 ? (
<p className='pl-0.5 text-[var(--text-tertiary)] text-caption'>
This workflow has no Start block inputs.
</p>
) : (
<div className='flex flex-col gap-2'>
{namedFields.map((field) => (
<CollapsibleCard
key={field.name}
title={field.name}
badge={
field.type ? (
<Badge variant='type' size='sm'>
{field.type}
</Badge>
) : undefined
}
collapsed={collapsed[field.name] ?? false}
onToggleCollapse={() => toggle(field.name)}
>
<Label className='text-small'>Column</Label>
<ChipCombobox
searchable
searchPlaceholder='Search columns…'
className='w-full'
dropdownWidth='trigger'
maxHeight={240}
disabled={columns.length === 0}
emptyMessage='No columns.'
placeholder='Select a column'
options={columns}
value={value[field.name] ?? ''}
onChange={(columnName: string) => onChange({ ...value, [field.name]: columnName })}
/>
</CollapsibleCard>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,56 @@
'use client'
import { ChipCombobox, Label } from '@sim/emcn'
import type { ColumnDefinition } from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
interface RunSettingsSectionProps {
/** All columns the group can depend on (left-of-current scalar + workflow
* output columns alike). */
depOptions: ColumnDefinition[]
/** Column names this group waits on. */
deps: string[]
onChangeDeps: (next: string[]) => void
/** Inline validation error rendered under the picker. */
error?: string | null
}
/**
* "Run after" picker: which upstream columns must be filled before this group
* fires. Workflow output columns count the same as plain columns — once a
* column is non-empty, the dep is satisfied. At least one dep is required
* when auto-run is on.
*/
export function RunSettingsSection({
depOptions,
deps,
onChangeDeps,
error,
}: RunSettingsSectionProps) {
const options = depOptions.map((c) => ({ label: c.name, value: getColumnId(c) }))
return (
<div className='flex flex-col gap-[9.5px]'>
<Label className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>Run after</Label>
<ChipCombobox
multiSelect
searchable
searchPlaceholder='Search…'
className='w-full'
dropdownWidth='trigger'
maxHeight={240}
disabled={depOptions.length === 0}
emptyMessage='No upstream columns.'
options={options}
multiSelectValues={deps}
onMultiSelectChange={onChangeDeps}
overlayContent={
<span className='truncate text-[var(--text-tertiary)]'>
{deps.length === 0 ? 'Select at least one column' : `${deps.length} selected`}
</span>
}
/>
{error && <p className='pl-0.5 text-[var(--text-error)] text-caption'>{error}</p>}
</div>
)
}
@@ -0,0 +1,30 @@
'use client'
import { Button } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { useParams, useRouter } from 'next/navigation'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function TableError({ error, reset }: ErrorBoundaryProps) {
const router = useRouter()
const { workspaceId } = useParams<{ workspaceId: string }>()
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load table'
description='Something went wrong while loading this table. The table may have been deleted or you may not have permission to view it.'
loggerName='TableError'
>
<Button
variant='default'
size='md'
onClick={() => router.push(`/workspace/${workspaceId}/tables`)}
>
<ArrowLeft className='mr-1.5 size-[14px]' />
Go back
</Button>
</ErrorState>
)
}
@@ -0,0 +1,3 @@
export * from './use-context-menu'
export * from './use-table'
export * from './use-table-event-stream'
@@ -0,0 +1,65 @@
import { useCallback, useState } from 'react'
import type { TableRow } from '@/lib/table'
import type { ContextMenuState } from '../types'
interface UseContextMenuReturn {
contextMenu: ContextMenuState
handleRowContextMenu: (e: React.MouseEvent, row: TableRow, columnName?: string | null) => void
handleEmptyCellContextMenu: (
e: React.MouseEvent,
rowIndex: number,
columnName: string | null
) => void
closeContextMenu: () => void
}
export function useContextMenu(): UseContextMenuReturn {
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
isOpen: false,
position: { x: 0, y: 0 },
row: null,
rowIndex: null,
columnName: null,
})
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, row: TableRow, columnName?: string | null) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row,
rowIndex: row.position,
columnName: columnName ?? null,
})
},
[]
)
const handleEmptyCellContextMenu = useCallback(
(e: React.MouseEvent, rowIndex: number, columnName: string | null) => {
e.preventDefault()
e.stopPropagation()
setContextMenu({
isOpen: true,
position: { x: e.clientX, y: e.clientY },
row: null,
rowIndex,
columnName,
})
},
[]
)
const closeContextMenu = useCallback(() => {
setContextMenu((prev) => ({ ...prev, isOpen: false }))
}, [])
return {
contextMenu,
handleRowContextMenu,
handleEmptyCellContextMenu,
closeContextMenu,
}
}
@@ -0,0 +1,436 @@
'use client'
import { useEffect, useRef } from 'react'
import { toast } from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { backoffWithJitter } from '@sim/utils/retry'
import { useQueryClient } from '@tanstack/react-query'
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
import type { RowData, RowExecutionMetadata, RowExecutions, TableDefinition } from '@/lib/table'
import type { TableEvent, TableEventEntry } from '@/lib/table/events'
import {
consumeInitiatedExport,
downloadExportResult,
snapshotAndMutateRows,
type TableRunState,
} from '@/hooks/queries/tables'
import { tableKeys } from '@/hooks/queries/utils/table-keys'
const logger = createLogger('useTableEventStream')
interface PrunedEvent {
earliestEventId: number | null
}
const RECONNECT_BACKOFF_BASE_MS = 500
const RECONNECT_BACKOFF_MAX_MS = 10_000
const RUN_STATE_REFETCH_THROTTLE_MS = 1_000
const ROWS_INVALIDATE_DEBOUNCE_MS = 250
interface UseTableEventStreamArgs {
tableId: string | undefined
workspaceId: string | undefined
enabled?: boolean
/** Fired when the server halts a dispatch because the billed account is over
* its usage limit. The page surfaces an upgrade prompt + redirect. */
onUsageLimitReached?: (event: { dispatchId?: string; message: string }) => void
}
/**
* Subscribes to the table's SSE event stream and patches the React Query
* cache as cell-state events arrive.
*
* Fresh mount tails from the latest event — the rows + run-state queries
* fetch current state from the DB, so replaying buffered history would only
* rewind fresh cells through stale intermediate states (queued → running →
* completed churn). Reconnect-resume: on transport error, reconnects with
* `from=` set to the last seen `eventId`; server replays missed events from
* the Redis-backed buffer. If the gap exceeds buffer retention (server emits
* `pruned`), the hook full-refetches and resumes tailing from latest.
*/
export function useTableEventStream({
tableId,
workspaceId,
enabled = true,
onUsageLimitReached,
}: UseTableEventStreamArgs): void {
const queryClient = useQueryClient()
// Ref so a changing callback identity doesn't tear down + reconnect the SSE.
const onUsageLimitReachedRef = useRef(onUsageLimitReached)
onUsageLimitReachedRef.current = onUsageLimitReached
useEffect(() => {
if (!enabled || !tableId || !workspaceId) return
let cancelled = false
let eventSource: EventSource | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
// `null` = no cursor yet: connect without `from` and tail from latest.
// Advanced in memory per event; within-session reconnects resume from it.
let lastEventId: number | null = null
let reconnectAttempt = 0
// Leading + trailing throttle for run-state refetches. Cell/dispatch SSE
// events arrive in bursts (the server flushes its buffer every 500ms): the
// leading edge keeps the badge stepping promptly on sporadic completions;
// the trailing timer coalesces a burst into one refetch per interval. A
// debounce would starve here — sustained bursts reset it indefinitely.
let runStateInvalidateTimer: ReturnType<typeof setTimeout> | null = null
let lastRunStateInvalidateAt = 0
let runStateFetchInFlight = false
let runStateDirtyDuringFetch = false
const invalidateRunState = async (): Promise<void> => {
// cancelRefetch: false — the default (true) cancels an in-flight refetch
// and restarts it. When the run-state fetch is slower than the throttle
// interval (a busy run congests the server), that livelocks: every
// interval kills the previous fetch before it can land and the badge
// freezes on the last value that ever resolved. Instead, let an
// in-flight fetch complete (slightly stale counts land), remember that
// events arrived meanwhile, and run one follow-up afterwards — without
// the follow-up, a run's final events deduping into a stale fetch would
// freeze the badge non-zero forever.
if (runStateFetchInFlight) {
runStateDirtyDuringFetch = true
return
}
// Stamped only when a fetch actually starts — the coalesced path above
// must not reset the throttle clock, or it delays the follow-up.
lastRunStateInvalidateAt = Date.now()
runStateFetchInFlight = true
try {
await queryClient.invalidateQueries(
{ queryKey: tableKeys.activeDispatches(tableId) },
{ cancelRefetch: false }
)
} finally {
runStateFetchInFlight = false
if (runStateDirtyDuringFetch) {
runStateDirtyDuringFetch = false
scheduleDispatchInvalidate()
}
}
}
const scheduleDispatchInvalidate = (): void => {
if (cancelled || runStateInvalidateTimer !== null) return
const elapsed = Date.now() - lastRunStateInvalidateAt
if (elapsed >= RUN_STATE_REFETCH_THROTTLE_MS) {
void invalidateRunState()
return
}
runStateInvalidateTimer = setTimeout(() => {
runStateInvalidateTimer = null
void invalidateRunState()
}, RUN_STATE_REFETCH_THROTTLE_MS - elapsed)
}
/** Urgent resync (usage-limit halt, prune recovery) — skips the throttle.
* Default cancelRefetch here: a fetch started before the halt is stale by
* definition, so kill it and read fresh. One-shot, so no churn risk. */
const invalidateDispatchesNow = (): void => {
if (runStateInvalidateTimer !== null) {
clearTimeout(runStateInvalidateTimer)
runStateInvalidateTimer = null
}
lastRunStateInvalidateAt = Date.now()
void queryClient.invalidateQueries({ queryKey: tableKeys.activeDispatches(tableId) })
}
// Live-fill: import progress ticks arrive every N rows; coalesce the row
// refetches into one per debounce window instead of refetching per tick.
let jobInvalidateTimer: ReturnType<typeof setTimeout> | null = null
const scheduleRowsInvalidate = (): void => {
if (jobInvalidateTimer !== null) clearTimeout(jobInvalidateTimer)
jobInvalidateTimer = setTimeout(() => {
jobInvalidateTimer = null
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
}, ROWS_INVALIDATE_DEBOUNCE_MS)
}
const applyCell = (event: Extract<TableEvent, { kind: 'cell' }>): void => {
const {
rowId,
groupId,
status,
executionId,
jobId,
error,
outputs,
runningBlockIds,
blockErrors,
} = event
void snapshotAndMutateRows(
queryClient,
tableId,
(row) => {
if (row.id !== rowId) return null
const prevExec = row.executions?.[groupId]
const nextExec: RowExecutionMetadata = {
status,
executionId: executionId ?? null,
jobId: jobId ?? null,
// Preserve workflowId from cache; SSE payload doesn't carry it.
workflowId: prevExec?.workflowId ?? '',
error: error ?? null,
...(runningBlockIds ? { runningBlockIds } : {}),
...(blockErrors ? { blockErrors } : {}),
}
const nextExecutions: RowExecutions = {
...(row.executions ?? {}),
[groupId]: nextExec,
}
const nextData: RowData = outputs ? ({ ...row.data, ...outputs } as RowData) : row.data
return { ...row, executions: nextExecutions, data: nextData }
},
{ cancelInFlight: false }
)
// `runningByRowId` (the "X running" badge + per-row gutter) is
// server-derived: refetch the snapshot on the throttle instead of
// maintaining client-side ±1 deltas, which drift on unloaded rows,
// replays, and races with optimistic stamps.
scheduleDispatchInvalidate()
}
const applyDispatch = (event: Extract<TableEvent, { kind: 'dispatch' }>): void => {
const { dispatchId, status, scope, cursor, mode, isManualRun, limit } = event
queryClient.setQueryData<TableRunState>(tableKeys.activeDispatches(tableId), (prev) => {
// SSE may arrive before the initial fetch lands. Seed an empty
// run-state so the dispatch isn't dropped; counters are reconciled
// by the subsequent fetch.
const base: TableRunState = prev ?? {
dispatches: [],
runningByRowId: {},
hasRunning: false,
}
const list = base.dispatches
// Terminal states drop the dispatch from the overlay; client renders
// the row's authoritative DB exec state from here.
if (status === 'complete' || status === 'cancelled') {
const filtered = list.filter((d) => d.id !== dispatchId)
return filtered.length === list.length ? base : { ...base, dispatches: filtered }
}
if (scope === undefined || cursor === undefined || mode === undefined) {
// Defensive: a legacy emit without the new fields can't drive the
// overlay. Leave existing cache alone.
return base
}
const idx = list.findIndex((d) => d.id === dispatchId)
const existing = idx === -1 ? undefined : list[idx]
// Prefer the event payload (current truth from server); fall back to
// the cached entry's value if this is a legacy emit without the
// field, and finally to `false` if we have nothing.
const resolvedManualRun = isManualRun ?? existing?.isManualRun ?? false
const resolvedLimit = limit ?? existing?.limit
const next: ActiveDispatch = {
id: dispatchId,
status,
mode,
isManualRun: resolvedManualRun,
cursor,
scope,
...(resolvedLimit ? { limit: resolvedLimit } : {}),
}
if (idx === -1) return { ...base, dispatches: [...list, next] }
const merged = list.slice()
merged[idx] = next
return { ...base, dispatches: merged }
})
// The dispatcher emits this once per window (after the window's cells
// finish + the cursor advances) and on completion. Re-sync
// `runningByRowId` from the server so the badge steps down per window
// and matches a reload exactly.
scheduleDispatchInvalidate()
}
const applyJob = (event: Extract<TableEvent, { kind: 'job' }>): void => {
const { type, status, progress, error, jobId } = event
const isTerminal = status === 'ready' || status === 'failed' || status === 'canceled'
// Exports run concurrently with other jobs and never touch the detail-cache job fields
// (those derive from the latest *non-export* job). Their only client effect: download the
// file when an export this session kicked off completes. The initiated-set guard is what
// keeps replayed `ready` events (SSE re-delivers up to 1h on reconnect) from re-downloading.
if (type === 'export') {
// Keep the tray's export list fresh between its polls.
void queryClient.invalidateQueries({ queryKey: tableKeys.exportJobs(workspaceId) })
if (status === 'ready' && jobId && consumeInitiatedExport(jobId)) {
void downloadExportResult(workspaceId, tableId, jobId)
.then(() => toast.success('Export ready — downloading'))
.catch((err) => {
logger.error('Export download failed', { tableId, jobId, err })
toast.error('Export finished but the download failed — try again from the table menu')
})
} else if (status === 'failed' && jobId && consumeInitiatedExport(jobId)) {
toast.error(error || 'Export failed')
}
return
}
// The SSE buffer replays on (re)connect and can hold a *prior* job's events for this table.
// Ignore anything from a superseded run, and don't trust a replayed terminal before we know
// the active run's id.
const prev = queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))
const lockedId = prev?.jobId
if (lockedId && jobId && jobId !== lockedId) return
if (!lockedId && isTerminal) return
queryClient.setQueryData<TableDefinition>(tableKeys.detail(tableId), (p) =>
p
? {
...p,
jobStatus: status,
jobId: jobId ?? p.jobId,
jobType: type,
jobRowsProcessed: progress ?? p.jobRowsProcessed,
jobError: error ?? null,
}
: p
)
// The header tray + completion toast are owned by the tray poll. Here we keep the detail
// cache + grid in sync. On terminal, refetch rows + the definition (import may have rewritten
// the schema; delete failure/cancel restores optimistically-hidden rows). While running,
// imports and backfills live-fill rows per batch; a delete has already optimistically removed
// its rows, so we don't refetch mid-run (that would flicker not-yet-deleted rows back in).
if (isTerminal) {
if (jobInvalidateTimer !== null) clearTimeout(jobInvalidateTimer)
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
void queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId) })
} else if (type === 'import' || type === 'backfill') {
scheduleRowsInvalidate()
}
}
const applyUsageLimit = (event: Extract<TableEvent, { kind: 'usageLimitReached' }>): void => {
// Drop the halted dispatch from the overlay so the "running" UI clears
// immediately (the dispatcher was marked complete server-side). Cascade /
// auto-fire events carry no dispatchId — nothing to remove.
if (event.dispatchId) {
queryClient.setQueryData<TableRunState>(tableKeys.activeDispatches(tableId), (prev) => {
if (!prev) return prev
const filtered = prev.dispatches.filter((d) => d.id !== event.dispatchId)
return filtered.length === prev.dispatches.length
? prev
: { ...prev, dispatches: filtered }
})
}
// Blocked cells are left `queued` in the DB with no terminal cell event,
// so `runningByRowId` would otherwise stay non-zero (stale "X running").
// Re-sync the server counts immediately (the user is being told they're
// over limit — the badge must not linger behind the throttle), and
// refetch rows so cells whose pre-stamps the server cleared drop their
// "Queued" state.
invalidateDispatchesNow()
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
onUsageLimitReachedRef.current?.({ dispatchId: event.dispatchId, message: event.message })
}
const handlePrune = (payload: PrunedEvent): void => {
logger.info('Table event buffer pruned — full refetch', { tableId, ...payload })
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
invalidateDispatchesNow()
// Tail from latest after the refetch — replaying the surviving buffer
// over freshly-refetched rows would rewind them through stale states.
lastEventId = null
// Close proactively so the server's close doesn't fire onerror and route
// through the backoff path. Reconnect immediately from the new cursor.
eventSource?.close()
eventSource = null
reconnectAttempt = 0
connect()
}
const scheduleReconnect = (): void => {
if (cancelled) return
reconnectAttempt++
const delay = backoffWithJitter(reconnectAttempt, null, {
baseMs: RECONNECT_BACKOFF_BASE_MS,
maxMs: RECONNECT_BACKOFF_MAX_MS,
})
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
const connect = (): void => {
if (cancelled) return
// No cursor → tail from latest (server-side); otherwise replay-resume.
const url =
lastEventId === null
? `/api/table/${tableId}/events/stream`
: `/api/table/${tableId}/events/stream?from=${lastEventId}`
try {
eventSource = new EventSource(url)
} catch (err) {
logger.warn('Failed to open table event stream', { tableId, err })
scheduleReconnect()
return
}
eventSource.onopen = () => {
reconnectAttempt = 0
}
eventSource.onmessage = (msg: MessageEvent<string>) => {
try {
const entry = JSON.parse(msg.data) as TableEventEntry
if (lastEventId !== null && entry.eventId <= lastEventId) return
lastEventId = entry.eventId
if (entry.event?.kind === 'cell') applyCell(entry.event)
else if (entry.event?.kind === 'dispatch') applyDispatch(entry.event)
else if (entry.event?.kind === 'job') applyJob(entry.event)
else if (entry.event?.kind === 'usageLimitReached') applyUsageLimit(entry.event)
} catch (err) {
logger.warn('Failed to parse table event', { tableId, err })
}
}
eventSource.addEventListener('pruned', (msg: MessageEvent<string>) => {
try {
handlePrune(JSON.parse(msg.data) as PrunedEvent)
} catch {
handlePrune({ earliestEventId: null })
}
})
eventSource.addEventListener('rotate', () => {
eventSource?.close()
eventSource = null
scheduleReconnect()
})
eventSource.onerror = () => {
if (cancelled) return
eventSource?.close()
eventSource = null
scheduleReconnect()
}
}
// In-SPA remount over a warm cache (table A → B → back to A within
// staleTime): the tail starts at "latest", so transitions that fired while
// unmounted were neither refetched (cache still fresh) nor replayed.
// Reconcile once — either cache being warm is enough (they can evict
// independently). Cold mounts have neither → skip, the queries are
// already fetching.
const hasWarmRunState =
queryClient.getQueryState(tableKeys.activeDispatches(tableId))?.data !== undefined
const hasWarmRows = queryClient
.getQueriesData({ queryKey: tableKeys.rowsRoot(tableId) })
.some(([, data]) => data !== undefined)
if (hasWarmRunState || hasWarmRows) {
void queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) })
void invalidateRunState()
}
connect()
return () => {
cancelled = true
if (reconnectTimer !== null) clearTimeout(reconnectTimer)
if (runStateInvalidateTimer !== null) clearTimeout(runStateInvalidateTimer)
if (jobInvalidateTimer !== null) clearTimeout(jobInvalidateTimer)
eventSource?.close()
eventSource = null
}
}, [enabled, tableId, workspaceId, queryClient])
}
@@ -0,0 +1,281 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Capture useEffect calls so tests can trigger them manually.
const capturedEffects: Array<() => undefined | (() => void)> = []
// Mock React hooks to be passthrough so useTable() can be called without a
// React root. useCallback returns its function arg; useMemo executes
// immediately; useEffect is captured for manual triggering.
vi.mock('react', () => ({
useCallback: (fn: unknown) => fn,
useMemo: (fn: () => unknown) => fn(),
useEffect: (fn: () => undefined | (() => void)) => {
capturedEffects.push(fn)
},
useRef: (init: unknown) => ({ current: init }),
}))
const mockGetQueryData = vi.fn()
const mockFetchNextPage = vi.fn()
const mockQueryClient = {
getQueryData: mockGetQueryData,
}
vi.mock('@tanstack/react-query', () => ({
useQueryClient: vi.fn(() => mockQueryClient),
}))
vi.mock('@/hooks/queries/tables', () => ({
tableRowsInfiniteOptions: vi.fn(({ tableId, pageSize, filter, sort }) => ({
queryKey: [
'tables',
'detail',
tableId,
'rows',
'infinite',
JSON.stringify({ pageSize, filter, sort }),
],
queryFn: vi.fn(),
initialPageParam: 0,
staleTime: 30000,
})),
useInfiniteTableRows: vi.fn(() => ({
data: { pages: [] },
isLoading: false,
refetch: vi.fn().mockResolvedValue(undefined),
fetchNextPage: mockFetchNextPage,
hasNextPage: false,
isFetchingNextPage: false,
})),
useTable: vi.fn(() => ({
data: undefined,
isLoading: false,
})),
}))
vi.mock('@/hooks/queries/workflows', () => ({
useWorkflows: vi.fn(() => ({ data: undefined })),
useWorkflowStates: vi.fn(() => new Map()),
}))
vi.mock('@/blocks', () => ({
getBlock: vi.fn(() => undefined),
}))
vi.mock('@/lib/table/constants', () => ({
TABLE_LIMITS: { MAX_QUERY_LIMIT: 1000 },
}))
import { useTable } from '@/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table'
const WORKSPACE_ID = 'ws-1'
const TABLE_ID = 'tbl-1'
const QUERY_OPTIONS = { filter: null, sort: null }
function makeRow(id: string, position: number) {
return { id, data: { name: `Row ${id}` }, position, executions: {} }
}
function makePages(rowsPerPage: number[], totalCount: number | null) {
return rowsPerPage.map((count, pageIdx) => ({
rows: Array.from({ length: count }, (_, i) =>
makeRow(`r${pageIdx * 1000 + i}`, pageIdx * 1000 + i)
),
totalCount,
}))
}
const OK = { status: 'success', hasNextPage: false } as const
function makeHook(queryOptions = QUERY_OPTIONS) {
return useTable({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID, queryOptions })
}
beforeEach(() => {
capturedEffects.length = 0
vi.clearAllMocks()
mockGetQueryData.mockReturnValue(undefined)
mockFetchNextPage.mockResolvedValue(OK)
})
describe('useTable ensureAllRowsLoaded', () => {
it('returns an empty array when cache is empty', async () => {
mockGetQueryData.mockReturnValue(undefined)
const { ensureAllRowsLoaded } = makeHook()
const rows = await ensureAllRowsLoaded()
expect(rows).toEqual([])
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('returns cached rows without fetching when the count is covered by a partial page', async () => {
mockGetQueryData.mockReturnValue({ pages: makePages([3], 3) })
const { ensureAllRowsLoaded } = makeHook()
const rows = await ensureAllRowsLoaded()
expect(rows).toHaveLength(3)
expect(rows.map((r) => r.id)).toEqual(['r0', 'r1', 'r2'])
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('returns cached rows without fetching when the count is covered by exactly one full page', async () => {
// The totalCount fast-path terminates a covered drain without the
// empty-page confirmation request the old page-fullness heuristic needed.
mockGetQueryData.mockReturnValue({ pages: makePages([1000], 1000) })
const { ensureAllRowsLoaded } = makeHook()
const rows = await ensureAllRowsLoaded()
expect(rows).toHaveLength(1000)
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('keeps paging past a short page when the count says more rows exist', async () => {
// The regression this termination rule exists for: a page shorter than the
// requested size must not be read as end-of-table.
const [shortPage] = makePages([36], 100)
const rest = {
rows: Array.from({ length: 64 }, (_, i) => makeRow(`r${1000 + i}`, 1000 + i)),
totalCount: null,
}
mockGetQueryData
.mockReturnValueOnce({ pages: [shortPage] }) // iter 1 check: 36 < 100 → fetch
.mockReturnValueOnce({ pages: [shortPage, rest] }) // iter 1 progress: 2 > 1
.mockReturnValue({ pages: [shortPage, rest] }) // iter 2 check: covered → break; final read
const { ensureAllRowsLoaded } = makeHook()
const rows = await ensureAllRowsLoaded()
expect(rows).toHaveLength(100)
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('drains until an empty page when the count is unknown', async () => {
const [page0] = makePages([1000], null)
const emptyPage = { rows: [], totalCount: null }
mockGetQueryData
.mockReturnValueOnce({ pages: [page0] }) // iter 1 check: unknown count → fetch
.mockReturnValueOnce({ pages: [page0, emptyPage] }) // iter 1 progress: 2 > 1
.mockReturnValue({ pages: [page0, emptyPage] }) // iter 2 check: empty page → break; final read
const { ensureAllRowsLoaded } = makeHook()
const rows = await ensureAllRowsLoaded()
expect(rows).toHaveLength(1000)
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('fetches multiple pages for a large table until the count is covered', async () => {
const [page0, page1, page2] = makePages([1000, 1000, 500], 2500)
mockGetQueryData
.mockReturnValueOnce({ pages: [page0] }) // iter 1 check: 1000 < 2500 → fetch
.mockReturnValueOnce({ pages: [page0, page1] }) // iter 1 progress: 2 > 1
.mockReturnValueOnce({ pages: [page0, page1] }) // iter 2 check: 2000 < 2500 → fetch
.mockReturnValueOnce({ pages: [page0, page1, page2] }) // iter 2 progress: 3 > 2
.mockReturnValue({ pages: [page0, page1, page2] }) // iter 3 check: covered → break; final read
const { ensureAllRowsLoaded } = makeHook()
const rows = await ensureAllRowsLoaded()
expect(rows).toHaveLength(2500)
expect(rows[0].id).toBe('r0')
expect(rows[1000].id).toBe('r1000')
expect(rows[2499].id).toBe('r2499')
expect(mockFetchNextPage).toHaveBeenCalledTimes(2)
})
it('throws when fetchNextPage returns an error status', async () => {
mockGetQueryData.mockReturnValue({ pages: makePages([1000], 2000) })
const error = new Error('Network failure')
mockFetchNextPage.mockResolvedValueOnce({ status: 'error', error })
const { ensureAllRowsLoaded } = makeHook()
await expect(ensureAllRowsLoaded()).rejects.toThrow('Network failure')
})
it('throws when a fetch makes no progress instead of spinning', async () => {
// A cancelQueries race can resolve fetchNextPage without appending a page.
mockGetQueryData.mockReturnValue({ pages: makePages([1000], 2000) })
const { ensureAllRowsLoaded } = makeHook()
await expect(ensureAllRowsLoaded()).rejects.toThrow('no progress')
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('does not call fetchNextPage or getQueryData when workspaceId is empty', async () => {
const { ensureAllRowsLoaded } = useTable({
workspaceId: '',
tableId: TABLE_ID,
queryOptions: QUERY_OPTIONS,
})
const rows = await ensureAllRowsLoaded()
expect(rows).toEqual([])
expect(mockFetchNextPage).not.toHaveBeenCalled()
expect(mockGetQueryData).not.toHaveBeenCalled()
})
it('does not call fetchNextPage or getQueryData when tableId is empty', async () => {
const { ensureAllRowsLoaded } = useTable({
workspaceId: WORKSPACE_ID,
tableId: '',
queryOptions: QUERY_OPTIONS,
})
const rows = await ensureAllRowsLoaded()
expect(rows).toEqual([])
expect(mockFetchNextPage).not.toHaveBeenCalled()
expect(mockGetQueryData).not.toHaveBeenCalled()
})
it('encodes queryOptions.filter into the queryKey passed to getQueryData', async () => {
const filter = { column: 'name', operator: 'eq', value: 'Alice' } as never
mockGetQueryData.mockReturnValue({ pages: makePages([3], 3) })
const { ensureAllRowsLoaded } = makeHook({ filter, sort: null })
await ensureAllRowsLoaded()
const queryKey = mockGetQueryData.mock.calls[0][0] as unknown[]
expect(JSON.stringify(queryKey)).toContain('Alice')
})
})
describe('useTable ensureRowsLoadedUpTo', () => {
it('returns the first maxRows with hasMore when the cache already exceeds the cap', async () => {
mockGetQueryData.mockReturnValue({ pages: makePages([1000, 1000], 2000) })
const { ensureRowsLoadedUpTo } = makeHook()
const result = await ensureRowsLoadedUpTo(1500)
expect(result.rows).toHaveLength(1500)
expect(result.hasMore).toBe(true)
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('returns everything with hasMore false when the table fits under the cap', async () => {
mockGetQueryData.mockReturnValue({ pages: makePages([3], 3) })
const { ensureRowsLoadedUpTo } = makeHook()
const result = await ensureRowsLoadedUpTo(50)
expect(result.rows).toHaveLength(3)
expect(result.hasMore).toBe(false)
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('loads one row past the cap to make hasMore exact at the boundary', async () => {
const [page0, page1] = makePages([1000, 1000], 2000)
mockGetQueryData
.mockReturnValueOnce({ pages: [page0] }) // check: at cap but more exist → fetch
.mockReturnValueOnce({ pages: [page0, page1] }) // progress: 2 > 1
.mockReturnValue({ pages: [page0, page1] }) // check: past cap → break; final read
const { ensureRowsLoadedUpTo } = makeHook()
const result = await ensureRowsLoadedUpTo(1000)
expect(result.rows).toHaveLength(1000)
expect(result.hasMore).toBe(true)
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
})
it('skips the boundary probe when the count is already covered', async () => {
mockGetQueryData.mockReturnValue({ pages: makePages([1000], 1000) })
const { ensureRowsLoadedUpTo } = makeHook()
const result = await ensureRowsLoadedUpTo(1000)
expect(result.rows).toHaveLength(1000)
expect(result.hasMore).toBe(false)
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
it('returns empty with hasMore false when ids are missing', async () => {
const { ensureRowsLoadedUpTo } = useTable({
workspaceId: '',
tableId: TABLE_ID,
queryOptions: QUERY_OPTIONS,
})
const result = await ensureRowsLoadedUpTo(10)
expect(result).toEqual({ rows: [], hasMore: false })
expect(mockFetchNextPage).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,253 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import type { ColumnDefinition, TableDefinition, TableRow, WorkflowGroup } from '@/lib/table'
import { TABLE_LIMITS } from '@/lib/table/constants'
import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs'
import { getBlock } from '@/blocks'
import {
tableRowsInfiniteOptions,
useInfiniteTableRows,
useTable as useTableQuery,
} from '@/hooks/queries/tables'
import { countLoadedTableRows, hasMoreTableRows } from '@/hooks/queries/utils/table-rows-pagination'
import { useWorkflowStates, useWorkflows } from '@/hooks/queries/workflows'
import type { WorkflowMetadata } from '@/stores/workflows/registry/types'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { BlockIconInfo, ColumnSourceInfo } from '../components/table-grid/types'
import type { QueryOptions } from '../types'
const EMPTY_COLUMNS: ColumnDefinition[] = []
const EMPTY_GROUPS: WorkflowGroup[] = []
interface UseTableParams {
workspaceId: string
tableId: string
queryOptions: QueryOptions
}
interface FetchNextPageResult {
hasNextPage: boolean
}
export interface UseTableReturn {
tableData: TableDefinition | undefined
isLoadingTable: boolean
/** Flattened across every fetched infinite-query page. */
rows: TableRow[]
/** Filter-scoped total row count (server COUNT(*) for the active filter); null until loaded. */
rowTotal: number | null
isLoadingRows: boolean
refetchRows: () => void
/**
* The resolved value's `hasNextPage` reflects the post-fetch cache state —
* read from this rather than the hook's `hasNextPage`, which only updates on
* the next React render.
*/
fetchNextPage: () => Promise<FetchNextPageResult>
hasNextPage: boolean
isFetchingNextPage: boolean
workflows: WorkflowMetadata[] | undefined
columns: ColumnDefinition[]
tableWorkflowGroups: WorkflowGroup[]
workflowStates: Map<string, WorkflowState | null>
/** Headers read from this map instead of each subscribing to its own workflow-state query. */
columnSourceInfo: Map<string, ColumnSourceInfo>
/**
* Fetches any missing pages then returns the full flat row list from cache.
* Safe to read immediately — no React re-render required. Gate bulk ops that
* need the complete row set behind this.
*/
ensureAllRowsLoaded: () => Promise<TableRow[]>
/**
* Pages until the cache holds at least `maxRows` rows (or no more pages
* exist), then returns the first `maxRows` from cache plus whether more
* remain. Unlike {@link ensureAllRowsLoaded} it stops early, so size-bound
* ops (clipboard copy) don't drain an entire large table. Filter/sort-aware.
*/
ensureRowsLoadedUpTo: (maxRows: number) => Promise<{ rows: TableRow[]; hasMore: boolean }>
}
/**
* Local interaction state (drag, resize, selection, editing) intentionally
* stays in the `Table` component — moving it here would push every keystroke
* through this hook's return value and re-render everything.
*/
export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams): UseTableReturn {
const queryClient = useQueryClient()
const { data: tableData, isLoading: isLoadingTable } = useTableQuery(workspaceId, tableId)
const {
data: rowsData,
isLoading: isLoadingRows,
refetch,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteTableRows({
workspaceId,
tableId,
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
filter: queryOptions.filter,
sort: queryOptions.sort,
enabled: Boolean(workspaceId && tableId),
})
const rows = useMemo<TableRow[]>(
() => rowsData?.pages.flatMap((p) => p.rows) ?? [],
[rowsData?.pages]
)
// Server-side COUNT(*) for the active filter (page 0 only). Null until the first page lands;
// callers fall back to the table's unfiltered `rowCount`. This is the true "select all" total —
// it reflects the filter, unlike `tableData.rowCount`.
const rowTotal = useMemo<number | null>(
() => rowsData?.pages[0]?.totalCount ?? null,
[rowsData?.pages]
)
const refetchRows = useCallback(() => {
void refetch()
}, [refetch])
const ensureAllRowsLoaded = useCallback(async (): Promise<TableRow[]> => {
if (!workspaceId || !tableId) return []
const opts = tableRowsInfiniteOptions({
workspaceId,
tableId,
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
filter: queryOptions.filter,
sort: queryOptions.sort,
})
// getQueryData bypasses React's render cycle — pages added by fetchNextPage
// are visible synchronously after each await without waiting for a re-render.
while (true) {
const pages = queryClient.getQueryData(opts.queryKey)?.pages ?? []
if (!hasMoreTableRows(pages)) break
const result = await fetchNextPage()
if (result.status === 'error') {
throw result.error ?? new Error('Failed to load table rows')
}
const after = queryClient.getQueryData(opts.queryKey)?.pages.length ?? 0
if (after <= pages.length) {
// A cancelQueries race (optimistic mutation) can resolve the fetch without
// appending a page; retrying would spin forever on the same state.
throw new Error('Table rows pagination made no progress')
}
}
return queryClient.getQueryData(opts.queryKey)?.pages.flatMap((p) => p.rows) ?? []
}, [workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage])
const ensureRowsLoadedUpTo = useCallback(
async (maxRows: number): Promise<{ rows: TableRow[]; hasMore: boolean }> => {
if (!workspaceId || !tableId) return { rows: [], hasMore: false }
const opts = tableRowsInfiniteOptions({
workspaceId,
tableId,
pageSize: TABLE_LIMITS.MAX_QUERY_LIMIT,
filter: queryOptions.filter,
sort: queryOptions.sort,
})
// Load one past the cap when needed so `hasMore` is exact: with the cap
// covered but hasMoreTableRows still true, row `maxRows + 1` confirms it.
while (true) {
const pages = queryClient.getQueryData(opts.queryKey)?.pages ?? []
if (countLoadedTableRows(pages) > maxRows || !hasMoreTableRows(pages)) break
const result = await fetchNextPage()
if (result.status === 'error') {
throw result.error ?? new Error('Failed to load table rows')
}
const after = queryClient.getQueryData(opts.queryKey)?.pages.length ?? 0
if (after <= pages.length) {
throw new Error('Table rows pagination made no progress')
}
}
const pages = queryClient.getQueryData(opts.queryKey)?.pages ?? []
const all = pages.flatMap((p) => p.rows)
return {
rows: all.length > maxRows ? all.slice(0, maxRows) : all,
hasMore: all.length > maxRows || hasMoreTableRows(pages),
}
},
[workspaceId, tableId, queryOptions.filter, queryOptions.sort, queryClient, fetchNextPage]
)
const fetchNextPageWrapped = useCallback(async () => {
const result = await fetchNextPage()
if (result.status === 'error') {
throw result.error ?? new Error('Failed to fetch next page')
}
return { hasNextPage: Boolean(result.hasNextPage) }
}, [fetchNextPage])
const { data: workflows } = useWorkflows(workspaceId)
const columns = useMemo(
() => tableData?.schema?.columns || EMPTY_COLUMNS,
[tableData?.schema?.columns]
)
const tableWorkflowGroups = useMemo<WorkflowGroup[]>(
() => tableData?.schema?.workflowGroups ?? EMPTY_GROUPS,
[tableData?.schema?.workflowGroups]
)
const workflowStates = useWorkflowStates(
useMemo(() => tableWorkflowGroups.map((g) => g.workflowId), [tableWorkflowGroups])
)
const columnSourceInfo = useMemo<Map<string, ColumnSourceInfo>>(() => {
const map = new Map<string, ColumnSourceInfo>()
for (const group of tableWorkflowGroups) {
// Enrichment groups have no workflow blocks; their output columns render
// with the standard column-type icon (the meta-header already carries the
// enrichment's icon), so we skip building source info for them.
if (group.type === 'enrichment') continue
const state = workflowStates.get(group.workflowId)
const blocks = (state as { blocks?: Record<string, FlattenOutputsBlockInput> } | null)?.blocks
// `useWorkflowStates` only fetches the live draft, so we can only judge
// "block missing" for live-mode groups. A deployed-mode group runs a
// different graph we don't load client-side — don't risk a false badge.
const isLiveMode = group.deploymentMode !== 'deployed'
for (const out of group.outputs) {
const block = blocks?.[out.blockId]
const blockConfig = block?.type ? getBlock(block.type) : undefined
const blockIconInfo: BlockIconInfo | undefined = blockConfig?.icon
? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' }
: undefined
const blockName = block?.name?.trim() || undefined
// Flag a missing source block only once the workflow state has loaded
// (truthy `blocks`), so a still-loading workflow never flashes the badge.
const blockMissing = Boolean(isLiveMode && blocks && out.blockId && !block)
map.set(out.columnName, { blockIconInfo, blockName, blockMissing })
}
}
return map
}, [tableWorkflowGroups, workflowStates])
return {
tableData,
isLoadingTable,
rows,
rowTotal,
isLoadingRows,
refetchRows,
fetchNextPage: fetchNextPageWrapped,
hasNextPage: Boolean(hasNextPage),
isFetchingNextPage,
workflows,
columns,
tableWorkflowGroups,
workflowStates,
columnSourceInfo,
ensureAllRowsLoaded,
ensureRowsLoadedUpTo,
}
}
@@ -0,0 +1,20 @@
'use client'
import { Table as TableIcon } from '@sim/emcn/icons'
import { noop } from '@sim/utils/helpers'
import {
type BreadcrumbItem,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const BREADCRUMBS: BreadcrumbItem[] = [
{ label: 'Tables', icon: TableIcon, onClick: noop },
{ label: '…', terminal: true },
]
export default function TableLoading() {
// The table editor's header has no static actions at load (import/run controls
// are data-gated) and its options bar is sort + filter with no search. The grid
// body fills in once the editor mounts, so no table chrome is rendered here.
return <ResourceChromeFallback icon={TableIcon} breadcrumbs={BREADCRUMBS} hasSort hasFilter />
}
@@ -0,0 +1,21 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import TableLoading from '@/app/workspace/[workspaceId]/tables/[tableId]/loading'
import { Table } from './table'
export const metadata: Metadata = {
title: 'Table',
}
/**
* Table-detail page entry. `Table` reads URL query params via nuqs (which uses
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
* fallback renders the real chrome so a suspend never shows a blank frame.
*/
export default function TablePage() {
return (
<Suspense fallback={<TableLoading />}>
<Table />
</Suspense>
)
}
@@ -0,0 +1,31 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort direction applied when a sort column is selected. */
export const DEFAULT_TABLE_DETAIL_SORT_DIRECTION = 'asc'
/**
* Co-located, typed URL query-param definitions for the table-detail view.
*
* - `sort` is the active sort column. Columns are user-defined table columns
* (not a fixed set), so the column id is stored as a free-form string. A
* `null` value means "no active sort" — the table's natural row order — and
* clears from the URL.
* - `dir` is the sort direction, following the shared `sort`+`dir` convention.
*
* The in-grid `filter` is intentionally NOT represented here. `Filter` is a
* recursive, arbitrarily-nested object (`$or`/`$and` combinators, per-column
* operator objects); serializing it would put a large structured blob in the
* URL, which the URL-state doctrine forbids. It stays in local `useState`.
*/
export const tableDetailParsers = {
sort: parseAsString,
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_TABLE_DETAIL_SORT_DIRECTION),
} as const
/** Sort view-state: clean URLs, no back-stack churn. */
export const tableDetailUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,966 @@
'use client'
import { useCallback, useMemo, useReducer, useRef, useState } from 'react'
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import type { RunLimit, RunMode } from '@/lib/api/contracts/tables'
import { captureEvent } from '@/lib/posthog/client'
import type {
ColumnDefinition,
Filter,
Sort,
TableRow as TableRowType,
WorkflowGroup,
} from '@/lib/table'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import {
type BreadcrumbItem,
type ColumnOption,
Resource,
type SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog'
import { ImportProgressMenu } from '@/app/workspace/[workspaceId]/tables/components/import-progress-menu'
import { useLogByExecutionId } from '@/hooks/queries/logs'
import {
downloadTableExport,
useCancelTableRuns,
useDeleteTable,
useDeleteTableRowsAsync,
useExportTableAsync,
useRenameTable,
useRunColumn,
} from '@/hooks/queries/tables'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useLogDetailsUIStore } from '@/stores/logs/store'
import type { DeletedRowSnapshot } from '@/stores/table/types'
import {
type ColumnConfig,
ColumnConfigSidebar,
EnrichmentDetails,
EnrichmentsSidebar,
NewColumnDropdown,
RowModal,
RunStatusControl,
type SelectionSnapshot,
TableActionBar,
TableFilter,
TableGrid,
type WorkflowConfig,
WorkflowSidebar,
} from './components'
import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants'
import { COLUMN_TYPE_ICONS } from './components/table-grid/headers'
import { useTable, useTableEventStream } from './hooks'
import {
DEFAULT_TABLE_DETAIL_SORT_DIRECTION,
tableDetailParsers,
tableDetailUrlKeys,
} from './search-params'
import type { QueryOptions } from './types'
import { generateColumnName } from './utils'
const logger = createLogger('Table')
interface TableProps {
/** When set, the table renders without its page header / breadcrumbs / page-level
* options bar. Used by the mothership chat panel to embed a table inline. */
embedded?: boolean
/** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */
workspaceId?: string
tableId?: string
}
/**
* Discriminated union encoding the at-most-one-open invariant for the three
* right-edge slideout panels. Driven by a `useReducer` so every transition
* goes through one place — opening a column config can't accidentally leave a
* workflow config open.
*/
type SlideoutState =
| { kind: 'none' }
| { kind: 'column'; config: ColumnConfig }
| { kind: 'enrichments'; editGroup?: WorkflowGroup }
| { kind: 'workflow'; config: WorkflowConfig }
| { kind: 'execution'; executionId: string }
| { kind: 'enrichment-details'; rowId: string; groupId: string }
type SlideoutAction =
| { type: 'OPEN_COLUMN'; config: ColumnConfig }
| { type: 'OPEN_ENRICHMENTS'; editGroup?: WorkflowGroup }
| { type: 'OPEN_WORKFLOW'; config: WorkflowConfig }
| { type: 'OPEN_EXECUTION'; executionId: string }
| { type: 'OPEN_ENRICHMENT_DETAILS'; rowId: string; groupId: string }
| { type: 'CLOSE' }
function slideoutReducer(_state: SlideoutState, action: SlideoutAction): SlideoutState {
switch (action.type) {
case 'OPEN_COLUMN':
return { kind: 'column', config: action.config }
case 'OPEN_ENRICHMENTS':
return { kind: 'enrichments', editGroup: action.editGroup }
case 'OPEN_WORKFLOW':
return { kind: 'workflow', config: action.config }
case 'OPEN_EXECUTION':
return { kind: 'execution', executionId: action.executionId }
case 'OPEN_ENRICHMENT_DETAILS':
return { kind: 'enrichment-details', rowId: action.rowId, groupId: action.groupId }
case 'CLOSE':
return { kind: 'none' }
}
}
/**
* Page-level wrapper for the table detail view. Mirrors the shape of
* `logs/logs.tsx`: a thin orchestrator that composes the data grid (`<TableGrid>`)
* and the page-level surface (sidebars, modals, action bar, breadcrumbs).
*
* Owns the at-most-one-open invariant for the three slideout panels (column
* config, workflow config, execution details) via a single reducer. The grid
* emits open requests via callbacks; the wrapper renders the panels.
*
* Embedded mode skips the page header but otherwise renders the same surface.
*/
export function Table({
embedded,
workspaceId: propWorkspaceId,
tableId: propTableId,
}: TableProps = {}) {
const params = useParams()
const router = useRouter()
const workspaceId = propWorkspaceId || (params.workspaceId as string)
const tableId = propTableId || (params.tableId as string)
const posthog = usePostHog()
const posthogRef = useRef(posthog)
posthogRef.current = posthog
const { navigateToSettings } = useSettingsNavigation()
// Plain function: `useTableEventStream` keeps it in a ref (its effect doesn't
// depend on the identity), so a stable reference buys nothing here.
const onUsageLimitReached = ({ message }: { dispatchId?: string; message: string }) => {
toast.error(message, {
action: { label: 'Upgrade', onClick: () => navigateToSettings({ section: 'billing' }) },
})
}
useTableEventStream({ tableId, workspaceId, onUsageLimitReached })
const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' })
const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false)
const [isImportCsvOpen, setIsImportCsvOpen] = useState(false)
const [editingRow, setEditingRow] = useState<TableRowType | null>(null)
const [deletingRows, setDeletingRows] = useState<DeletedRowSnapshot[]>([])
const [deletingAll, setDeletingAll] = useState<{
excludeRowIds: string[]
estimatedCount: number
} | null>(null)
const [deletingColumns, setDeletingColumns] = useState<string[] | null>(null)
const [selection, setSelection] = useState<SelectionSnapshot>({
actionBarRowIds: [],
runningInActionBarSelection: 0,
totalRunning: 0,
hasRunningCell: false,
hasActiveDispatch: false,
hasWorkflowColumns: false,
selectedRunScope: null,
selectionStats: { hasIncompleteOrFailed: false, hasCompleted: false, hasInFlight: false },
singleWorkflowCell: null,
})
const [filter, setFilter] = useState<Filter | null>(null)
const [filterOpen, setFilterOpen] = useState(false)
const [{ sort: sortColumn, dir: sortDirection }, setSortParams] = useQueryStates(
tableDetailParsers,
tableDetailUrlKeys
)
/** Resolved single-column sort, or `null` when no column is active. */
const sortQuery = useMemo<Sort | null>(
() => (sortColumn ? { [sortColumn]: sortDirection } : null),
[sortColumn, sortDirection]
)
const queryOptions = useMemo<QueryOptions>(
() => ({ filter, sort: sortQuery }),
[filter, sortQuery]
)
const userPermissions = useUserPermissionsContext()
const onOpenColumnConfig = useCallback((config: ColumnConfig) => {
dispatch({ type: 'OPEN_COLUMN', config })
}, [])
const onOpenWorkflowConfig = useCallback((config: WorkflowConfig) => {
dispatch({ type: 'OPEN_WORKFLOW', config })
}, [])
const onOpenEnrichments = useCallback(() => {
dispatch({ type: 'OPEN_ENRICHMENTS' })
}, [])
const onOpenEnrichmentConfig = useCallback((editGroup: WorkflowGroup) => {
dispatch({ type: 'OPEN_ENRICHMENTS', editGroup })
}, [])
const onOpenExecutionDetails = useCallback((executionId: string) => {
dispatch({ type: 'OPEN_EXECUTION', executionId })
}, [])
const onOpenEnrichmentDetails = useCallback((rowId: string, groupId: string) => {
dispatch({ type: 'OPEN_ENRICHMENT_DETAILS', rowId, groupId })
}, [])
const onCloseSlideout = () => dispatch({ type: 'CLOSE' })
const onOpenRowModal = (row: TableRowType) => setEditingRow(row)
// useCallback because <Resource.Header> is memo-wrapped — these flow into
// the breadcrumbs / headerActions memos, whose identity drives that re-render.
const onRequestDeleteTable = useCallback(() => setShowDeleteTableConfirm(true), [])
const onRequestImportCsv = useCallback(() => setIsImportCsvOpen(true), [])
// Used inside grid's `useCallback` deps — identity stability prevents the
// grid's `useCallback` from re-creating on every wrapper re-render.
const onRequestDeleteRows = useCallback((snapshots: DeletedRowSnapshot[]) => {
setDeletingRows(snapshots)
}, [])
const onRequestDeleteAllByFilter = useCallback(
(params: { excludeRowIds: string[]; estimatedCount: number }) => {
setDeletingAll(params)
},
[]
)
const onRequestDeleteColumns = useCallback((names: string[]) => {
setDeletingColumns(names)
}, [])
/**
* Sink populated by the grid: invoked from sidebar `onColumnRename` so the
* grid can rewrite its local `columnWidths` / `columnOrder` keys after a
* rename. The grid's render assigns to `current`; the wrapper forwards calls.
*/
const columnRenameSinkRef = useRef<((oldName: string, newName: string) => void) | null>(null)
const onColumnRename = (oldName: string, newName: string) => {
columnRenameSinkRef.current?.(oldName, newName)
}
/**
* Sink the grid populates with its post-row-delete cleanup (push undo,
* clear selection). The wrapper invokes after the row-delete modal's
* mutation succeeds.
*/
const afterDeleteRowsSinkRef = useRef<((snapshots: DeletedRowSnapshot[]) => void) | null>(null)
/** Sink the grid populates with its post-select-all-delete cleanup (clear selection). */
const afterDeleteAllSinkRef = useRef<(() => void) | null>(null)
/**
* Sink the grid populates with its full delete-columns cascade (per-column
* mutation, undo push, columnOrder + columnWidths cleanup). The wrapper's
* delete-columns confirmation modal invokes this on confirm.
*/
const confirmDeleteColumnsSinkRef = useRef<((names: string[]) => void) | null>(null)
/**
* Sink the grid populates with its `pushUndo({ type: 'rename-table', ... })`
* call so the wrapper's breadcrumb rename can register an undo entry on the
* grid's undo stack.
*/
const pushTableRenameUndoSinkRef = useRef<
((previousName: string, newName: string) => void) | null
>(null)
// Single source of truth for `useTable` — drives both the grid render and
// the wrapper's slideouts/modals. The grid receives the bundle as props.
const { tableData, columns, tableWorkflowGroups, workflows } = useTable({
workspaceId,
tableId,
queryOptions,
})
const runColumnMutation = useRunColumn({ workspaceId, tableId })
const cancelRunsMutation = useCancelTableRuns({ workspaceId, tableId })
const runColumnMutate = runColumnMutation.mutate
const cancelRunsMutate = cancelRunsMutation.mutate
// Canonical run dispatcher. Every UI gesture (column-header menu, per-row
// gutter, action-bar Play/Refresh, right-click context menu) reduces to a
// (groupIds, rowIds?, runMode) triple. Empty groupIds = no-op.
const runScope = useCallback(
(args: {
groupIds: string[]
rowIds?: string[]
filter?: Filter
excludeRowIds?: string[]
runMode: RunMode
limit?: RunLimit
source: 'row' | 'rows' | 'column'
}) => {
const { source, ...mutateArgs } = args
if (mutateArgs.groupIds.length === 0) return
if (mutateArgs.rowIds && mutateArgs.rowIds.length === 0) return
runColumnMutate(mutateArgs)
// Derive the run's deployment mode from the targeted groups (default 'live' when unset).
// 'mixed' when the targeted groups don't all agree.
const targetGroupIds = new Set(mutateArgs.groupIds)
const modes = new Set(
tableWorkflowGroups
.filter((g) => targetGroupIds.has(g.id))
.map((g) => g.deploymentMode ?? 'live')
)
const deploymentMode = modes.size === 1 ? [...modes][0] : 'mixed'
captureEvent(posthogRef.current, 'table_workflow_run', {
table_id: tableId,
workspace_id: workspaceId,
source,
run_mode: mutateArgs.runMode,
group_count: mutateArgs.groupIds.length,
row_count: mutateArgs.rowIds?.length ?? null,
has_limit: mutateArgs.limit != null,
deployment_mode: deploymentMode,
})
},
[runColumnMutate, tableId, workspaceId, tableWorkflowGroups]
)
const onRunColumn = useCallback(
(
groupId: string,
runMode: RunMode,
rowIds?: string[],
limit?: RunLimit,
filter?: Filter,
excludeRowIds?: string[]
) => {
runScope({
groupIds: [groupId],
rowIds,
filter,
excludeRowIds,
runMode,
limit,
source: 'column',
})
},
[runScope]
)
const onRunRows = useCallback(
(rowIds: string[] | undefined, runMode: RunMode, filter?: Filter, excludeRowIds?: string[]) => {
runScope({
groupIds: tableWorkflowGroups.map((g) => g.id),
rowIds,
filter,
excludeRowIds,
runMode,
source: 'rows',
})
},
[runScope, tableWorkflowGroups]
)
const onRunRow = useCallback(
(rowId: string) => {
runScope({
groupIds: tableWorkflowGroups.map((g) => g.id),
rowIds: [rowId],
runMode: 'incomplete',
source: 'row',
})
},
[runScope, tableWorkflowGroups]
)
// useCallback because <DataRow> is React.memo-wrapped — identity stability
// matters for per-row gutter Stop button.
const onStopRow = useCallback(
(rowId: string) => {
cancelRunsMutate({ scope: 'row', rowId })
captureEvent(posthogRef.current, 'table_workflow_stopped', {
table_id: tableId,
workspace_id: workspaceId,
scope: 'row',
row_count: 1,
})
},
[cancelRunsMutate, tableId, workspaceId]
)
const onStopRows = (rowIds: string[]) => {
if (rowIds.length === 0) return
for (const rowId of rowIds) {
cancelRunsMutate({ scope: 'row', rowId })
}
captureEvent(posthogRef.current, 'table_workflow_stopped', {
table_id: tableId,
workspace_id: workspaceId,
scope: 'rows',
row_count: rowIds.length,
})
}
// useCallback because <RunStatusControl> is memo-wrapped. Zero-arg on
// purpose — RunStatusControl passes it straight to onClick, which would
// otherwise leak the MouseEvent into `filter`.
const onStopAll = useCallback(() => {
cancelRunsMutate({ scope: 'all' })
captureEvent(posthogRef.current, 'table_workflow_stopped', {
table_id: tableId,
workspace_id: workspaceId,
scope: 'all',
row_count: null,
})
}, [cancelRunsMutate, tableId, workspaceId])
/** Select-all Stop — filter-scoped when a filter is active; deselected rows keep running. */
const onStopAllRows = useCallback(
(filter?: Filter, excludeRowIds?: string[]) => {
// `sort` scopes the optimistic flip to the active view's cache (filtered stops
// only cancel matching rows server-side).
cancelRunsMutate({ scope: 'all', filter, sort: queryOptions.sort, excludeRowIds })
captureEvent(posthogRef.current, 'table_workflow_stopped', {
table_id: tableId,
workspace_id: workspaceId,
scope: 'all',
row_count: null,
})
},
[cancelRunsMutate, tableId, workspaceId, queryOptions.sort]
)
const onSelectionChange = (next: SelectionSnapshot) => {
setSelection(next)
}
const renameTableMutation = useRenameTable(workspaceId)
const tableDataRef = useRef(tableData)
tableDataRef.current = tableData
const tableHeaderRename = useInlineRename({
onSave: (_id, name) => {
const data = tableDataRef.current
if (data) pushTableRenameUndoSinkRef.current?.(data.name, name)
return renameTableMutation.mutateAsync({ tableId, name })
},
})
const handleNavigateBack = useCallback(() => {
router.push(`/workspace/${workspaceId}/tables`)
}, [router, workspaceId])
const handleStartTableRename = useCallback(() => {
const data = tableDataRef.current
if (data) tableHeaderRename.startRename(tableId, data.name)
}, [tableHeaderRename.startRename, tableId])
const handleAddColumnOfType = (type: ColumnDefinition['type']) => {
onOpenColumnConfig({ mode: 'create', proposedName: generateColumnName(columns), type })
}
const handleAddWorkflowColumn = () => {
onOpenWorkflowConfig({
mode: 'create',
kind: 'manual',
proposedName: generateColumnName(columns),
})
}
const handleExportCsv = useCallback(async () => {
if (!tableData) return
try {
// Big tables export as a background job (the file downloads when the job completes via the
// SSE stream); small ones keep the instant synchronous stream. While a delete job runs,
// rowCount is a doomed-estimate-adjusted number — not ground truth — so always take the
// async path (safe at any size; exports bypass the one-job-per-table gate).
const deleteRunning = tableData.jobType === 'delete' && tableData.jobStatus === 'running'
if (deleteRunning || tableData.rowCount > TABLE_LIMITS.EXPORT_ASYNC_THRESHOLD_ROWS) {
await exportTableAsync.mutateAsync({ format: 'csv' })
toast.success('Export started — the download will begin when it finishes')
} else {
await downloadTableExport(tableData.id, tableData.name)
}
captureEvent(posthogRef.current, 'table_exported', {
table_id: tableData.id,
workspace_id: workspaceId,
})
} catch (err) {
logger.error('Failed to export table:', err)
toast.error('Failed to export table')
}
}, [tableData, workspaceId])
const columnOptions = useMemo<ColumnOption[]>(
() =>
columns.map((col) => ({
// `id` is the filter/sort field key (column id); `label` is what the user sees.
id: getColumnId(col),
label: col.name,
type: col.type,
icon: COLUMN_TYPE_ICONS[col.type],
})),
[columns]
)
const sortConfig = useMemo<SortConfig>(
() => ({
options: columnOptions,
active: sortColumn ? { column: sortColumn, direction: sortDirection } : null,
onSort: (column, direction) => setSortParams({ sort: column, dir: direction }),
/**
* Clearing writes the default direction (stripped by clearOnDefault) and
* drops the column, leaving a clean URL with no active sort.
*/
onClear: () => setSortParams({ sort: null, dir: DEFAULT_TABLE_DETAIL_SORT_DIRECTION }),
}),
[columnOptions, sortColumn, sortDirection, setSortParams]
)
const handleFilterApply = (next: Filter | null) => {
setFilter(next)
}
const breadcrumbs = useMemo(
(): BreadcrumbItem[] => [
{ label: 'Tables', onClick: handleNavigateBack },
// While the table loads, mirror this route's loading.tsx (terminal "…" crumb)
// so no empty-label / orphaned-chevron frame renders in between.
tableData
? {
label: tableData.name,
editing: tableHeaderRename.editingId
? {
isEditing: true,
value: tableHeaderRename.editValue,
onChange: tableHeaderRename.setEditValue,
onSubmit: tableHeaderRename.submitRename,
onCancel: tableHeaderRename.cancelRename,
}
: undefined,
dropdownItems: [
{
label: 'Rename',
icon: Pencil,
onClick: handleStartTableRename,
},
{
label: 'Delete',
icon: Trash,
onClick: onRequestDeleteTable,
},
],
}
: { label: '…', terminal: true },
],
[
handleNavigateBack,
tableData,
tableHeaderRename.editingId,
tableHeaderRename.editValue,
tableHeaderRename.setEditValue,
tableHeaderRename.submitRename,
tableHeaderRename.cancelRename,
handleStartTableRename,
onRequestDeleteTable,
]
)
const headerActions = useMemo(
() =>
tableData
? [
{
label: 'Import CSV',
icon: Upload,
onClick: onRequestImportCsv,
disabled: userPermissions.canEdit !== true,
},
{
label: 'Export CSV',
icon: Download,
onClick: () => void handleExportCsv(),
disabled: tableData.rowCount === 0,
},
]
: undefined,
[tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv]
)
const createTrigger = userPermissions.canEdit ? (
<NewColumnDropdown
trigger='header'
disabled={false}
onPickType={handleAddColumnOfType}
onPickWorkflow={handleAddWorkflowColumn}
onPickEnrichment={onOpenEnrichments}
/>
) : null
const logPanelWidth = useLogDetailsUIStore((state) => state.panelWidth)
const sidebarReservedPx =
slideout.kind === 'column' || slideout.kind === 'workflow' || slideout.kind === 'enrichments'
? COLUMN_SIDEBAR_WIDTH
: slideout.kind === 'execution' || slideout.kind === 'enrichment-details'
? logPanelWidth
: 0
const deleteTableMutation = useDeleteTable(workspaceId)
const deleteRowsAsyncMutation = useDeleteTableRowsAsync({ workspaceId, tableId })
const exportTableAsync = useExportTableAsync({ workspaceId, tableId })
const handleDeleteTable = async () => {
try {
await deleteTableMutation.mutateAsync(tableId)
setShowDeleteTableConfirm(false)
router.push(`/workspace/${workspaceId}/tables`)
} catch {
setShowDeleteTableConfirm(false)
}
}
const handleConfirmDeleteColumns = () => {
if (!deletingColumns) return
const names = deletingColumns
setDeletingColumns(null)
confirmDeleteColumnsSinkRef.current?.(names)
}
const columnConfig = slideout.kind === 'column' ? slideout.config : null
const workflowConfig = slideout.kind === 'workflow' ? slideout.config : null
const executionId = slideout.kind === 'execution' ? slideout.executionId : null
const enrichmentDetailsTarget = slideout.kind === 'enrichment-details' ? slideout : null
const enrichmentDetailsGroupName =
enrichmentDetailsTarget &&
tableWorkflowGroups.find((g) => g.id === enrichmentDetailsTarget.groupId)?.name
// Fetch the workflow log when the execution-details slideout is open. Reuses
// the logs page's <LogDetails> directly — no intermediate wrapper needed for
// a one-line query forward.
const { data: executionLog } = useLogByExecutionId(workspaceId, executionId)
// Stable identity so the memoized Resource.Options can bail — an inline
// object literal (with an inline arrow) would defeat its memo every render.
const handleToggleFilter = useCallback(() => setFilterOpen((prev) => !prev), [])
const filterConfig = useMemo(
() => ({
mode: 'toggle' as const,
active: filterOpen || !!queryOptions.filter,
onToggle: handleToggleFilter,
}),
[filterOpen, queryOptions.filter, handleToggleFilter]
)
return (
<Resource>
{!embedded && (
<Resource.Header
icon={TableIcon}
breadcrumbs={breadcrumbs}
aside={
<div className='flex items-center gap-1.5'>
<ImportProgressMenu workspaceId={workspaceId} tableId={tableId} />
{selection.totalRunning > 0 || selection.hasActiveDispatch ? (
<RunStatusControl
running={selection.totalRunning}
queueing={!selection.hasRunningCell}
onStopAll={onStopAll}
isStopping={cancelRunsMutation.isPending}
/>
) : null}
{headerActions?.map((action) => (
<Chip
key={action.label}
leftIcon={action.icon}
onClick={action.onClick}
disabled={action.disabled}
>
{action.label}
</Chip>
))}
{createTrigger}
</div>
}
/>
)}
{/* Sort + filter render in both modes. In embedded (mothership) mode there's no
Resource.Header, so the run/stop control rides in the options bar's `aside`
slot, just left of filter/sort. */}
<Resource.Options
sort={sortConfig}
filter={filterConfig}
aside={
embedded && (selection.totalRunning > 0 || selection.hasActiveDispatch) ? (
<RunStatusControl
running={selection.totalRunning}
queueing={!selection.hasRunningCell}
onStopAll={onStopAll}
isStopping={cancelRunsMutation.isPending}
/>
) : undefined
}
/>
{filterOpen && (
<TableFilter
columns={columns}
filter={queryOptions.filter}
onApply={handleFilterApply}
onClose={() => setFilterOpen(false)}
/>
)}
<TableGrid
workspaceId={workspaceId}
tableId={tableId}
embedded={embedded}
sidebarReservedPx={sidebarReservedPx}
onOpenColumnConfig={onOpenColumnConfig}
onOpenWorkflowConfig={onOpenWorkflowConfig}
onOpenEnrichments={onOpenEnrichments}
onOpenEnrichmentConfig={onOpenEnrichmentConfig}
onOpenExecutionDetails={onOpenExecutionDetails}
onOpenEnrichmentDetails={onOpenEnrichmentDetails}
onOpenRowModal={onOpenRowModal}
onRequestDeleteRows={onRequestDeleteRows}
onRequestDeleteAllByFilter={onRequestDeleteAllByFilter}
onRequestDeleteColumns={onRequestDeleteColumns}
onRunColumn={onRunColumn}
onRunRow={onRunRow}
onRunRows={onRunRows}
onStopRows={onStopRows}
onStopAllRows={onStopAllRows}
onStopRow={onStopRow}
onSelectionChange={onSelectionChange}
queryOptions={queryOptions}
columnRenameSinkRef={columnRenameSinkRef}
afterDeleteRowsSinkRef={afterDeleteRowsSinkRef}
afterDeleteAllSinkRef={afterDeleteAllSinkRef}
confirmDeleteColumnsSinkRef={confirmDeleteColumnsSinkRef}
pushTableRenameUndoSinkRef={pushTableRenameUndoSinkRef}
/>
{userPermissions.canEdit && (
<TableActionBar
selectedCellCount={
selection.selectedRunScope
? selection.selectedRunScope.groupIds.length * selection.selectedRunScope.rowCount
: 0
}
runningCount={selection.runningInActionBarSelection}
hasWorkflowColumns={selection.hasWorkflowColumns}
showPlay={selection.selectionStats.hasIncompleteOrFailed}
showRefresh={selection.selectionStats.hasCompleted}
onPlay={() => {
const scope = selection.selectedRunScope
if (!scope) return
runScope({
groupIds: scope.groupIds,
rowIds: scope.allRows ? undefined : scope.rowIds,
// `filter`/`excludeRowIds` are only populated on select-all.
filter: scope.filter,
excludeRowIds: scope.excludeRowIds,
runMode: 'incomplete',
source: 'rows',
})
}}
onRefresh={() => {
const scope = selection.selectedRunScope
if (!scope) return
runScope({
groupIds: scope.groupIds,
rowIds: scope.allRows ? undefined : scope.rowIds,
filter: scope.filter,
excludeRowIds: scope.excludeRowIds,
runMode: 'all',
source: 'rows',
})
}}
onStopWorkflows={() => {
const scope = selection.selectedRunScope
if (!scope) return
if (scope.allRows) {
scope.filter || scope.excludeRowIds?.length
? onStopAllRows(scope.filter, scope.excludeRowIds)
: onStopAll()
} else {
onStopRows(scope.rowIds)
}
}}
onViewExecution={
selection.singleWorkflowCell?.canViewExecution &&
selection.singleWorkflowCell.executionId
? () => {
const id = selection.singleWorkflowCell?.executionId
if (id) onOpenExecutionDetails(id)
}
: selection.singleWorkflowCell?.canViewEnrichment
? () => {
const cell = selection.singleWorkflowCell
if (cell) onOpenEnrichmentDetails(cell.rowId, cell.groupId)
}
: undefined
}
/>
)}
<ColumnConfigSidebar
config={columnConfig}
onClose={onCloseSlideout}
existingColumn={
columnConfig?.mode === 'edit'
? (columns.find((c) => getColumnId(c) === columnConfig.columnName) ?? null)
: null
}
workspaceId={workspaceId}
tableId={tableId}
onColumnRename={onColumnRename}
/>
<EnrichmentsSidebar
open={slideout.kind === 'enrichments'}
onClose={onCloseSlideout}
allColumns={columns}
workspaceId={workspaceId}
tableId={tableId}
editGroup={slideout.kind === 'enrichments' ? slideout.editGroup : undefined}
/>
<WorkflowSidebar
config={workflowConfig}
onClose={onCloseSlideout}
allColumns={columns}
workflowGroups={tableWorkflowGroups}
workflows={workflows}
workspaceId={workspaceId}
tableId={tableId}
onColumnRename={onColumnRename}
/>
<LogDetails
log={executionLog ?? null}
isOpen={Boolean(executionId)}
onClose={onCloseSlideout}
/>
<EnrichmentDetails
tableId={tableId}
rowId={enrichmentDetailsTarget?.rowId ?? null}
groupId={enrichmentDetailsTarget?.groupId ?? null}
groupName={enrichmentDetailsGroupName ?? undefined}
isOpen={Boolean(enrichmentDetailsTarget)}
onClose={onCloseSlideout}
/>
{tableData && (
<ImportCsvDialog
open={isImportCsvOpen}
onOpenChange={setIsImportCsvOpen}
workspaceId={workspaceId}
table={tableData}
/>
)}
{editingRow && tableData && (
<RowModal
mode='edit'
isOpen={true}
onClose={() => setEditingRow(null)}
table={tableData}
row={editingRow}
onSuccess={() => setEditingRow(null)}
/>
)}
{deletingRows.length > 0 && tableData && (
<RowModal
mode='delete'
isOpen={true}
onClose={() => setDeletingRows([])}
table={tableData}
rowIds={deletingRows.map((r) => r.rowId)}
onSuccess={() => {
afterDeleteRowsSinkRef.current?.(deletingRows)
setDeletingRows([])
}}
/>
)}
<ChipConfirmModal
open={deletingAll !== null}
onOpenChange={(open) => {
if (!open) setDeletingAll(null)
}}
srTitle='Delete rows'
title='Delete rows'
text={`Delete ${deletingAll ? deletingAll.estimatedCount.toLocaleString() : 0} ${
deletingAll?.estimatedCount === 1 ? 'row' : 'rows'
}${queryOptions.filter ? ' matching the current filter' : ''}? This can't be undone.`}
confirm={{
label: 'Delete',
pending: deleteRowsAsyncMutation.isPending,
pendingLabel: 'Deleting...',
onClick: () => {
if (!deletingAll) return
const { excludeRowIds, estimatedCount } = deletingAll
deleteRowsAsyncMutation.mutate({
filter: queryOptions.filter ?? undefined,
sort: queryOptions.sort,
excludeRowIds: excludeRowIds.length > 0 ? excludeRowIds : undefined,
estimatedCount,
})
// Clear at click so the header checkbox doesn't linger in its
// select-all state over the optimistically-emptied grid. If the
// kickoff fails the rows visibly return with an error toast —
// re-selecting is cheaper than a stale-looking selection.
afterDeleteAllSinkRef.current?.()
setDeletingAll(null)
},
}}
/>
<ChipConfirmModal
open={deletingColumns !== null}
onOpenChange={(open) => {
if (!open) setDeletingColumns(null)
}}
srTitle={
deletingColumns && deletingColumns.length > 1
? `Delete ${deletingColumns.length} Columns`
: 'Delete Column'
}
title={
deletingColumns && deletingColumns.length > 1
? `Delete ${deletingColumns.length} Columns`
: 'Delete Column'
}
text={[
'Are you sure you want to delete ',
deletingColumns && deletingColumns.length > 1
? { text: `${deletingColumns.length} columns`, bold: true }
: {
text:
(deletingColumns &&
columns.find((c) => getColumnId(c) === deletingColumns[0])?.name) ??
deletingColumns?.[0] ??
'this column',
bold: true,
},
'? ',
{
text: `This will remove all data in ${deletingColumns && deletingColumns.length > 1 ? 'these columns' : 'this column'}.`,
error: true,
},
' You can undo this action.',
]}
confirm={{
label: 'Delete',
onClick: handleConfirmDeleteColumns,
}}
/>
{!embedded && (
<ChipConfirmModal
open={showDeleteTableConfirm}
onOpenChange={setShowDeleteTableConfirm}
srTitle='Delete Table'
title='Delete Table'
text={[
'Are you sure you want to delete ',
{ text: tableData?.name ?? 'this table', bold: true },
'? ',
{ text: `All ${tableData?.rowCount ?? 0} rows will be removed.`, error: true },
' You can restore it from Recently Deleted in Settings.',
]}
confirm={{
label: 'Delete',
onClick: handleDeleteTable,
pending: deleteTableMutation.isPending,
pendingLabel: 'Deleting...',
}}
/>
)}
</Resource>
)
}
@@ -0,0 +1,38 @@
import type { Filter, Sort, TableRow } from '@/lib/table'
/**
* Reason the inline editor completed, used to determine navigation after save
*/
export type SaveReason = 'enter' | 'tab' | 'shift-tab' | 'blur'
/**
* Query options for filtering and sorting table data
*/
export interface QueryOptions {
filter: Filter | null
sort: Sort | null
}
/**
* State for the row context menu (right-click).
* When `row` is null and `rowIndex` is set, the menu targets an empty cell.
*/
export interface ContextMenuState {
isOpen: boolean
position: { x: number; y: number }
row: TableRow | null
rowIndex: number | null
columnName: string | null
}
/**
* Tracks which cell is currently being edited inline. `columnKey` distinguishes
* fanned-out workflow visual columns (which share the same `columnName`) — set
* when the interaction targets a specific visual column (e.g. expanded view),
* omitted for plain cells.
*/
export interface EditingCell {
rowId: string
columnName: string
columnKey?: string
}
@@ -0,0 +1,138 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
cleanCellValue,
dateValueToLocalParts,
displayToStorage,
formatValueForInput,
localPartsToDateValue,
storageToDisplay,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/utils'
describe('dateValueToLocalParts / localPartsToDateValue', () => {
it('splits calendar dates without a time part and round-trips', () => {
expect(dateValueToLocalParts('2026-07-06')).toEqual({ day: '2026-07-06', time: null })
expect(localPartsToDateValue('2026-07-06', null)).toBe('2026-07-06')
})
it('splits instants into their literal wall day/time — no zone conversion', () => {
expect(dateValueToLocalParts('2026-07-06T16:04:55-07:00')).toEqual({
day: '2026-07-06',
time: '16:04:55',
})
expect(dateValueToLocalParts('2026-07-06T23:04:55Z')).toEqual({
day: '2026-07-06',
time: '23:04:55',
})
expect(dateValueToLocalParts('2026-07-06T23:04:55.000Z')).toEqual({
day: '2026-07-06',
time: '23:04:55',
})
})
it('recombines parts stamping the given zone offset, keeping the wall time', () => {
expect(localPartsToDateValue('2026-07-06', '16:04:55', 'America/New_York')).toBe(
'2026-07-06T16:04:55-04:00'
)
expect(localPartsToDateValue('2026-07-09', '16:04:55', 'America/New_York')).toBe(
'2026-07-09T16:04:55-04:00'
)
expect(localPartsToDateValue('2026-07-06', '16:04', 'America/New_York')).toBe(
'2026-07-06T16:04:00-04:00'
)
})
it('returns null parts for unparseable values', () => {
expect(dateValueToLocalParts('garbage')).toEqual({ day: null, time: null })
expect(dateValueToLocalParts('')).toEqual({ day: null, time: null })
})
})
describe('displayToStorage', () => {
it('parses date-only display formats to calendar dates', () => {
expect(displayToStorage('07/06/2026')).toBe('2026-07-06')
expect(displayToStorage('7/6/2026')).toBe('2026-07-06')
expect(displayToStorage('2026-07-06')).toBe('2026-07-06')
expect(displayToStorage('7/6')).toBe(`${new Date().getFullYear()}-07-06`)
})
it('parses M/D/YYYY with a time to a wall time stamped with the given zone', () => {
expect(displayToStorage('07/06/2026 4:04 PM', 'America/New_York')).toBe(
'2026-07-06T16:04:00-04:00'
)
expect(displayToStorage('07/06/2026 4:04:55 PM', 'America/New_York')).toBe(
'2026-07-06T16:04:55-04:00'
)
expect(displayToStorage('07/06/2026 16:04', 'America/New_York')).toBe(
'2026-07-06T16:04:00-04:00'
)
expect(displayToStorage('07/06/2026 12:00 AM', 'America/New_York')).toBe(
'2026-07-06T00:00:00-04:00'
)
})
it('preserves the wall time and offset of canonical and offset strings', () => {
expect(displayToStorage('2026-07-06T16:04:55-07:00')).toBe('2026-07-06T16:04:55-07:00')
expect(displayToStorage('2026-07-06T23:04:55.000Z')).toBe('2026-07-06T23:04:55Z')
expect(displayToStorage('2026-07-06 16:04:55 PDT')).toBe('2026-07-06T16:04:55-07:00')
})
it('rejects invalid dates and times', () => {
expect(displayToStorage('13/06/2026')).toBeNull()
expect(displayToStorage('07/06/2026 25:00')).toBeNull()
expect(displayToStorage('07/06/2026 13:00 PM')).toBeNull()
expect(displayToStorage('02/30/2026 5:00 PM')).toBeNull()
expect(displayToStorage('02/30/2026')).toBeNull()
expect(displayToStorage('2/30')).toBeNull()
expect(displayToStorage('garbage')).toBeNull()
})
})
describe('storageToDisplay', () => {
it('renders calendar dates as MM/DD/YYYY', () => {
expect(storageToDisplay('2026-07-06')).toBe('07/06/2026')
})
it('renders the literal wall time identically regardless of viewer or offset', () => {
expect(storageToDisplay('2026-07-06T16:04:55-07:00', { seconds: true })).toBe(
'07/06/2026 4:04:55 PM'
)
expect(storageToDisplay('2026-07-06T16:04:55+09:00', { seconds: true })).toBe(
'07/06/2026 4:04:55 PM'
)
})
it('round-trips an instant through the editor draft format without shifting', () => {
const stored = displayToStorage('07/06/2026 4:04:55 PM', 'America/New_York') as string
const draft = storageToDisplay(stored, { seconds: true })
expect(draft).toBe('07/06/2026 4:04:55 PM')
expect(displayToStorage(draft, 'America/New_York')).toBe(stored)
})
})
describe('cleanCellValue', () => {
it('normalizes date cells to canonical storage', () => {
const column = { name: 'due', type: 'date' } as const
expect(cleanCellValue('07/06/2026', column)).toBe('2026-07-06')
expect(cleanCellValue('2026-07-06T16:04:55-07:00', column)).toBe('2026-07-06T16:04:55-07:00')
expect(cleanCellValue('nope', column)).toBeNull()
expect(cleanCellValue('', column)).toBeNull()
})
it('leaves non-date types on their existing contracts', () => {
expect(cleanCellValue('2024', { name: 'n', type: 'number' } as const)).toBe(2024)
expect(cleanCellValue('true', { name: 'b', type: 'boolean' } as const)).toBe(true)
})
})
describe('formatValueForInput', () => {
it('gives editors the canonical value, surfacing legacy UTC midnights as calendar days', () => {
expect(formatValueForInput('2026-07-06T00:00:00.000Z', 'date')).toBe('2026-07-06')
expect(formatValueForInput('2026-07-06T16:04:55-07:00', 'date')).toBe(
'2026-07-06T16:04:55-07:00'
)
expect(formatValueForInput('2026-07-06', 'date')).toBe('2026-07-06')
})
})
@@ -0,0 +1,199 @@
import type { ColumnDefinition } from '@/lib/table'
import {
formatDateCellDisplay,
getWallClockParts,
normalizeDateCellValue,
storedDateToEditable,
} from '@/lib/table/dates'
type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray'
/**
* Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by
* both the page-header and inline-header "New column" dropdowns.
*/
export function generateColumnName(columns: ReadonlyArray<{ name: string }>): string {
const existing = new Set(columns.map((c) => c.name.toLowerCase()))
let name = 'untitled'
let i = 2
while (existing.has(name.toLowerCase())) {
name = `untitled_${i}`
i++
}
return name
}
/**
* Returns the appropriate badge color variant for a column type
*/
export function getTypeBadgeVariant(type: string): BadgeVariant {
switch (type) {
case 'string':
return 'green'
case 'number':
return 'blue'
case 'boolean':
return 'purple'
case 'json':
return 'orange'
case 'date':
return 'teal'
default:
return 'gray'
}
}
/**
* Coerce a raw input value to the appropriate type for a column.
* Throws on invalid JSON.
*/
export function cleanCellValue(
value: unknown,
column: ColumnDefinition,
timeZone?: string
): unknown {
if (column.type === 'number') {
if (value === '') return null
const num = Number(value)
return Number.isNaN(num) ? null : num
}
if (column.type === 'json') {
if (typeof value === 'string') {
if (value === '') return null
return JSON.parse(value)
}
return value
}
if (column.type === 'boolean') {
return Boolean(value)
}
if (column.type === 'date') {
if (value === '' || value === null || value === undefined) return null
return displayToStorage(String(value), timeZone)
}
return value || null
}
/**
* Format a stored value for display in an input field. Defensive against
* shape drift: a column whose declared type lags its actual data (e.g. a
* workflow column mid-remap, where the schema cache hasn't refetched but
* row data already has the new mapping's value) would otherwise render
* `[object Object]` via `String(value)`.
*/
export function formatValueForInput(value: unknown, type: string): string {
if (value === null || value === undefined) return ''
if (type === 'json') {
return typeof value === 'string' ? value : JSON.stringify(value)
}
if (type === 'date' && value) {
return storedDateToEditable(String(value))
}
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
}
/** A canonical date-cell value split into its wall-clock editing parts. */
export interface DateCellLocalParts {
/** Calendar day `YYYY-MM-DD`, or null when the value is unparseable. */
day: string | null
/** Time-of-day `HH:mm:ss`, or null for calendar-date values. */
time: string | null
}
/**
* Splits a canonical date-cell value into the day and time the date/time
* pickers edit — the value's **literal wall time**, no timezone conversion
* (display and editing are wall-clock-faithful for every viewer). Calendar
* dates have no time part; legacy strings normalize first.
*/
export function dateValueToLocalParts(value: string): DateCellLocalParts {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return { day: value, time: null }
const wall = value.match(
/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::(\d{2}))?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/
)
if (wall) return { day: wall[1], time: `${wall[2]}:${wall[3] ?? '00'}` }
const canonical = normalizeDateCellValue(value)
if (!canonical || canonical === value) return { day: null, time: null }
return dateValueToLocalParts(canonical)
}
/**
* Recombines picker-edited parts into a canonical date-cell value: a calendar
* date when there is no time, else that wall time stamped with the given
* zone's offset (runtime-local when omitted).
*/
export function localPartsToDateValue(day: string, time: string | null, timeZone?: string): string {
if (!time) return day
return normalizeDateCellValue(`${day}T${time}`, { timezone: timeZone }) ?? day
}
/** Today's calendar day as `YYYY-MM-DD` in the given zone (runtime-local when omitted). */
export function todayLocalCalendarDate(timeZone?: string): string {
const wall = getWallClockParts(new Date(), timeZone)
const pad = (n: number) => String(n).padStart(2, '0')
return `${wall.year}-${pad(wall.month)}-${pad(wall.day)}`
}
/**
* Format a stored date-cell value for display: calendar dates as MM/DD/YYYY,
* instants as their literal wall time `MM/DD/YYYY h:mm AM/PM` — identical
* for every viewer. Pass `seconds: true` for editor drafts so re-saving an
* untouched cell keeps second precision.
*/
export function storageToDisplay(stored: string, options?: { seconds?: boolean }): string {
return formatDateCellDisplay(stored, options)
}
/**
* Parse a date-cell input string to its canonical storage form: `YYYY-MM-DD`
* for date-only inputs (MM/DD/YYYY, MM/DD, ISO), an offset-preserved instant
* for inputs carrying a time. Naive times are stamped with the offset of
* `timeZone` (the writer's effective timezone; the runtime's zone when
* omitted). Returns null when unparseable.
*/
export function displayToStorage(display: string, timeZone?: string): string | null {
const trimmed = display.trim()
const withTime = trimmed.match(
/^(\d{1,2})\/(\d{1,2})\/(\d{4})[ ,]+(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(AM|PM))?$/i
)
if (withTime) {
const [, m, d, y, h, min, sec, meridiem] = withTime
let hours = Number(h)
if (meridiem) {
if (hours < 1 || hours > 12) return null
hours = (hours % 12) + (meridiem.toUpperCase() === 'PM' ? 12 : 0)
} else if (hours > 23) {
return null
}
if (Number(min) > 59 || Number(sec ?? 0) > 59) return null
if (!isValidCalendarDay(Number(y), Number(m), Number(d))) return null
const pad = (n: string) => n.padStart(2, '0')
// Route through the shared normalizer so the wall time resolves in the
// effective zone.
return normalizeDateCellValue(
`${y}-${pad(m)}-${pad(d)}T${String(hours).padStart(2, '0')}:${min}:${sec ?? '00'}`,
{ timezone: timeZone }
)
}
const full = trimmed.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
if (full) {
if (!isValidCalendarDay(Number(full[3]), Number(full[1]), Number(full[2]))) return null
return `${full[3]}-${full[1].padStart(2, '0')}-${full[2].padStart(2, '0')}`
}
const partial = trimmed.match(/^(\d{1,2})\/(\d{1,2})$/)
if (partial) {
const year = Number(todayLocalCalendarDate(timeZone).slice(0, 4))
if (!isValidCalendarDay(year, Number(partial[1]), Number(partial[2]))) return null
return `${year}-${partial[1].padStart(2, '0')}-${partial[2].padStart(2, '0')}`
}
return normalizeDateCellValue(trimmed, { timezone: timeZone })
}
/** True when Y/M/D is a real calendar day — `Date` rolls impossible days over
* (02/30 → 03/02) instead of rejecting them, so compare the round-trip. */
function isValidCalendarDay(year: number, month: number, day: number): boolean {
if (month < 1 || month > 12 || day < 1 || day > 31) return false
const check = new Date(year, month - 1, day)
return check.getMonth() === month - 1 && check.getDate() === day
}
@@ -0,0 +1,525 @@
'use client'
import { useMemo, useRef, useState } from 'react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
ChipCombobox,
ChipModal,
ChipModalBody,
ChipModalError,
ChipModalField,
ChipModalFooter,
ChipModalHeader,
type ComboboxOption,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
toast,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES } from '@/lib/table/constants'
import { buildAutoMapping, parseCsvBuffer } from '@/lib/table/import'
import type { TableDefinition } from '@/lib/table/types'
import {
type CsvImportMode,
cancelTableJob,
useImportCsvIntoTable,
useImportCsvIntoTableAsync,
} from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('ImportCsvDialog')
const MAX_SAMPLE_ROWS = 5
const MAX_EXAMPLES_IN_ERROR = 3
/**
* Bytes read for the preview/mapping. We never parse the whole file client-side — the importer
* streams it server-side and the DB row-count trigger enforces the row limit.
*/
const CSV_PREVIEW_BYTES = 512 * 1024
/**
* Sentinel value for the "Do not import" option in the mapping combobox. The
* whitespace is intentional: valid column names must match `NAME_PATTERN`
* (`/^[a-z_][a-z0-9_]*$/i`), so no real column can share this value.
*/
const SKIP_VALUE = '__ skip __'
/**
* Sentinel for the "Create new column" option. Same whitespace trick as
* `SKIP_VALUE` to avoid colliding with any valid column name.
*/
const CREATE_VALUE = '__ create __'
/**
* Converts the verbose backend error messages into a short, human-friendly
* summary suitable for the modal footer. Specifically collapses repeated
* `Row N: Column "X" must be unique. Value "Y" already exists in row M`
* segments into a single concise summary.
*/
function summarizeImportError(message: string): string {
const uniqueMatches = [
...message.matchAll(/Column\s+"([^"]+)"\s+must be unique\.\s+Value\s+"([^"]+)"/g),
]
if (uniqueMatches.length > 0) {
const column = uniqueMatches[0][1]
const values = Array.from(new Set(uniqueMatches.map((m) => m[2])))
const preview = values
.slice(0, MAX_EXAMPLES_IN_ERROR)
.map((v) => `"${v}"`)
.join(', ')
const extra = values.length - MAX_EXAMPLES_IN_ERROR
const suffix = extra > 0 ? `, +${extra} more` : ''
return `${values.length} row${values.length === 1 ? '' : 's'} conflict on unique column "${column}" (${preview}${suffix})`
}
const requiredMatch = message.match(/missing required columns?:\s*(.+)/i)
if (requiredMatch) {
return `Missing required column(s): ${requiredMatch[1].replace(/[.;]+$/, '')}`
}
const rowLimitMatch = message.match(/row limit[^.;]*/i)
if (rowLimitMatch) {
return rowLimitMatch[0].trim()
}
const trimmed = message.trim()
if (trimmed.length > 180) return truncate(trimmed, 177)
return trimmed
}
interface ImportCsvDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
workspaceId: string
table: TableDefinition
onImported?: (result: { insertedCount?: number; deletedCount?: number }) => void
}
interface ParsedCsv {
file: File
headers: string[]
sampleRows: Record<string, unknown>[]
}
/** Parses the head of a CSV/TSV for the mapping + sample, dropping any truncated final line. */
async function parseCsvPreview(file: File, delimiter: ',' | '\t') {
const sliced = file.size > CSV_PREVIEW_BYTES
const blob = sliced ? file.slice(0, CSV_PREVIEW_BYTES) : file
let bytes = new Uint8Array(await blob.arrayBuffer())
if (sliced) {
const lastNewline = bytes.lastIndexOf(0x0a)
if (lastNewline > 0) bytes = bytes.subarray(0, lastNewline + 1)
}
return parseCsvBuffer(bytes, delimiter)
}
export function ImportCsvDialog({
open,
onOpenChange,
workspaceId,
table,
onImported,
}: ImportCsvDialogProps) {
const [parsed, setParsed] = useState<ParsedCsv | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const [submitError, setSubmitError] = useState<string | null>(null)
const [parsing, setParsing] = useState(false)
const [mapping, setMapping] = useState<Record<string, string | null>>({})
const [createHeaders, setCreateHeaders] = useState<Set<string>>(new Set())
const [mode, setMode] = useState<CsvImportMode>('append')
const importMutation = useImportCsvIntoTable()
const importAsyncMutation = useImportCsvIntoTableAsync()
function resetState() {
setParsed(null)
setParseError(null)
setSubmitError(null)
setMapping({})
setCreateHeaders(new Set())
setMode('append')
setParsing(false)
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) resetState()
onOpenChange(newOpen)
}
const prevTableIdRef = useRef(table.id)
if (prevTableIdRef.current !== table.id) {
prevTableIdRef.current = table.id
resetState()
}
const columnOptions: ComboboxOption[] = useMemo(() => {
const options: ComboboxOption[] = [
{ label: 'Do not import', value: SKIP_VALUE },
{ label: '+ Create new column', value: CREATE_VALUE },
]
for (const col of table.schema.columns) {
options.push({
label: col.required ? `${col.name} (required)` : col.name,
value: col.name,
})
}
return options
}, [table.schema.columns])
async function handleFileSelected(file: File) {
const ext = file.name.split('.').pop()?.toLowerCase()
if (ext !== 'csv' && ext !== 'tsv') {
setParseError('Only CSV and TSV files are supported')
return
}
setParsing(true)
setParseError(null)
try {
const delimiter: ',' | '\t' = ext === 'tsv' ? '\t' : ','
const { headers, rows } = await parseCsvPreview(file, delimiter)
const autoMapping = buildAutoMapping(headers, table.schema)
setParsed({
file,
headers,
sampleRows: rows.slice(0, MAX_SAMPLE_ROWS),
})
setMapping(autoMapping)
} catch (err) {
const message = getErrorMessage(err, 'Failed to parse CSV')
logger.error('CSV parse failed', err)
setParseError(message)
} finally {
setParsing(false)
}
}
function handleFilesSelected(files: File[]) {
const file = files[0]
if (file) void handleFileSelected(file)
}
function handleMappingChange(header: string, value: string) {
setSubmitError(null)
if (value === CREATE_VALUE) {
setCreateHeaders((prev) => {
const next = new Set(prev)
next.add(header)
return next
})
setMapping((prev) => ({ ...prev, [header]: null }))
return
}
setCreateHeaders((prev) => {
if (!prev.has(header)) return prev
const next = new Set(prev)
next.delete(header)
return next
})
setMapping((prev) => ({
...prev,
[header]: value === SKIP_VALUE ? null : value,
}))
}
function handleCreateAllUnmapped() {
if (!parsed) return
setSubmitError(null)
setCreateHeaders((prev) => {
const next = new Set(prev)
for (const header of parsed.headers) {
if (!mapping[header] && !next.has(header)) next.add(header)
}
return next
})
}
function handleModeChange(value: string) {
setSubmitError(null)
setMode(value as CsvImportMode)
}
const { missingRequired, duplicateTargets, mappedCount, skipCount, createCount } = useMemo(() => {
const mappedTargets = new Map<string, string[]>()
let mapped = 0
let skipped = 0
let creating = 0
for (const header of parsed?.headers ?? []) {
if (createHeaders.has(header)) {
creating++
continue
}
const target = mapping[header]
if (!target) {
skipped++
continue
}
mapped++
const existing = mappedTargets.get(target) ?? []
existing.push(header)
mappedTargets.set(target, existing)
}
const dupes = [...mappedTargets.entries()]
.filter(([, headers]) => headers.length > 1)
.map(([col]) => col)
const mappedSet = new Set(mappedTargets.keys())
const missing = table.schema.columns
.filter((c) => c.required && !mappedSet.has(c.name))
.map((c) => c.name)
return {
missingRequired: missing,
duplicateTargets: dupes,
mappedCount: mapped,
skipCount: skipped,
createCount: creating,
}
}, [mapping, parsed?.headers, table.schema.columns, createHeaders])
const canSubmit =
parsed !== null &&
!importMutation.isPending &&
!importAsyncMutation.isPending &&
missingRequired.length === 0 &&
duplicateTargets.length === 0 &&
mappedCount + createCount > 0
async function handleSubmit() {
if (!parsed || !canSubmit) return
setSubmitError(null)
const createColumns = createHeaders.size > 0 ? [...createHeaders] : undefined
// Large files can't be POSTed through the server (request-body cap) — upload them
// straight to storage and import in the background instead. Seed the header tray and
// close the dialog immediately so the indicator is visible during the upload, then run
// the upload + kickoff in the background (don't block the dialog on it).
if (parsed.file.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES) {
useImportTrayStore.getState().startUpload({
uploadId: table.id,
workspaceId,
title: parsed.file.name,
})
onOpenChange(false)
toast.success(`Importing "${parsed.file.name}" into "${table.name}" in the background`)
importAsyncMutation.mutate(
{
workspaceId,
tableId: table.id,
file: parsed.file,
mode,
mapping,
createColumns,
onProgress: (percent) => {
useImportTrayStore.getState().setUploadPercent(table.id, percent)
},
},
{
onSuccess: (data) => {
useImportTrayStore.getState().endUpload(table.id)
// The server row drives the tray once the list refetches. If canceled mid-upload, flag
// the id so it's not shown and cancel the worker server-side.
if (useImportTrayStore.getState().consumeCanceled(table.id) && data?.importId) {
useImportTrayStore.getState().cancel(table.id)
void cancelTableJob(workspaceId, table.id, data.importId).catch(() => {})
}
},
onError: () => {
// The hook's onError surfaces the toast; just clear the tray indicator here.
useImportTrayStore.getState().endUpload(table.id)
},
}
)
return
}
try {
const result = await importMutation.mutateAsync({
workspaceId,
tableId: table.id,
file: parsed.file,
mode,
mapping,
createColumns,
})
const data = result.data
if (mode === 'append') {
toast.success(`Imported ${data?.insertedCount ?? 0} rows into "${table.name}"`)
} else {
toast.success(
`Replaced rows in "${table.name}": deleted ${data?.deletedCount ?? 0}, inserted ${data?.insertedCount ?? 0}`
)
}
onImported?.({
insertedCount: data?.insertedCount,
deletedCount: data?.deletedCount,
})
onOpenChange(false)
} catch (err) {
const message = getErrorMessage(err, 'Failed to import CSV')
setSubmitError(summarizeImportError(message))
logger.error('CSV import into existing table failed', err)
}
}
const hasWarning = missingRequired.length > 0 || duplicateTargets.length > 0
return (
<ChipModal
open={open}
onOpenChange={handleOpenChange}
srTitle={`Import CSV into ${table.name}`}
size='lg'
>
<ChipModalHeader onClose={() => handleOpenChange(false)}>
Import CSV into {table.name}
</ChipModalHeader>
<ChipModalBody>
{!parsed ? (
<ChipModalField
type='file'
title='Import CSV'
accept='.csv,.tsv'
disabled={parsing}
onChange={handleFilesSelected}
label={parsing ? 'Parsing...' : 'Drop CSV or TSV here or click to browse'}
description='Map columns to append or replace rows in this table'
error={parseError ?? undefined}
/>
) : (
<>
<ChipModalField type='custom' title='File'>
<div className='flex items-center justify-between gap-3 rounded-sm border border-[var(--border)] p-2'>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-primary)] text-caption'>
{parsed.file.name}
</span>
<span className='text-[var(--text-tertiary)] text-xs'>
{parsed.headers.length} columns
</span>
</div>
<Button variant='ghost' size='sm' onClick={resetState}>
Change file
</Button>
</div>
</ChipModalField>
<ChipModalField type='custom' title='Mode'>
<ButtonGroup value={mode} onValueChange={handleModeChange}>
<ButtonGroupItem value='append'>Append</ButtonGroupItem>
<ButtonGroupItem value='replace'>Replace all rows</ButtonGroupItem>
</ButtonGroup>
</ChipModalField>
<ChipModalField type='custom' title='Column mapping'>
{skipCount > 0 && (
<div className='flex justify-end'>
<Button variant='ghost' size='sm' onClick={handleCreateAllUnmapped}>
Create columns for {skipCount} unmapped
</Button>
</div>
)}
<div className='overflow-hidden rounded-sm border border-[var(--border)]'>
<div className='max-h-[320px] overflow-auto'>
<Table>
<TableHeader>
<TableRow>
<TableHead>CSV column</TableHead>
<TableHead>Target column</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{parsed.headers.map((header) => {
const sample = parsed.sampleRows
.map((r) =>
r[header] === '' || r[header] == null ? '' : String(r[header])
)
.filter(Boolean)
.slice(0, 2)
.join(', ')
return (
<TableRow key={header}>
<TableCell>
<div className='flex min-w-0 flex-col'>
<span className='truncate text-[var(--text-primary)]'>
{header}
</span>
{sample && (
<span className='truncate text-[var(--text-tertiary)] text-xs'>
{sample}
</span>
)}
</div>
</TableCell>
<TableCell>
<ChipCombobox
options={columnOptions}
value={
createHeaders.has(header)
? CREATE_VALUE
: (mapping[header] ?? SKIP_VALUE)
}
onChange={(value) => handleMappingChange(header, value)}
className='w-full'
/>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
<span className='text-[var(--text-tertiary)] text-xs'>
{mappedCount} mapped
{createCount > 0
? ` · ${createCount} new column${createCount === 1 ? '' : 's'}`
: ''}
{' · '}
{skipCount} skipped
</span>
</ChipModalField>
{missingRequired.length > 0 && (
<ChipModalError>
Missing required column(s): {missingRequired.join(', ')}
</ChipModalError>
)}
{duplicateTargets.length > 0 && (
<ChipModalError>
Multiple CSV columns target: {duplicateTargets.join(', ')} (pick one)
</ChipModalError>
)}
{mode === 'replace' && !hasWarning && (
<ChipModalError>
Replace will permanently delete the {table.rowCount.toLocaleString()} existing
row(s) before inserting the new rows.
</ChipModalError>
)}
<ChipModalError title={submitError ?? undefined}>{submitError}</ChipModalError>
</>
)}
</ChipModalBody>
<ChipModalFooter
onCancel={() => onOpenChange(false)}
cancelDisabled={importMutation.isPending}
primaryAction={{
label: importMutation.isPending
? mode === 'replace'
? 'Replacing...'
: 'Importing...'
: mode === 'replace'
? 'Replace rows'
: 'Append rows',
onClick: handleSubmit,
disabled: !canSubmit,
variant: mode === 'replace' ? 'destructive' : 'primary',
}}
/>
</ChipModal>
)
}
@@ -0,0 +1 @@
export * from './import-csv-dialog'
@@ -0,0 +1,117 @@
'use client'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
ProgressItem,
toast,
} from '@sim/emcn'
import { CircleAlert, CircleCheck, Loader } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { cancelTableJob, downloadExportResult } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
import { getImportStage } from './import-stage'
import { type ImportRow, useWorkspaceImports } from './use-workspace-imports'
const logger = createLogger('ImportProgressMenu')
interface ImportProgressMenuProps {
workspaceId: string | undefined
/** When mounted inside a specific table's header, the indicator is scoped to that table. */
tableId?: string
}
/**
* Header affordance for background table jobs: a clickable `{done}/{total}` count that opens a
* dropdown of per-job progress rows — CSV imports and exports (a ready export row carries a
* Download action). Renders nothing when there are no jobs. The single job-progress surface for
* both the tables list and the in-table view.
*/
export function ImportProgressMenu({ workspaceId, tableId }: ImportProgressMenuProps) {
const imports = useWorkspaceImports(workspaceId, tableId)
const dismiss = useImportTrayStore((state) => state.dismiss)
const dismissJob = useImportTrayStore((state) => state.dismissJob)
const cancelId = useImportTrayStore((state) => state.cancel)
const menuOpen = useImportTrayStore((state) => state.menuOpen)
const setMenuOpen = useImportTrayStore((state) => state.setMenuOpen)
if (imports.length === 0) return null
const total = imports.length
const done = imports.filter((e) => e.phase === 'ready').length
const anyRunning = imports.some((e) => e.phase === 'importing')
const anyFailed = imports.some((e) => e.phase === 'failed')
const cancel = (row: ImportRow) => {
cancelId(row.id)
// Worker already running — cancel it server-side now. (An upload still mid-flight is canceled by
// the kickoff handler once its jobId is known; see the `consumeCanceled` branches.)
if (row.jobId) {
void cancelTableJob(row.workspaceId, row.tableId, row.jobId).catch(() => {})
}
}
const download = (row: ImportRow) => {
if (!row.jobId) return
void downloadExportResult(row.workspaceId, row.tableId, row.jobId).catch((err) => {
logger.error('Export download failed', { jobId: row.jobId, err })
toast.error('Download failed — the export may have expired')
})
}
return (
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<Button variant='subtle' className='px-2 py-1 text-caption'>
{/* Aggregate state, mirroring the row iconography: spinner while anything runs, then
alert if any job failed, else a check. */}
{anyRunning ? (
<Loader animate className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
) : anyFailed ? (
<CircleAlert className='mr-1.5 size-[14px] text-[var(--text-error)]' />
) : (
<CircleCheck className='mr-1.5 size-[14px] text-[var(--text-icon)]' />
)}
<span className='tabular-nums'>
{done}/{total}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='min-w-[320px] max-w-[420px] gap-0 p-1'>
{imports.map((row) => {
const stage = getImportStage(row)
const isReadyExport = row.jobType === 'export' && row.phase === 'ready' && row.hasResult
return (
<ProgressItem
key={row.id}
status={stage.status}
title={stage.title}
meta={stage.meta}
detail={
isReadyExport ? (
<button
type='button'
className='text-[var(--brand-primary)] hover-hover:underline'
onClick={() => download(row)}
>
Download
</button>
) : (
stage.detail
)
}
onCancel={row.phase === 'importing' ? () => cancel(row) : undefined}
onDismiss={
stage.dismissible
? () => (row.jobType === 'export' ? dismissJob(row.id) : dismiss(row.id))
: undefined
}
/>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,84 @@
import type { ImportRow } from './use-workspace-imports'
type ProgressStatus = 'pending' | 'success' | 'error'
/** Uniform view model for a tray entry — every stage fills the same slots. */
export interface ImportStageView {
status: ProgressStatus
/** Primary line: `{status} {name}`, e.g. `Processing data.csv`. */
title: string
/** Right-aligned on the title row: the percent (when known). */
meta?: string
/** Secondary line: the row count, or the error message on failure. */
detail?: string
dismissible: boolean
}
/**
* Maps a tray entry to the stage shown in the import dropdown. The single place the import
* stages (Uploading → Processing → Imported / Failed) are defined; the row component just
* renders the returned slots, so every stage looks consistent: `{status} {name}`. While
* uploading, the right slot shows the byte-based upload percent (from the client XHR). Once the
* server is processing we only know the committed row count (polled from the table row), so the
* detail line reads `{rows} rows` with no percent.
*/
export function getImportStage(entry: ImportRow): ImportStageView {
const rows = entry.rowsProcessed.toLocaleString()
const name = entry.title
const meta = typeof entry.percent === 'number' ? `${entry.percent}%` : undefined
if (entry.jobType === 'export') {
if (entry.phase === 'failed') {
return {
status: 'error',
title: `Export failed for ${name}`,
detail: entry.error ?? 'Something went wrong',
dismissible: true,
}
}
if (entry.phase === 'ready') {
// The menu replaces `detail` with a Download action for ready exports.
return {
status: 'success',
title: `Exported ${name}`,
detail: `${rows} rows`,
dismissible: true,
}
}
return {
status: 'pending',
title: `Exporting ${name}`,
detail: `${rows} rows`,
dismissible: false,
}
}
if (entry.phase === 'failed') {
return {
status: 'error',
title: `Failed ${name}`,
detail: entry.error ?? 'Something went wrong',
dismissible: true,
}
}
if (entry.phase === 'ready') {
return {
status: 'success',
title: `Imported ${name}`,
detail: `${rows} rows`,
dismissible: true,
}
}
// importing: rows only start arriving once the worker is processing; before that it's the upload.
if (entry.rowsProcessed > 0) {
return {
status: 'pending',
title: `Processing ${name}`,
detail: `${rows} rows`,
dismissible: false,
}
}
return { status: 'pending', title: `Uploading ${name}`, meta, dismissible: false }
}
@@ -0,0 +1 @@
export * from './import-progress-menu'
@@ -0,0 +1,185 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { toast } from '@sim/emcn'
import { useShallow } from 'zustand/react/shallow'
import { useTablesList, useWorkspaceExportJobs } from '@/hooks/queries/tables'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
const READY_AUTO_CLEAR_MS = 6000
const POLL_INTERVAL_MS = 2000
export type ImportPhase = 'importing' | 'ready' | 'failed'
/** A row rendered in the job tray. Import rows come live from the table list (uploads are
* client-only until their server row exists); export rows come from the workspace export-jobs
* query. Delete/backfill jobs are intentionally excluded — the grid reflects them directly. */
export interface ImportRow {
/** Table id for imports/uploads; job id for exports (a table can have several exports). */
id: string
/** The table the job belongs to — cancel and download need it for export rows. */
tableId: string
workspaceId: string
title: string
phase: ImportPhase
jobType: 'import' | 'export'
rowsProcessed: number
/** Upload byte percent (upload phase only). */
percent?: number
error?: string
jobId?: string
/** Export rows: whether the generated file is downloadable. */
hasResult?: boolean
}
/**
* Single source for the import tray. Importing rows are derived live from the table list (polled
* while any job is in flight) rather than mirrored into a store; the store only supplies
* optimistic uploads and which terminal completions to surface this session. Also fires the
* completion toasts on the importing → terminal transition. Delete jobs never render as tray rows
* and only surface a toast on failure (a failed delete restores the optimistically-removed rows).
*/
export function useWorkspaceImports(
workspaceId: string | undefined,
scopeTableId?: string
): ImportRow[] {
const { data: tables } = useTablesList(workspaceId, 'active', {
refetchInterval: (list) =>
list?.some((t) => t.jobStatus === 'running') ? POLL_INTERVAL_MS : false,
})
// Exports are excluded from the table-level job derivation (they run concurrently with other
// jobs), so the tray reads them from their dedicated workspace listing.
const { data: exportJobs } = useWorkspaceExportJobs(workspaceId)
const prevStatusRef = useRef<Map<string, string> | null>(null)
useEffect(() => {
if (!tables) return
const prevStatus = (prevStatusRef.current ??= new Map())
const store = useImportTrayStore.getState()
for (const table of tables) {
const before = prevStatus.get(table.id)
const now = table.jobStatus ?? 'none'
if (before === 'running' && now === 'ready') {
// Success toast only for imports — deletes reflect instantly in the grid and backfills
// live-fill cells; announcing them would be noise.
if (table.jobType === 'import') {
const rows = (table.jobRowsProcessed ?? 0).toLocaleString()
toast.success(`Imported ${rows} rows into "${table.name}"`)
store.notify(table.id)
setTimeout(() => useImportTrayStore.getState().dismiss(table.id), READY_AUTO_CLEAR_MS)
}
} else if (before === 'running' && now === 'failed') {
// Surface every failure — e.g. a failed delete restores the optimistically-removed rows,
// and a failed backfill leaves cells unfilled; the user should know why.
const fallback =
table.jobType === 'delete'
? `Delete failed for "${table.name}"`
: table.jobType === 'backfill'
? `Column backfill failed for "${table.name}"`
: `Import failed for "${table.name}"`
toast.error(table.jobError || fallback)
if (table.jobType === 'import') store.notify(table.id)
}
if (now !== 'running' && store.isCanceled(table.id)) store.consumeCanceled(table.id)
prevStatus.set(table.id, now)
}
}, [tables])
const uploads = useImportTrayStore(useShallow((s) => Object.values(s.uploads)))
const notified = useImportTrayStore((s) => s.notified)
const canceledIds = useImportTrayStore((s) => s.canceledIds)
const dismissedIds = useImportTrayStore((s) => s.dismissedIds)
return useMemo(() => {
const rows: ImportRow[] = []
const seen = new Set<string>()
for (const table of tables ?? []) {
if (scopeTableId && table.id !== scopeTableId) continue
// Of the table-derived jobs, only imports render here: deletes reflect optimistically in
// the grid and backfills live-fill cells via SSE. (Exports merge in below.)
if (table.jobType !== 'import') continue
if (table.jobStatus === 'running') {
if (canceledIds[table.id]) continue
rows.push({
id: table.id,
tableId: table.id,
workspaceId: table.workspaceId,
title: table.name,
phase: 'importing',
jobType: 'import',
rowsProcessed: table.jobRowsProcessed ?? 0,
jobId: table.jobId ?? undefined,
})
seen.add(table.id)
} else if (
(table.jobStatus === 'ready' || table.jobStatus === 'failed') &&
notified[table.id]
) {
rows.push({
id: table.id,
tableId: table.id,
workspaceId: table.workspaceId,
title: table.name,
phase: table.jobStatus,
jobType: 'import',
rowsProcessed: table.jobRowsProcessed ?? 0,
error: table.jobError ?? undefined,
})
seen.add(table.id)
}
}
for (const upload of uploads) {
if (upload.workspaceId !== workspaceId) continue
if (scopeTableId && upload.uploadId !== scopeTableId) continue
if (canceledIds[upload.uploadId] || seen.has(upload.uploadId)) continue
rows.push({
id: upload.uploadId,
tableId: upload.uploadId,
workspaceId: upload.workspaceId,
title: upload.title,
phase: 'importing',
jobType: 'import',
rowsProcessed: 0,
percent: upload.percent,
})
}
// Export rows: running ones always; terminal ready stays listed (re-downloadable) until the
// server's visibility window lapses or the user dismisses it. Keyed by jobId.
for (const job of exportJobs ?? []) {
if (!workspaceId) break
if (scopeTableId && job.tableId !== scopeTableId) continue
if (job.status === 'canceled' || canceledIds[job.jobId] || dismissedIds[job.jobId]) continue
if (job.status === 'running') {
rows.push({
id: job.jobId,
tableId: job.tableId,
workspaceId,
title: job.tableName,
phase: 'importing',
jobType: 'export',
rowsProcessed: job.rowsProcessed,
jobId: job.jobId,
})
} else {
rows.push({
id: job.jobId,
tableId: job.tableId,
workspaceId,
title: job.tableName,
phase: job.status === 'ready' ? 'ready' : 'failed',
jobType: 'export',
rowsProcessed: job.rowsProcessed,
jobId: job.jobId,
hasResult: job.hasResult,
error: job.error ?? undefined,
})
}
}
rows.sort((a, b) => (a.phase === b.phase ? 0 : a.phase === 'importing' ? -1 : 1))
return rows
}, [tables, exportJobs, uploads, notified, canceledIds, dismissedIds, workspaceId, scopeTableId])
}
@@ -0,0 +1,4 @@
export * from './import-csv-dialog'
export * from './import-progress-menu'
export * from './table-context-menu'
export * from './tables-list-context-menu'
@@ -0,0 +1 @@
export { TableContextMenu } from './table-context-menu'
@@ -0,0 +1,110 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Upload,
} from '@sim/emcn'
import { Database, Download, Duplicate, Pencil, Trash } from '@sim/emcn/icons'
interface TableContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCopyId?: () => void
onDelete?: () => void
onViewSchema?: () => void
onRename?: () => void
onImportCsv?: () => void
onExportCsv?: () => void
disableDelete?: boolean
disableRename?: boolean
disableImport?: boolean
disableExport?: boolean
menuRef?: React.RefObject<HTMLDivElement | null>
}
export function TableContextMenu({
isOpen,
position,
onClose,
onCopyId,
onDelete,
onViewSchema,
onRename,
onImportCsv,
onExportCsv,
disableDelete = false,
disableRename = false,
disableImport = false,
disableExport = false,
}: TableContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onViewSchema && (
<DropdownMenuItem onSelect={onViewSchema}>
<Database />
View Schema
</DropdownMenuItem>
)}
{onRename && (
<DropdownMenuItem disabled={disableRename} onSelect={onRename}>
<Pencil />
Rename
</DropdownMenuItem>
)}
{onImportCsv && (
<DropdownMenuItem disabled={disableImport} onSelect={onImportCsv}>
<Upload />
Import CSV
</DropdownMenuItem>
)}
{onExportCsv && (
<DropdownMenuItem disabled={disableExport} onSelect={onExportCsv}>
<Download />
Export CSV
</DropdownMenuItem>
)}
{(onViewSchema || onRename || onImportCsv || onExportCsv) && (onCopyId || onDelete) && (
<DropdownMenuSeparator />
)}
{onCopyId && (
<DropdownMenuItem onSelect={onCopyId}>
<Duplicate />
Copy ID
</DropdownMenuItem>
)}
{onCopyId && onDelete && <DropdownMenuSeparator />}
{onDelete && (
<DropdownMenuItem disabled={disableDelete} onSelect={onDelete}>
<Trash />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1 @@
export { TablesListContextMenu } from './tables-list-context-menu'
@@ -0,0 +1,68 @@
'use client'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Upload,
} from '@sim/emcn'
import { Plus } from '@sim/emcn/icons'
interface TablesListContextMenuProps {
isOpen: boolean
position: { x: number; y: number }
onClose: () => void
onCreateTable?: () => void
onUploadCsv?: () => void
disableCreate?: boolean
disableUpload?: boolean
}
export function TablesListContextMenu({
isOpen,
position,
onClose,
onCreateTable,
onUploadCsv,
disableCreate = false,
disableUpload = false,
}: TablesListContextMenuProps) {
return (
<DropdownMenu open={isOpen} onOpenChange={(open) => !open && onClose()} modal={false}>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'fixed',
left: `${position.x}px`,
top: `${position.y}px`,
width: '1px',
height: '1px',
pointerEvents: 'none',
}}
tabIndex={-1}
aria-hidden
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align='start'
side='bottom'
sideOffset={4}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{onCreateTable && (
<DropdownMenuItem disabled={disableCreate} onSelect={onCreateTable}>
<Plus />
Create table
</DropdownMenuItem>
)}
{onUploadCsv && (
<DropdownMenuItem disabled={disableUpload} onSelect={onUploadCsv}>
<Upload />
Import CSV
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}
@@ -0,0 +1,15 @@
'use client'
import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
export default function TablesError({ error, reset }: ErrorBoundaryProps) {
return (
<ErrorState
error={error}
reset={reset}
title='Failed to load tables'
description='Something went wrong while loading the tables. Please try again.'
loggerName='TablesError'
/>
)
}
@@ -0,0 +1,36 @@
'use client'
import { Plus, Upload } from '@sim/emcn'
import { Table as TableIcon } from '@sim/emcn/icons'
import {
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
const COLUMNS = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
const ACTIONS: ChromeActionSpec[] = [
{ text: 'Import CSV', icon: Upload },
{ text: 'New table', icon: Plus, variant: 'primary' },
]
export default function TablesLoading() {
return (
<ResourceChromeFallback
icon={TableIcon}
title='Tables'
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search tables...'
hasSort
hasFilter
/>
)
}
@@ -0,0 +1,32 @@
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 TablesLoading from '@/app/workspace/[workspaceId]/tables/loading'
import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch'
import { Tables } from './tables'
export const metadata: Metadata = {
title: 'Tables',
}
/**
* Tables page entry. `Tables` reads URL query params via nuqs (which uses
* `useSearchParams` internally), so it must sit under a Suspense boundary. The
* fallback renders the real chrome so a suspend never shows a blank frame; the
* route-level `loading.tsx` covers the navigation/chunk-load transition.
*/
export default async function TablesPage({ params }: { params: Promise<{ workspaceId: string }> }) {
const { workspaceId } = await params
const queryClient = getQueryClient()
await prefetchTables(queryClient, workspaceId)
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<TablesLoading />}>
<Tables />
</Suspense>
</HydrationBoundary>
)
}
@@ -0,0 +1,26 @@
import type { QueryClient } from '@tanstack/react-query'
import type { TableDefinition } from '@/lib/table'
import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch'
import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys'
/**
* Prefetches the workspace's tables list under the same query key the client
* `useTablesList` hook uses (scope `active`), so the list paints populated on
* first render.
*
* Table definitions carry `Date` fields, so the list goes through the
* `/api/table` route and caches the serialized wire shape — see
* {@link prefetchInternalJson}.
*/
export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise<void> {
await queryClient.prefetchQuery({
queryKey: tableKeys.list(workspaceId, 'active'),
queryFn: async () => {
const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>(
`/api/table?workspaceId=${workspaceId}&scope=active`
)
return response.data.tables
},
staleTime: TABLE_LIST_STALE_TIME,
})
}
@@ -0,0 +1,47 @@
import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
/** Sortable table columns, matching the `Resource.Options` sort menu. */
export const TABLE_SORT_COLUMNS = [
'name',
'columns',
'rows',
'created',
'owner',
'updated',
] as const
export type TableSortColumn = (typeof TABLE_SORT_COLUMNS)[number]
const SORT_DIRECTIONS = ['asc', 'desc'] as const
/** Default sort: most-recently-updated first. */
export const DEFAULT_TABLE_SORT_COLUMN: TableSortColumn = 'updated'
export const DEFAULT_TABLE_SORT_DIRECTION = 'desc'
/**
* Co-located, typed URL query-param definitions for the Tables list.
*
* - `search` is the table name filter. The input is controlled directly by the
* nuqs value; only its URL write is debounced via `limitUrlUpdates`
* (`debounce`) on the setter — never written on every keystroke.
* - `sort` / `dir` follow the shared sort convention (two scalar params).
* - `rows` filters by row-count bucket; `owner` filters by creator id. Both are
* multi-select arrays.
*
* Selecting a table navigates to the `tables/[tableId]` route (via `router`),
* so the active table is route state, not query state, and is intentionally not
* represented here.
*/
export const tablesParsers = {
search: parseAsString.withDefault(''),
sort: parseAsStringLiteral(TABLE_SORT_COLUMNS).withDefault(DEFAULT_TABLE_SORT_COLUMN),
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault(DEFAULT_TABLE_SORT_DIRECTION),
rows: parseAsArrayOf(parseAsString).withDefault([]),
owner: parseAsArrayOf(parseAsString).withDefault([]),
} as const
/** Filter/search/sort view-state: clean URLs, no back-stack churn. */
export const tablesUrlKeys = {
history: 'replace',
clearOnDefault: true,
} as const
@@ -0,0 +1,735 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ComboboxOption } from '@sim/emcn'
import { ChipCombobox, ChipConfirmModal, Plus, toast, Upload } from '@sim/emcn'
import { Columns3, Rows3, Table as TableIcon } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { useParams, useRouter } from 'next/navigation'
import { debounce, useQueryStates } from 'nuqs'
import type { TableDefinition } from '@/lib/table'
import { CSV_ASYNC_IMPORT_THRESHOLD_BYTES, generateUniqueTableName } from '@/lib/table/constants'
import type {
FilterTag,
ResourceAction,
ResourceColumn,
ResourceRow,
SearchConfig,
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { ownerCell, Resource, timeCell } from '@/app/workspace/[workspaceId]/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import {
ImportCsvDialog,
ImportProgressMenu,
TablesListContextMenu,
} from '@/app/workspace/[workspaceId]/tables/components'
import { TableContextMenu } from '@/app/workspace/[workspaceId]/tables/components/table-context-menu'
import {
DEFAULT_TABLE_SORT_COLUMN,
DEFAULT_TABLE_SORT_DIRECTION,
TABLE_SORT_COLUMNS,
type TableSortColumn,
tablesParsers,
tablesUrlKeys,
} from '@/app/workspace/[workspaceId]/tables/search-params'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import {
cancelTableJob,
downloadTableExport,
useCreateTable,
useDeleteTable,
useImportCsvAsync,
useRenameTable,
useTablesList,
useUploadCsvToTable,
} from '@/hooks/queries/tables'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useInlineRename } from '@/hooks/use-inline-rename'
import { usePermissionConfig } from '@/hooks/use-permission-config'
import { useImportTrayStore } from '@/stores/table/import-tray/store'
const logger = createLogger('Tables')
/** Debounce window for `search` URL writes; the input itself stays instant. */
const SEARCH_DEBOUNCE_MS = 300 as const
const COLUMNS: ResourceColumn[] = [
{ id: 'name', header: 'Name' },
{ id: 'columns', header: 'Columns' },
{ id: 'rows', header: 'Rows' },
{ id: 'created', header: 'Created' },
{ id: 'owner', header: 'Owner' },
{ id: 'updated', header: 'Last Updated' },
]
export function Tables() {
const params = useParams()
const router = useRouter()
const workspaceId = params.workspaceId as string
const { config: permissionConfig } = usePermissionConfig()
useEffect(() => {
if (permissionConfig.hideTablesTab) {
router.replace(`/workspace/${workspaceId}`)
}
}, [permissionConfig.hideTablesTab, router, workspaceId])
const userPermissions = useUserPermissionsContext()
const { data: tables = [], error } = useTablesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
if (error) {
logger.error('Failed to load tables:', error)
}
const deleteTable = useDeleteTable(workspaceId)
const renameTable = useRenameTable(workspaceId)
const createTable = useCreateTable(workspaceId)
const uploadCsv = useUploadCsvToTable()
const importCsvAsync = useImportCsvAsync()
const tableRename = useInlineRename({
onSave: (tableId, name) => renameTable.mutateAsync({ tableId, name }),
})
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [activeTable, setActiveTable] = useState<TableDefinition | null>(null)
const [
{
search: urlSearchTerm,
sort: sortColumn,
dir: sortDirection,
rows: rowCountFilter,
owner: ownerFilter,
},
setTableFilters,
] = useQueryStates(tablesParsers, tablesUrlKeys)
/**
* The input is controlled directly by the instant nuqs value; only the URL
* write is debounced. The in-memory filter below still reads a debounced value
* so it doesn't recompute on every keystroke.
*/
const setSearchTerm = useCallback(
(value: string) => {
const trimmed = value.trim()
const next = trimmed.length > 0 ? trimmed : null
setTableFilters(
{ search: next },
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
)
},
[setTableFilters]
)
const debouncedSearchTerm = useDebounce(urlSearchTerm, 300)
/**
* The resolved sort is exposed to the sort menu only when it differs from the
* default, mirroring the prior `null`-means-default semantics.
*/
const activeSort = useMemo(
() =>
sortColumn === DEFAULT_TABLE_SORT_COLUMN && sortDirection === DEFAULT_TABLE_SORT_DIRECTION
? null
: { column: sortColumn, direction: sortDirection },
[sortColumn, sortDirection]
)
const setRowCountFilter = useCallback(
(next: string[]) => setTableFilters({ rows: next }),
[setTableFilters]
)
const setOwnerFilter = useCallback(
(next: string[]) => setTableFilters({ owner: next }),
[setTableFilters]
)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const uploading = uploadProgress.total > 0
const csvInputRef = useRef<HTMLInputElement>(null)
const {
isOpen: isListContextMenuOpen,
position: listContextMenuPosition,
handleContextMenu: handleListContextMenu,
closeMenu: closeListContextMenu,
} = useContextMenu()
const {
isOpen: isRowContextMenuOpen,
position: rowContextMenuPosition,
handleContextMenu: handleRowCtxMenu,
closeMenu: closeRowContextMenu,
} = useContextMenu()
const processedTables = useMemo(() => {
let result = debouncedSearchTerm
? tables.filter((t) => t.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()))
: tables
if (rowCountFilter.length > 0) {
result = result.filter((t) => {
if (rowCountFilter.includes('empty') && t.rowCount === 0) return true
if (rowCountFilter.includes('small') && t.rowCount >= 1 && t.rowCount <= 100) return true
if (rowCountFilter.includes('large') && t.rowCount > 100) return true
return false
})
}
if (ownerFilter.length > 0) {
result = result.filter((t) => ownerFilter.includes(t.createdBy))
}
const col = activeSort?.column ?? 'updated'
const dir = activeSort?.direction ?? 'desc'
return [...result].sort((a, b) => {
let cmp = 0
switch (col) {
case 'name':
cmp = a.name.localeCompare(b.name)
break
case 'columns':
cmp = a.schema.columns.length - b.schema.columns.length
break
case 'rows':
cmp = a.rowCount - b.rowCount
break
case 'created':
cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
break
case 'updated':
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
break
case 'owner': {
const aName = members?.find((m) => m.userId === a.createdBy)?.name ?? ''
const bName = members?.find((m) => m.userId === b.createdBy)?.name ?? ''
cmp = aName.localeCompare(bName)
break
}
}
return dir === 'asc' ? cmp : -cmp
})
}, [tables, debouncedSearchTerm, rowCountFilter, ownerFilter, activeSort, members])
const rows: ResourceRow[] = useMemo(
() =>
processedTables.map((table) => ({
id: table.id,
cells: {
name: {
icon: <TableIcon className='size-[14px]' />,
label: table.name,
editing:
tableRename.editingId === table.id
? {
value: tableRename.editValue,
onChange: tableRename.setEditValue,
onSubmit: tableRename.submitRename,
onCancel: tableRename.cancelRename,
}
: undefined,
},
columns: {
icon: <Columns3 className='size-[14px]' />,
label: String(table.schema.columns.length),
},
rows: {
icon: <Rows3 className='size-[14px]' />,
label: String(table.rowCount),
},
created: timeCell(table.createdAt),
owner: ownerCell(table.createdBy, members),
updated: timeCell(table.updatedAt),
},
})),
[
processedTables,
members,
tableRename.editingId,
tableRename.editValue,
tableRename.setEditValue,
tableRename.submitRename,
tableRename.cancelRename,
]
)
const searchConfig: SearchConfig = useMemo(
() => ({
value: urlSearchTerm,
onChange: setSearchTerm,
onClearAll: () => setSearchTerm(''),
placeholder: 'Search tables...',
}),
[urlSearchTerm, setSearchTerm]
)
const sortConfig: SortConfig = useMemo(
() => ({
options: [
{ id: 'name', label: 'Name' },
{ id: 'columns', label: 'Columns' },
{ id: 'rows', label: 'Rows' },
{ id: 'created', label: 'Created' },
{ id: 'owner', label: 'Owner' },
{ id: 'updated', label: 'Last Updated' },
],
active: activeSort,
onSort: (column, direction) => {
const sort = (TABLE_SORT_COLUMNS as readonly string[]).includes(column)
? (column as TableSortColumn)
: DEFAULT_TABLE_SORT_COLUMN
setTableFilters({ sort, dir: direction })
},
onClear: () =>
setTableFilters({
sort: DEFAULT_TABLE_SORT_COLUMN,
dir: DEFAULT_TABLE_SORT_DIRECTION,
}),
}),
[activeSort, setTableFilters]
)
const rowCountDisplayLabel = useMemo(() => {
if (rowCountFilter.length === 0) return 'All'
if (rowCountFilter.length === 1) {
const labels: Record<string, string> = {
empty: 'Empty',
small: 'Small (1100)',
large: 'Large (101+)',
}
return labels[rowCountFilter[0]] ?? rowCountFilter[0]
}
return `${rowCountFilter.length} selected`
}, [rowCountFilter])
const ownerDisplayLabel = useMemo(() => {
if (ownerFilter.length === 0) return 'All'
if (ownerFilter.length === 1)
return members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'
return `${ownerFilter.length} members`
}, [ownerFilter, members])
const memberOptions: ComboboxOption[] = useMemo(
() =>
(members ?? []).map((m) => ({
value: m.userId,
label: m.name,
iconElement: m.image ? (
<img
src={m.image}
alt={m.name}
referrerPolicy='no-referrer'
className='size-[14px] rounded-full border border-[var(--border)] object-cover'
/>
) : (
<span className='flex size-[14px] items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface-3)] font-medium text-[8px] text-[var(--text-secondary)]'>
{m.name.charAt(0).toUpperCase()}
</span>
),
})),
[members]
)
const hasActiveFilters = rowCountFilter.length > 0 || ownerFilter.length > 0
const filterContent = useMemo(
() => (
<div className='flex w-[240px] flex-col gap-3 p-3'>
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Row Count</span>
<ChipCombobox
options={[
{ value: 'empty', label: 'Empty' },
{ value: 'small', label: 'Small (1100 rows)' },
{ value: 'large', label: 'Large (101+ rows)' },
]}
multiSelect
multiSelectValues={rowCountFilter}
onMultiSelectChange={setRowCountFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{rowCountDisplayLabel}</span>
}
showAllOption
allOptionLabel='All'
className='w-full'
/>
</div>
{memberOptions.length > 0 && (
<div className='flex flex-col gap-1.5'>
<span className='font-medium text-[var(--text-secondary)] text-caption'>Owner</span>
<ChipCombobox
options={memberOptions}
multiSelect
multiSelectValues={ownerFilter}
onMultiSelectChange={setOwnerFilter}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{ownerDisplayLabel}</span>
}
searchable
searchPlaceholder='Search members...'
showAllOption
allOptionLabel='All'
className='w-full'
/>
</div>
)}
{hasActiveFilters && (
<button
type='button'
onClick={() => {
setRowCountFilter([])
setOwnerFilter([])
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Clear all filters
</button>
)}
</div>
),
[
rowCountFilter,
ownerFilter,
memberOptions,
rowCountDisplayLabel,
ownerDisplayLabel,
hasActiveFilters,
]
)
const filterTags: FilterTag[] = useMemo(() => {
const tags: FilterTag[] = []
if (rowCountFilter.length > 0) {
const rowLabels: Record<string, string> = { empty: 'Empty', small: 'Small', large: 'Large' }
const label =
rowCountFilter.length === 1
? `Rows: ${rowLabels[rowCountFilter[0]]}`
: `Rows: ${rowCountFilter.length} selected`
tags.push({ label, onRemove: () => setRowCountFilter([]) })
}
if (ownerFilter.length > 0) {
const label =
ownerFilter.length === 1
? `Owner: ${members?.find((m) => m.userId === ownerFilter[0])?.name ?? '1 member'}`
: `Owner: ${ownerFilter.length} members`
tags.push({ label, onRemove: () => setOwnerFilter([]) })
}
return tags
}, [rowCountFilter, ownerFilter, members])
const handleContentContextMenu = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement
if (
target.closest('[data-resource-row]') ||
target.closest('button, input, a, [role="button"]')
) {
return
}
handleListContextMenu(e)
},
[handleListContextMenu]
)
const handleRowClick = useCallback(
(rowId: string) => {
if (!isRowContextMenuOpen) {
router.push(`/workspace/${workspaceId}/tables/${rowId}`)
}
},
[isRowContextMenuOpen, router, workspaceId]
)
const handleRowContextMenu = useCallback(
(e: React.MouseEvent, rowId: string) => {
const table = tables.find((t) => t.id === rowId) ?? null
setActiveTable(table)
handleRowCtxMenu(e)
},
[tables, handleRowCtxMenu]
)
const handleDelete = async () => {
if (!activeTable) return
try {
await deleteTable.mutateAsync(activeTable.id)
setIsDeleteDialogOpen(false)
setActiveTable(null)
} catch (err) {
logger.error('Failed to delete table:', err)
}
}
const handleCsvChange = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const list = e.target.files
if (!list || list.length === 0 || !workspaceId) return
const csvFiles = Array.from(list).filter((f) => {
const ext = f.name.split('.').pop()?.toLowerCase()
return ext === 'csv' || ext === 'tsv'
})
if (csvFiles.length === 0) {
toast.error('No CSV or TSV files selected')
if (csvInputRef.current) csvInputRef.current.value = ''
return
}
// Large files can't be POSTed through the server (request-body cap) — upload them
// straight to storage and import in the background. These are tracked by the import
// tray, never the header upload button, so don't touch uploading/uploadProgress here.
const asyncFiles = csvFiles.filter((f) => f.size >= CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
const syncFiles = csvFiles.filter((f) => f.size < CSV_ASYNC_IMPORT_THRESHOLD_BYTES)
try {
for (const file of asyncFiles) {
// Show the indicator immediately under a temporary id (the real table id doesn't
// exist until kickoff returns), then let the tray track it. Don't redirect — the
// table is still empty/importing, so stay on the list.
const pendingId = `pending_${generateId()}`
useImportTrayStore
.getState()
.startUpload({ uploadId: pendingId, workspaceId, title: file.name })
toast.success(`Importing "${file.name}" in the background`)
try {
const result = await importCsvAsync.mutateAsync({
workspaceId,
file,
onProgress: (percent) => {
useImportTrayStore.getState().setUploadPercent(pendingId, percent)
},
})
useImportTrayStore.getState().endUpload(pendingId)
// The server row drives the tray once the list refetches (mutation invalidates it).
// If canceled mid-upload, flag the real id so it's not shown and cancel server-side.
if (
result?.tableId &&
result.importId &&
useImportTrayStore.getState().consumeCanceled(pendingId)
) {
useImportTrayStore.getState().cancel(result.tableId)
void cancelTableJob(workspaceId, result.tableId, result.importId).catch(() => {})
}
} catch {
// The hook's onError surfaces the toast; just clear the tray indicator here.
useImportTrayStore.getState().endUpload(pendingId)
}
}
if (syncFiles.length === 0) return
setUploadProgress({ completed: 0, total: syncFiles.length })
const failed: string[] = []
for (let i = 0; i < syncFiles.length; i++) {
const file = syncFiles[i]
try {
const result = await uploadCsv.mutateAsync({ workspaceId, file })
if (syncFiles.length === 1 && asyncFiles.length === 0) {
const tableId = result?.data?.table?.id
if (tableId) {
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
}
}
} catch (err) {
failed.push(file.name)
logger.error('Error uploading CSV:', err)
} finally {
setUploadProgress({ completed: i + 1, total: syncFiles.length })
}
}
if (failed.length > 0) {
toast.error(
failed.length === 1
? `Failed to import ${failed[0]}`
: `Failed to import ${failed.length} file${failed.length > 1 ? 's' : ''}: ${failed.join(', ')}`
)
}
} catch (err) {
logger.error('Error uploading CSV:', err)
toast.error('Failed to import CSV')
} finally {
setUploadProgress({ completed: 0, total: 0 })
if (csvInputRef.current) {
csvInputRef.current.value = ''
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5
[workspaceId, router]
)
const handleListUploadCsv = useCallback(() => {
csvInputRef.current?.click()
closeListContextMenu()
}, [closeListContextMenu])
const uploadButtonLabel = uploading
? `${uploadProgress.completed}/${uploadProgress.total}`
: 'Import CSV'
// `mutateAsync` is stable in TanStack Query v5 — extract it so the callback
// can list it as a dep instead of the unstable mutation object.
const createTableAsync = createTable.mutateAsync
const handleCreateTable = useCallback(async () => {
const existingNames = tables.map((t) => t.name)
const name = generateUniqueTableName(existingNames)
try {
const result = await createTableAsync({
name,
schema: {
columns: [{ name: 'name', type: 'string' }],
},
initialRowCount: 1,
})
const tableId = result?.data?.table?.id
if (tableId) {
router.push(`/workspace/${workspaceId}/tables/${tableId}`)
}
} catch (err) {
logger.error('Failed to create table:', err)
}
}, [tables, router, workspaceId, createTableAsync])
const headerActions: ResourceAction[] = useMemo(
() => [
{
text: uploadButtonLabel,
icon: Upload,
onSelect: () => csvInputRef.current?.click(),
disabled: uploading || userPermissions.canEdit !== true,
},
{
text: 'New table',
icon: Plus,
onSelect: handleCreateTable,
disabled: uploading || userPermissions.canEdit !== true || createTable.isPending,
variant: 'primary',
},
],
[
uploadButtonLabel,
uploading,
userPermissions.canEdit,
handleCreateTable,
createTable.isPending,
]
)
// Stable identities so the memoized Resource.Header / Resource.Options can
// actually bail — inline object/element props would defeat their memo.
const headerAside = useMemo(() => <ImportProgressMenu workspaceId={workspaceId} />, [workspaceId])
const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent])
return (
<>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header
icon={TableIcon}
title='Tables'
actions={headerActions}
aside={headerAside}
/>
<Resource.Options
search={searchConfig}
sort={sortConfig}
filterTags={filterTags}
filter={filterConfig}
/>
<Resource.Table
columns={COLUMNS}
rows={rows}
onRowClick={handleRowClick}
onRowContextMenu={handleRowContextMenu}
/>
</Resource>
<input
ref={csvInputRef}
type='file'
className='hidden'
onChange={handleCsvChange}
disabled={uploading}
accept='.csv,.tsv'
multiple
/>
<TablesListContextMenu
isOpen={isListContextMenuOpen}
position={listContextMenuPosition}
onClose={closeListContextMenu}
onCreateTable={handleCreateTable}
onUploadCsv={handleListUploadCsv}
disableCreate={userPermissions.canEdit !== true || createTable.isPending}
disableUpload={uploading || userPermissions.canEdit !== true}
/>
<TableContextMenu
isOpen={isRowContextMenuOpen}
position={rowContextMenuPosition}
onClose={closeRowContextMenu}
onCopyId={() => {
if (activeTable) navigator.clipboard.writeText(activeTable.id)
}}
onDelete={() => setIsDeleteDialogOpen(true)}
onRename={() => {
if (activeTable) tableRename.startRename(activeTable.id, activeTable.name)
}}
onImportCsv={() => setIsImportDialogOpen(true)}
onExportCsv={async () => {
if (!activeTable) return
try {
await downloadTableExport(activeTable.id, activeTable.name)
} catch (err) {
logger.error('Failed to export table:', err)
toast.error('Failed to export table')
}
}}
disableDelete={userPermissions.canEdit !== true}
disableRename={userPermissions.canEdit !== true}
disableImport={userPermissions.canEdit !== true}
/>
{activeTable && (
<ImportCsvDialog
open={isImportDialogOpen}
onOpenChange={(open) => {
setIsImportDialogOpen(open)
if (!open) setActiveTable(null)
}}
workspaceId={workspaceId}
table={activeTable}
/>
)}
<ChipConfirmModal
open={isDeleteDialogOpen}
onOpenChange={(open) => {
setIsDeleteDialogOpen(open)
if (!open) setActiveTable(null)
}}
srTitle='Delete Table'
title='Delete Table'
text={[
'Are you sure you want to delete ',
{ text: activeTable?.name ?? 'this table', bold: true },
'? ',
{ text: `All ${activeTable?.rowCount ?? 0} rows will be removed.`, error: true },
' You can restore it from Recently Deleted in Settings.',
]}
confirm={{
label: 'Delete',
onClick: handleDelete,
pending: deleteTable.isPending,
pendingLabel: 'Deleting...',
}}
/>
</>
)
}