improvement(tables): migrate inputs to emcn chip components and clean up tables feature (#4995)

* improvement(tables): migrate inputs to emcn chip components and clean up tables feature

* fix(tables): address review feedback — stale filter column label, shared FieldError in enrichment config

* improvement(tables): scope create-table callback to stable mutateAsync
This commit is contained in:
Waleed
2026-06-12 10:29:58 -07:00
committed by GitHub
parent 2c75a4ac04
commit e2523e0fb5
15 changed files with 290 additions and 279 deletions
@@ -1,13 +1,24 @@
'use client'
import type React from 'react'
import { useState } from 'react'
import { toError } from '@sim/utils/errors'
import { X } from 'lucide-react'
import { Button, ChipCombobox, FieldDivider, Input, Label, Switch, toast } from '@/components/emcn'
import {
Button,
ChipCombobox,
ChipInput,
FieldDivider,
Label,
Switch,
toast,
} from '@/components/emcn'
import { X } from '@/components/emcn/icons'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
import { cn } from '@/lib/core/utils/cn'
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'
@@ -169,7 +180,7 @@ function ColumnConfigBody({
<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>
<Input
<ChipInput
id='column-sidebar-name'
value={nameInput}
onChange={(e) => {
@@ -178,6 +189,7 @@ function ColumnConfigBody({
}}
spellCheck={false}
autoComplete='off'
error={Boolean((showValidation && !trimmedName) || nameError)}
aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined}
/>
{showValidation && !trimmedName && <FieldError message='Column name is required' />}
@@ -228,16 +240,3 @@ function ColumnConfigBody({
</div>
)
}
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>
)
}
function FieldError({ message }: { message: string }) {
return <p className='pl-0.5 text-caption text-destructive'>{message}</p>
}
@@ -7,18 +7,18 @@ import {
Badge,
Button,
ChipCombobox,
ChipInput,
CollapsibleCard,
FieldDivider,
Input,
Label,
Switch,
toast,
} from '@/components/emcn'
import { ArrowLeft, X } from '@/components/emcn/icons'
import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table'
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,
@@ -280,12 +280,10 @@ export function EnrichmentConfig({
onChange={(columnName: string) =>
setInputMappings((prev) => ({ ...prev, [input.id]: columnName }))
}
error={
showValidation && input.required && !inputMappings[input.id]
? 'Required'
: null
}
/>
{showValidation && input.required && !inputMappings[input.id] && (
<FieldError message='Required' />
)}
</CollapsibleCard>
))}
</div>
@@ -317,16 +315,16 @@ export function EnrichmentConfig({
}
>
<Label className='text-small'>Column name</Label>
<Input
<ChipInput
value={outputNames[output.id] ?? ''}
onChange={(e) =>
setOutputNames((prev) => ({ ...prev, [output.id]: e.target.value }))
}
spellCheck={false}
autoComplete='off'
className={cn(outErr && 'border-[var(--text-error)]')}
error={Boolean(outErr)}
/>
{outErr && <p className='text-[var(--text-error)] text-caption'>{outErr}</p>}
{outErr && <FieldError message={outErr} />}
</CollapsibleCard>
)
})}
@@ -1,7 +1,7 @@
'use client'
import { useState } from 'react'
import { Input } from '@/components/emcn'
import { Button, ChipInput } from '@/components/emcn'
import { Search, X } from '@/components/emcn/icons'
import { cn } from '@/lib/core/utils/cn'
import type { ColumnDefinition, WorkflowGroup } from '@/lib/table'
@@ -74,14 +74,15 @@ function EnrichmentsSidebarBody({
<div className='flex h-full flex-col'>
<div className='flex 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
type='button'
<Button
variant='ghost'
size='sm'
onClick={onClose}
className='flex size-7 flex-none items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-primary)]'
className='!p-1 size-7 flex-none'
aria-label='Close'
>
<X className='size-[14px]' />
</button>
</Button>
</div>
<div className='flex flex-1 items-center justify-center px-6 text-center'>
<p className='text-[var(--text-tertiary)] text-small'>
@@ -119,28 +120,26 @@ function EnrichmentsSidebarBody({
<div className='flex h-full flex-col'>
<div className='flex 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
type='button'
<Button
variant='ghost'
size='sm'
onClick={onClose}
className='flex size-7 flex-none items-center justify-center rounded-md text-[var(--text-muted)] transition-colors hover-hover:bg-[var(--surface-hover)] hover-hover:text-[var(--text-primary)]'
className='!p-1 size-7 flex-none'
aria-label='Close'
>
<X className='size-[14px]' />
</button>
</Button>
</div>
<div className='px-2 pt-3'>
<div className='relative'>
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-2 size-[14px] text-[var(--text-muted)]' />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder='Search'
spellCheck={false}
autoComplete='off'
className='pl-7'
/>
</div>
<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]'>
@@ -4,6 +4,7 @@ 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'
@@ -0,0 +1 @@
export { FieldError, RequiredLabel } from './sidebar-fields'
@@ -0,0 +1,30 @@
'use client'
import type React from 'react'
import { Label } from '@/components/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,5 +1,6 @@
'use client'
import type React from 'react'
import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion'
import { Button, Tooltip } from '@/components/emcn'
import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons'
@@ -98,71 +99,35 @@ export function TableActionBar({
<div className='flex items-center gap-[5px]'>
{showPlay && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onPlay}
disabled={isLoading}
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={playLabel}
>
<PlayOutline className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{playLabel}</Tooltip.Content>
</Tooltip.Root>
<ActionIconButton label={playLabel} onClick={onPlay} disabled={isLoading}>
<PlayOutline className='size-[12px]' />
</ActionIconButton>
)}
{showRefresh && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onRefresh}
disabled={isLoading}
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={refreshLabel}
>
<RefreshCw className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{refreshLabel}</Tooltip.Content>
</Tooltip.Root>
<ActionIconButton label={refreshLabel} onClick={onRefresh} disabled={isLoading}>
<RefreshCw className='size-[12px]' />
</ActionIconButton>
)}
{runningCount > 0 && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onStopWorkflows}
disabled={isLoading}
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={stopLabel}
>
<Square className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>{stopLabel}</Tooltip.Content>
</Tooltip.Root>
<ActionIconButton
label={stopLabel}
onClick={onStopWorkflows}
disabled={isLoading}
>
<Square className='size-[12px]' />
</ActionIconButton>
)}
{onViewExecution && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
<Button
variant='ghost'
onClick={onViewExecution}
disabled={isLoading}
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='View execution'
>
<Eye className='size-[12px]' />
</Button>
</Tooltip.Trigger>
<Tooltip.Content side='top'>View execution</Tooltip.Content>
</Tooltip.Root>
<ActionIconButton
label='View execution'
onClick={onViewExecution}
disabled={isLoading}
>
<Eye className='size-[12px]' />
</ActionIconButton>
)}
</div>
</div>
@@ -172,3 +137,34 @@ export function TableActionBar({
</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>
)
}
@@ -2,24 +2,13 @@
import { memo, useCallback, useMemo, useRef, useState } from 'react'
import { generateShortId } from '@sim/utils/id'
import { X } from 'lucide-react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/emcn'
import { ChevronDown, Plus } from '@/components/emcn/icons'
import { Button, ChipDropdown, ChipInput } from '@/components/emcn'
import { Plus, X } from '@/components/emcn/icons'
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'
const OPERATOR_LABELS = Object.fromEntries(
COMPARISON_OPERATORS.map((op) => [op.value, op.label])
) as Record<string, string>
interface TableFilterProps {
columns: ColumnDefinition[]
filter: Filter | null
@@ -150,6 +139,14 @@ const FilterRuleRow = memo(function FilterRuleRow({
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 ? (
@@ -163,67 +160,49 @@ const FilterRuleRow = memo(function FilterRuleRow({
</button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className='flex h-[28px] min-w-[100px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
<span className='truncate'>
{columns.find((col) => col.value === rule.column)?.label || rule.column || 'Column'}
</span>
<ChevronDown className='ml-1 size-[10px] shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{columns.map((col) => (
<DropdownMenuItem
key={col.value}
onSelect={() => onUpdate(rule.id, 'column', col.value)}
>
{col.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<ChipDropdown
options={columnOptions}
value={rule.column}
onChange={(value) => onUpdate(rule.id, 'column', value)}
placeholder='Column'
align='start'
matchTriggerWidth={false}
className='min-w-[100px]'
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className='flex h-[28px] min-w-[90px] items-center justify-between rounded-[5px] border border-[var(--border)] bg-transparent px-2 text-[var(--text-secondary)] text-xs outline-none hover-hover:border-[var(--border-1)]'>
<span className='truncate'>{OPERATOR_LABELS[rule.operator] ?? rule.operator}</span>
<ChevronDown className='ml-1 size-[10px] shrink-0 text-[var(--text-icon)]' />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{COMPARISON_OPERATORS.map((op) => (
<DropdownMenuItem
key={op.value}
onSelect={() => onUpdate(rule.id, 'operator', op.value)}
>
{op.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<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' />
) : (
<input
type='text'
<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='h-[30px] flex-1 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 text-[var(--text-secondary)] text-xs outline-none placeholder:text-[var(--text-subtle)] dark:bg-[var(--surface-4)]'
className='flex-1'
/>
)}
<button
<Button
variant='ghost'
size='sm'
onClick={() => onRemove(rule.id)}
className='flex size-[28px] shrink-0 items-center justify-center rounded-[5px] text-[var(--text-tertiary)] transition-colors hover-hover:bg-[var(--surface-4)] hover-hover:text-[var(--text-primary)]'
className='!p-1 size-7 shrink-0'
aria-label='Remove filter'
>
<X className='size-[12px]' />
</button>
</Button>
</div>
)
})
@@ -40,7 +40,6 @@ export function ExpandedCellPopover({
const rootRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const [rect, setRect] = useState<{ top: number; left: number; width: number } | null>(null)
const [draftValue, setDraftValue] = useState<string>('')
const target = useMemo(() => {
if (!expandedCell) return null
@@ -75,7 +74,6 @@ export function ExpandedCellPopover({
setRect(null)
return
}
setDraftValue(isEditable ? formatValueForInput(target.value, target.column.type) : '')
const selector = `[data-table-scroll] [data-row-id="${target.row.id}"][data-col="${target.colIndex}"]`
const el = document.querySelector<HTMLElement>(selector)
if (!el) {
@@ -86,7 +84,7 @@ export function ExpandedCellPopover({
setRect({ top: r.top, left: r.left, width: r.width })
// Focus textarea on open so typing works immediately.
requestAnimationFrame(() => textareaRef.current?.focus())
}, [expandedCell, target, isEditable])
}, [expandedCell, target])
const onCloseEvent = useEffectEvent(onClose)
@@ -136,23 +134,6 @@ export function ExpandedCellPopover({
? Math.max(VIEWPORT_PAD, window.innerHeight - EXPANDED_CELL_HEIGHT - VIEWPORT_PAD)
: rect.top
const handleSave = () => {
if (!isEditable) return
// `displayToStorage` only normalizes dates — it returns null for anything else.
// Fall back to the raw draft for non-date columns, matching the inline editor.
const raw = displayToStorage(draftValue) ?? draftValue
const cleaned = cleanCellValue(raw, target.column)
onSave(target.row.id, target.column.key, cleaned, 'blur')
onClose()
}
const handleTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSave()
}
}
return (
<div
ref={rootRef}
@@ -162,30 +143,15 @@ export function ExpandedCellPopover({
style={{ top, left, width, height: EXPANDED_CELL_HEIGHT }}
>
{isEditable ? (
<>
<textarea
ref={textareaRef}
value={draftValue}
onChange={(e) => setDraftValue(e.target.value)}
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'>
<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>
</>
<ExpandedCellEditor
key={`${expandedCell.rowId}:${expandedCell.columnKey ?? expandedCell.columnName}`}
initialValue={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'>
@@ -207,3 +173,71 @@ export function ExpandedCellPopover({
</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 handleSave = () => {
// `displayToStorage` only normalizes dates — it returns null for anything else.
// Fall back to the raw draft for non-date columns, matching the inline editor.
const raw = displayToStorage(draftValue) ?? draftValue
const cleaned = cleanCellValue(raw, column)
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)}
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'>
<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>
</>
)
}
@@ -2,7 +2,7 @@
import type React from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { Button, Input } from '@/components/emcn'
import { Button, ChipInput } from '@/components/emcn'
import { Loader, X } from '@/components/emcn/icons'
export interface TableFindProps {
@@ -63,11 +63,11 @@ export function TableFind({
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'>
<Input
<ChipInput
ref={inputRef}
value={query}
placeholder='Search'
className='h-8 w-[200px]'
className='w-[200px]'
onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={handleKeyDown}
/>
@@ -50,7 +50,7 @@ export function RunSettingsSection({
</span>
}
/>
{error && <p className='pl-0.5 text-[var(--text-danger)] text-xs'>{error}</p>}
{error && <p className='pl-0.5 text-[var(--text-error)] text-caption'>{error}</p>}
</div>
)
}
@@ -5,22 +5,22 @@ import { useMemo, useState } from 'react'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ExternalLink, RepeatIcon, SplitIcon, X } from 'lucide-react'
import { ExternalLink, RepeatIcon, SplitIcon } from 'lucide-react'
import {
Button,
ButtonGroup,
ButtonGroupItem,
ChipCombobox,
ChipInput,
type ComboboxOptionGroup,
FieldDivider,
Input,
Label,
Loader,
Switch,
Tooltip,
toast,
} from '@/components/emcn'
import { ArrowLeft, ChevronDown } from '@/components/emcn/icons'
import { ArrowLeft, ChevronDown, X } from '@/components/emcn/icons'
import { findValidationIssue, isValidationError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import type {
@@ -51,6 +51,10 @@ import {
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
import type { InputFormatField } from '@/lib/workflows/types'
import {
FieldError,
RequiredLabel,
} from '@/app/workspace/[workspaceId]/tables/[tableId]/components/sidebar-fields'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks'
import {
@@ -168,19 +172,6 @@ function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefine
}
}
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>
)
}
function FieldError({ message }: { message: string }) {
return <p className='pl-0.5 text-caption text-destructive'>{message}</p>
}
const TagIcon: React.FC<{
icon: string | React.ComponentType<{ className?: string }>
color: string
@@ -835,7 +826,7 @@ export function WorkflowSidebarBody({
<>
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel htmlFor='workflow-sidebar-column-name'>Column name</RequiredLabel>
<Input
<ChipInput
id='workflow-sidebar-column-name'
value={columnNameInput}
onChange={(e) => {
@@ -844,6 +835,7 @@ export function WorkflowSidebarBody({
}}
spellCheck={false}
autoComplete='off'
error={Boolean((showValidation && !columnNameInput.trim()) || nameError)}
aria-invalid={
(showValidation && !columnNameInput.trim()) || nameError ? true : undefined
}
@@ -954,7 +946,6 @@ export function WorkflowSidebarBody({
maxHeight={260}
searchable
searchPlaceholder='Search workflows...'
error={showValidation && !selectedWorkflowId ? 'Select a workflow' : null}
/>
{showValidation && !selectedWorkflowId && <FieldError message='Select a workflow' />}
</div>
@@ -592,6 +592,18 @@ export function Table({
// 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 (
<div className='relative flex h-full flex-col overflow-hidden'>
{!embedded && (
@@ -628,11 +640,7 @@ export function Table({
bar's right-aligned `aside` slot — opposite the left-aligned filter/sort. */}
<Resource.Options
sort={sortConfig}
filter={{
mode: 'toggle',
active: filterOpen || !!queryOptions.filter,
onToggle: () => setFilterOpen((prev) => !prev),
}}
filter={filterConfig}
aside={
embedded && (selection.totalRunning > 0 || selection.hasActiveDispatch) ? (
<RunStatusControl
@@ -94,8 +94,8 @@ export function Tables() {
} | null>(null)
const [rowCountFilter, setRowCountFilter] = useState<string[]>([])
const [ownerFilter, setOwnerFilter] = useState<string[]>([])
const [uploading, setUploading] = useState(false)
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 })
const uploading = uploadProgress.total > 0
const csvInputRef = useRef<HTMLInputElement>(null)
const {
@@ -458,7 +458,6 @@ export function Tables() {
if (syncFiles.length === 0) return
setUploading(true)
setUploadProgress({ completed: 0, total: syncFiles.length })
const failed: string[] = []
@@ -492,7 +491,6 @@ export function Tables() {
logger.error('Error uploading CSV:', err)
toast.error('Failed to import CSV')
} finally {
setUploading(false)
setUploadProgress({ completed: 0, total: 0 })
if (csvInputRef.current) {
csvInputRef.current.value = ''
@@ -508,18 +506,18 @@ export function Tables() {
closeListContextMenu()
}, [closeListContextMenu])
const uploadButtonLabel =
uploading && uploadProgress.total > 0
? `${uploadProgress.completed}/${uploadProgress.total}`
: uploading
? 'Uploading...'
: 'Import CSV'
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 createTable.mutateAsync({
const result = await createTableAsync({
name,
schema: {
columns: [{ name: 'name', type: 'string' }],
@@ -533,7 +531,7 @@ export function Tables() {
} catch (err) {
logger.error('Failed to create table:', err)
}
}, [tables, createTable, router, workspaceId])
}, [tables, router, workspaceId, createTableAsync])
const headerActions: ResourceAction[] = useMemo(
() => [
@@ -560,6 +558,11 @@ export function Tables() {
]
)
// 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}>
@@ -567,13 +570,13 @@ export function Tables() {
icon={TableIcon}
title='Tables'
actions={headerActions}
aside={<ImportProgressMenu workspaceId={workspaceId} />}
aside={headerAside}
/>
<Resource.Options
search={searchConfig}
sort={sortConfig}
filterTags={filterTags}
filter={{ content: filterContent }}
filter={filterConfig}
/>
<Resource.Table
columns={COLUMNS}
+7 -35
View File
@@ -363,34 +363,6 @@ interface InfiniteTableRowsParams {
enabled?: boolean
}
export function useTableRows({
workspaceId,
tableId,
limit,
offset,
filter,
sort,
includeTotal,
enabled = true,
}: TableRowsParams & { enabled?: boolean }) {
const paramsKey = JSON.stringify({
limit,
offset,
filter: filter ?? null,
sort: sort ?? null,
includeTotal,
})
return useQuery({
queryKey: [...tableKeys.rowsRoot(tableId), paramsKey] as const,
queryFn: ({ signal }) =>
fetchTableRows({ workspaceId, tableId, limit, offset, filter, sort, includeTotal, signal }),
enabled: Boolean(workspaceId && tableId) && enabled,
staleTime: 30 * 1000,
placeholderData: keepPreviousData,
})
}
export function tableRowsParamsKey({
pageSize,
filter,
@@ -1568,10 +1540,6 @@ export function useImportCsvIntoTable() {
})
}
/**
* Downloads the full contents of a table to the user's device by streaming
* `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON.
*/
/**
* Cancels an in-flight async table job (import or delete). Plain function (not a hook) because the
* job tray lists multiple tables and cancels a chosen one by id rather than binding to a single
@@ -1672,6 +1640,10 @@ export async function downloadExportResult(
document.body.removeChild(a)
}
/**
* Downloads the full contents of a table to the user's device by streaming
* `/api/table/[tableId]/export`. Defaults to CSV; pass `'json'` for JSON.
*/
export async function downloadTableExport(
tableId: string,
fileName: string,
@@ -1785,9 +1757,9 @@ interface RunColumnVariables {
type InfiniteRowsCache = { pages: TableRowsResponse[]; pageParams: TableRowsPageParam[] }
/**
* Cache shapes that hold table-row data. Single-page (`useTableRows`) and
* infinite (`useInfiniteTableRows`) live under the same `rowsRoot(tableId)`
* prefix, so optimistic mutations have to walk both shapes.
* Cache shapes that hold table-row data under the `rowsRoot(tableId)` prefix.
* Optimistic mutations walk every entry defensively, handling both the
* single-page and infinite (`useInfiniteTableRows`) shapes.
*/
type RowsCacheEntry = TableRowsResponse | InfiniteRowsCache
type RowsCacheSnapshots = Array<[ReadonlyArray<unknown>, RowsCacheEntry]>