mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+16
-17
@@ -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>
|
||||
}
|
||||
|
||||
+8
-10
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
+19
-20
@@ -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'
|
||||
|
||||
+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 '@/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>
|
||||
}
|
||||
+52
-56
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+36
-57
@@ -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>
|
||||
)
|
||||
})
|
||||
|
||||
+78
-44
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+9
-18
@@ -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}
|
||||
|
||||
@@ -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]>
|
||||
|
||||
Reference in New Issue
Block a user