fix(tables): enrichment sidebar column-id display + filter-scoped run menu (#5459)

* fix(tables): resolve column ids to display names in enrichment edit sidebar

* feat(tables): scope group-header run menu to the active filter
This commit is contained in:
Theodore Li
2026-07-06 21:51:29 -04:00
committed by GitHub
parent 24ebba9acc
commit e9a922d346
3 changed files with 61 additions and 17 deletions
@@ -17,6 +17,7 @@ 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'
@@ -49,7 +50,7 @@ function defaultColumnFor(
c.name.toLowerCase() === input.id.toLowerCase() ||
c.name.toLowerCase() === input.name.toLowerCase()
)
return match?.name ?? ''
return match ? getColumnId(match) : ''
}
/**
@@ -71,14 +72,23 @@ export function EnrichmentConfig({
const updateColumn = useUpdateColumn({ workspaceId, tableId })
const isEditing = Boolean(existingGroup)
/** Output column's persisted name (edit mode), used to detect renames. */
const originalOutputName = (outputId: string): string | undefined =>
existingGroup?.outputs.find((o) => o.outputId === outputId)?.columnName
/** 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 ?? []) seed[m.inputName] = m.columnName
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> = {}
@@ -94,7 +104,9 @@ export function EnrichmentConfig({
const seed: Record<string, string> = {}
if (existingGroup) {
for (const o of existingGroup.outputs) {
if (o.outputId) seed[o.outputId] = o.columnName
if (!o.outputId) continue
const col = allColumns.find((c) => columnMatchesRef(c, o.columnName))
seed[o.outputId] = col?.name ?? o.columnName
}
return seed
}
@@ -111,7 +123,7 @@ export function EnrichmentConfig({
const [deps, setDeps] = useState<string[]>(() => existingGroup?.dependencies?.columns ?? [])
const [showValidation, setShowValidation] = useState(false)
const columnOptions = allColumns.map((c) => ({ label: c.name, value: c.name }))
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
@@ -162,10 +174,13 @@ export function EnrichmentConfig({
autoRun,
})
for (const o of enrichment.outputs) {
const original = originalOutputName(o.id)
const col = outputColumn(o.id)
const next = (outputNames[o.id] ?? '').trim()
if (original && next && next !== original) {
await updateColumn.mutateAsync({ columnName: original, updates: { name: next } })
if (col && next && next !== col.name) {
await updateColumn.mutateAsync({
columnName: getColumnId(col),
updates: { name: next },
})
}
}
toast.success(`Updated "${enrichment.name}"`)
@@ -37,6 +37,18 @@ import type { DisplayColumn } from '../types'
* 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
@@ -65,6 +77,9 @@ interface ColumnOptionsMenuProps {
/** 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
@@ -97,12 +112,14 @@ export function ColumnOptionsMenu({
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>
@@ -140,15 +157,15 @@ export function ColumnOptionsMenu({
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={() => onRunColumnAll?.()}>
Run all rows
{runLabels.all}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => onRunColumnIncomplete?.()}>
Run empty rows
{runLabels.incomplete}
</DropdownMenuItem>
{onRunColumnLimited &&
LIMITED_RUN_PRESETS.map((max) => (
<DropdownMenuItem key={max} onSelect={() => onRunColumnLimited(max)}>
{`Run ${max.toLocaleString()} empty rows`}
{runLabels.limited(max)}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
@@ -221,6 +238,9 @@ interface WorkflowGroupMetaCellProps {
/** 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
@@ -267,6 +287,7 @@ export function WorkflowGroupMetaCell({
onDeleteColumn,
onDeleteGroup,
selectedRowIds,
hasActiveFilter = false,
onViewWorkflow,
onDragStart,
onDragOver,
@@ -292,6 +313,7 @@ export function WorkflowGroupMetaCell({
const didDragRef = useRef(false)
const selectedCount = selectedRowIds?.length ?? 0
const runLabels = runMenuLabels(hasActiveFilter)
function handleRunAll() {
if (groupId) onRunColumn?.(groupId, 'all')
@@ -443,11 +465,13 @@ export function WorkflowGroupMetaCell({
{`Run ${selectedCount} selected ${selectedCount === 1 ? 'row' : 'rows'}`}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={handleRunAll}>Run all rows</DropdownMenuItem>
<DropdownMenuItem onSelect={handleRunIncomplete}>Run empty 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)}>
{`Run ${max.toLocaleString()} empty rows`}
{runLabels.limited(max)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -470,6 +494,7 @@ export function WorkflowGroupMetaCell({
onRunColumnLimited={onRunColumn ? handleRunLimited : undefined}
onRunColumnSelected={onRunColumn && selectedCount > 0 ? handleRunSelected : undefined}
selectedRowCount={selectedCount}
hasActiveFilter={hasActiveFilter}
onViewWorkflow={onViewWorkflow ? () => onViewWorkflow(workflowId) : undefined}
isPinned={isPinned}
onPinToggle={onPinToggle}
@@ -503,7 +503,10 @@ export function TableGrid({
rowIds?: string[],
limit?: RunLimit
) {
onRunColumn(groupId, runMode, rowIds, limit)
// Table-scoped runs (Run all / Run empty / Run N empty) honor the active
// filter; an explicit rowIds scope (Run selected) already names its rows.
const filter = rowIds ? undefined : (queryOptions.filter ?? undefined)
onRunColumn(groupId, runMode, rowIds, limit, filter)
}
const handleViewWorkflow = useCallback(
@@ -3643,6 +3646,7 @@ export function TableGrid({
onSelectGroup={handleGroupSelect}
onOpenConfig={() => handleConfigureWorkflowGroup(g.groupId)}
onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined}
hasActiveFilter={Boolean(queryOptions.filter)}
selectedRowIds={selectedRowIds}
onInsertLeft={
userPermissions.canEdit ? handleInsertColumnLeft : undefined