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
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:
+233
@@ -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>
|
||||
)
|
||||
}
|
||||
+35
@@ -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')
|
||||
+8
@@ -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'
|
||||
+173
@@ -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'
|
||||
+388
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { EnrichmentDetails } from './enrichment-details'
|
||||
+382
@@ -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>
|
||||
)
|
||||
}
|
||||
+176
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -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'
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { NewColumnDropdown } from './new-column-dropdown'
|
||||
+92
@@ -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'
|
||||
+290
@@ -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}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { RunStatusControl } from './run-status-control'
|
||||
+52
@@ -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>
|
||||
)
|
||||
})
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { FieldError, RequiredLabel } from './sidebar-fields'
|
||||
+30
@@ -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>
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TableActionBar } from './table-action-bar'
|
||||
+169
@@ -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'
|
||||
+218
@@ -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: '',
|
||||
}
|
||||
}
|
||||
+73
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
+516
@@ -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)
|
||||
}
|
||||
+284
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+4
@@ -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'
|
||||
+332
@@ -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} />
|
||||
}
|
||||
+103
@@ -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>
|
||||
)
|
||||
}
|
||||
+25
@@ -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)]'
|
||||
+400
@@ -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)
|
||||
+360
@@ -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>
|
||||
)
|
||||
})
|
||||
+75
@@ -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)]' />
|
||||
}
|
||||
+3
@@ -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'
|
||||
+505
@@ -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'
|
||||
+108
@@ -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>
|
||||
)
|
||||
}
|
||||
+3963
File diff suppressed because it is too large
Load Diff
+76
@@ -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
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type WorkflowConfig,
|
||||
WorkflowSidebar,
|
||||
WorkflowSidebarBody,
|
||||
type WorkflowSidebarBodyProps,
|
||||
} from './workflow-sidebar'
|
||||
+84
@@ -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>
|
||||
)
|
||||
}
|
||||
+56
@@ -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>
|
||||
)
|
||||
}
|
||||
+1074
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
}
|
||||
}
|
||||
+436
@@ -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
|
||||
}
|
||||
+525
@@ -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'
|
||||
+117
@@ -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>
|
||||
)
|
||||
}
|
||||
+84
@@ -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'
|
||||
+185
@@ -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'
|
||||
+110
@@ -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>
|
||||
)
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { TablesListContextMenu } from './tables-list-context-menu'
|
||||
+68
@@ -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 (1–100)',
|
||||
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 (1–100 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...',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user