Skip to content

Commit ce7ddd1

Browse files
feat(tables): workflow version selection (live/deployed) and not-found/no-output badges (#4889)
* feat(tables): workflow version selection (live/deployed) and not-found/no-output badges * fix(tables): draw row-selection left edge as checkbox cell border so it cannot be cut off * fix(tables): per-group version in cascade, accurate deploy error, skip not-found for deployed groups * fix(tables): render selection left edge as continuous strip overlapping row gridlines * feat(tables): not-found column icon, optional workflow inputs, mothership deploymentMode --------- Co-authored-by: waleed <walif6@gmail.com>
1 parent ddab1aa commit ce7ddd1

15 files changed

Lines changed: 188 additions & 14 deletions

File tree

apps/sim/app/api/table/[tableId]/groups/route.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R
116116
...(validated.inputMappings !== undefined
117117
? { inputMappings: validated.inputMappings }
118118
: {}),
119+
...(validated.deploymentMode !== undefined
120+
? { deploymentMode: validated.deploymentMode }
121+
: {}),
119122
...(validated.type !== undefined ? { type: validated.type } : {}),
120123
...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}),
121124
},

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export type CellRenderKind =
2222
| { kind: 'error' }
2323
| { kind: 'waiting'; labels: string[] }
2424
| { kind: 'not-found' }
25+
| { kind: 'no-output' }
2526
// Plain typed cells
2627
| { kind: 'boolean'; checked: boolean }
2728
| { kind: 'json'; text: string }
@@ -106,6 +107,9 @@ export function resolveCellRender({
106107
if (exec?.status === 'error') return { kind: 'error' }
107108
// Enrichment ran to completion but matched nothing → "Not found".
108109
if (isEnrichmentOutput && exec?.status === 'completed') return { kind: 'not-found' }
110+
// Workflow output: the group's run completed but this block produced no
111+
// value for the cell → grey "No output" (distinct from a never-run blank).
112+
if (exec?.status === 'completed') return { kind: 'no-output' }
109113
return { kind: 'empty' }
110114
}
111115

@@ -394,6 +398,15 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
394398
</Wrap>
395399
)
396400

401+
case 'no-output':
402+
return (
403+
<Wrap isEditing={isEditing}>
404+
<Badge variant='gray' dot size='sm'>
405+
No output
406+
</Badge>
407+
</Wrap>
408+
)
409+
397410
case 'empty':
398411
return null
399412

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/data-row.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,10 +194,28 @@ export const DataRow = React.memo(function DataRow({
194194
}, [workflowGroups, row])
195195
const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol)
196196
const isRowSelected = isRowChecked
197+
/**
198+
* Whether the selection's left edge sits at column 0 for this row. The blue
199+
* edge is drawn inside the sticky checkbox cell — over its gray right
200+
* border — rather than as the col-0 overlay's `border-l`, so the sticky
201+
* cell can never paint over it and the gray/blue lines never double up at
202+
* the column boundary. The strip overlaps the row gridlines (`-top-px` /
203+
* `-bottom-px`) so consecutive selected rows form one continuous line.
204+
*/
205+
const rowInRange = sel !== null && rowIndex >= sel.startRow && rowIndex <= sel.endRow
206+
const isLeftEdgeSelected = isRowChecked || (isMultiCell && rowInRange && sel!.startCol === 0)
197207

198208
return (
199209
<tr onContextMenu={(e) => onContextMenu(e, row)}>
200210
<td className={cn(CELL_CHECKBOX, 'cursor-pointer')}>
211+
{isLeftEdgeSelected && (
212+
<div
213+
className={cn(
214+
'-right-px -bottom-px pointer-events-none absolute w-px bg-[var(--selection)]',
215+
isFirstRow ? 'top-0' : '-top-px'
216+
)}
217+
/>
218+
)}
201219
<div
202220
className={cn(
203221
'flex items-center',
@@ -322,7 +340,7 @@ export const DataRow = React.memo(function DataRow({
322340
isFirstRow && isTopEdge && 'top-0',
323341
isTopEdge && 'border-t border-t-[var(--selection)]',
324342
isBottomEdge && 'border-b border-b-[var(--selection)]',
325-
isLeftEdge && 'border-l border-l-[var(--selection)]',
343+
isLeftEdge && colIndex !== 0 && 'border-l border-l-[var(--selection)]',
326344
isRightEdge && 'border-r border-r-[var(--selection)]'
327345
)}
328346
/>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
237237
setMenuOpen(true)
238238
}
239239

240+
// Column whose workflow source block was deleted — the header icon swaps to
241+
// `WorkflowX` with an explanatory tooltip.
242+
const blockMissing = Boolean(sourceInfo?.blockMissing)
243+
240244
return (
241245
<th
242246
className={cn(
@@ -268,6 +272,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
268272
type={column.type}
269273
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
270274
blockIconInfo={sourceInfo?.blockIconInfo}
275+
blockMissing={blockMissing}
271276
/>
272277
<input
273278
ref={renameInputRef}
@@ -288,6 +293,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
288293
type={column.type}
289294
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
290295
blockIconInfo={sourceInfo?.blockIconInfo}
296+
blockMissing={blockMissing}
291297
/>
292298
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[13px] text-[var(--text-primary)]'>
293299
{column.workflowGroupId ? column.headerLabel : column.name}
@@ -305,6 +311,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
305311
type={column.type}
306312
isWorkflowColumn={!!column.workflowGroupId && ownGroup?.type !== 'enrichment'}
307313
blockIconInfo={sourceInfo?.blockIconInfo}
314+
blockMissing={blockMissing}
308315
/>
309316
<span className='ml-1.5 min-w-0 overflow-clip text-ellipsis whitespace-nowrap font-medium text-[var(--text-primary)] text-small'>
310317
{column.workflowGroupId ? column.headerLabel : column.name}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
'use client'
22

33
import type React from 'react'
4+
import { Tooltip } from '@/components/emcn'
45
import {
56
Calendar as CalendarIcon,
67
PlayOutline,
78
TypeBoolean,
89
TypeJson,
910
TypeNumber,
1011
TypeText,
12+
WorkflowX,
1113
} from '@/components/emcn/icons'
1214
import type { BlockIconInfo } from '../types'
1315

@@ -32,16 +34,39 @@ interface ColumnTypeIconProps {
3234
* ignored — icons render in the plain `text-[var(--text-icon)]` tone like
3335
* every other column-type icon, no per-block tint. */
3436
blockIconInfo?: BlockIconInfo
37+
/** Workflow-output column whose source block no longer exists in the
38+
* workflow — renders the `WorkflowX` "not found" icon with a tooltip. */
39+
blockMissing?: boolean
3540
}
3641

3742
/**
3843
* Tiny icon shown next to a column header. Workflow-output columns get the
3944
* producing block's icon (falling back to `PlayOutline`); plain columns get
4045
* their scalar type icon. Both render in the same `text-[var(--text-icon)]`
41-
* tone — no per-workflow color, no colored swatch.
46+
* tone — no per-workflow color, no colored swatch. A workflow column whose
47+
* source block was deleted renders a `WorkflowX` with an explanatory tooltip.
4248
*/
43-
export function ColumnTypeIcon({ type, isWorkflowColumn, blockIconInfo }: ColumnTypeIconProps) {
49+
export function ColumnTypeIcon({
50+
type,
51+
isWorkflowColumn,
52+
blockIconInfo,
53+
blockMissing,
54+
}: ColumnTypeIconProps) {
4455
if (isWorkflowColumn) {
56+
if (blockMissing) {
57+
return (
58+
<Tooltip.Root>
59+
<Tooltip.Trigger asChild>
60+
<span className='flex shrink-0 items-center'>
61+
<WorkflowX className='size-3 shrink-0 text-[var(--text-icon)]' />
62+
</span>
63+
</Tooltip.Trigger>
64+
<Tooltip.Content side='top'>
65+
This column's source block no longer exists in the workflow.
66+
</Tooltip.Content>
67+
</Tooltip.Root>
68+
)
69+
}
4570
const Icon = blockIconInfo?.icon ?? PlayOutline
4671
return <Icon className='size-3 shrink-0 text-[var(--text-icon)]' />
4772
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ export interface BlockIconInfo {
99
export interface ColumnSourceInfo {
1010
blockIconInfo?: BlockIconInfo
1111
blockName?: string
12+
/** Workflow loaded but the column's source block no longer exists — the
13+
* header renders a "Not found" badge. Only set for loaded states. */
14+
blockMissing?: boolean
1215
}
1316

1417
/**

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/input-mapping-section.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ export function InputMappingSection({
3939
<div className='flex flex-col gap-[9.5px]'>
4040
<Label className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>
4141
Workflow inputs
42-
<span className='ml-0.5'>*</span>
4342
</Label>
4443
{namedFields.length === 0 ? (
4544
<p className='pl-0.5 text-[var(--text-tertiary)] text-caption'>

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
88
import { ExternalLink, RepeatIcon, SplitIcon, X } from 'lucide-react'
99
import {
1010
Button,
11+
ButtonGroup,
12+
ButtonGroupItem,
1113
Combobox,
1214
type ComboboxOptionGroup,
1315
FieldDivider,
@@ -34,6 +36,7 @@ import type {
3436
ColumnDefinition,
3537
WorkflowGroup,
3638
WorkflowGroupDependencies,
39+
WorkflowGroupDeploymentMode,
3740
WorkflowGroupInputMapping,
3841
WorkflowGroupOutput,
3942
} from '@/lib/table'
@@ -347,6 +350,11 @@ export function WorkflowSidebarBody({
347350
const [autoRun, setAutoRun] = useState<boolean>(() =>
348351
existingGroup ? existingGroup.autoRun !== false : false
349352
)
353+
// Which workflow state per-cell runs execute against. Defaults to `'live'`
354+
// (the editable draft) for both new and pre-feature groups.
355+
const [deploymentMode, setDeploymentMode] = useState<WorkflowGroupDeploymentMode>(
356+
() => existingGroup?.deploymentMode ?? 'live'
357+
)
350358
// Deps default to none selected. With auto-run on, at least one is required
351359
// (enforced via `depsValid` below); a legacy group with empty deps will
352360
// surface the error on first open until the user picks at least one column.
@@ -709,6 +717,7 @@ export function WorkflowSidebarBody({
709717
outputs: fullOutputs,
710718
...(newOutputColumns.length > 0 ? { newOutputColumns } : {}),
711719
inputMappings: inputMappingsList,
720+
deploymentMode,
712721
autoRun,
713722
})
714723
toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`)
@@ -740,6 +749,7 @@ export function WorkflowSidebarBody({
740749
dependencies,
741750
outputs: groupOutputs,
742751
inputMappings: inputMappingsList,
752+
deploymentMode,
743753
autoRun,
744754
}
745755
await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns })
@@ -1027,12 +1037,31 @@ export function WorkflowSidebarBody({
10271037
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
10281038
</div>
10291039
{showAdvanced && (
1030-
<InputMappingSection
1031-
inputFields={startBlockInputs.existing}
1032-
columnOptions={depOptions}
1033-
value={inputMappings}
1034-
onChange={setInputMappings}
1035-
/>
1040+
<>
1041+
{!isEnrichment && (
1042+
<>
1043+
<div className='flex items-center justify-between pl-0.5'>
1044+
<Label>Workflow version</Label>
1045+
<ButtonGroup
1046+
value={deploymentMode}
1047+
onValueChange={(v) =>
1048+
setDeploymentMode(v === 'deployed' ? 'deployed' : 'live')
1049+
}
1050+
>
1051+
<ButtonGroupItem value='live'>Live</ButtonGroupItem>
1052+
<ButtonGroupItem value='deployed'>Deployed</ButtonGroupItem>
1053+
</ButtonGroup>
1054+
</div>
1055+
<FieldDivider />
1056+
</>
1057+
)}
1058+
<InputMappingSection
1059+
inputFields={startBlockInputs.existing}
1060+
columnOptions={depOptions}
1061+
value={inputMappings}
1062+
onChange={setInputMappings}
1063+
/>
1064+
</>
10361065
)}
10371066
</>
10381067
)}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,14 +195,21 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams)
195195
if (group.type === 'enrichment') continue
196196
const state = workflowStates.get(group.workflowId)
197197
const blocks = (state as { blocks?: Record<string, FlattenOutputsBlockInput> } | null)?.blocks
198+
// `useWorkflowStates` only fetches the live draft, so we can only judge
199+
// "block missing" for live-mode groups. A deployed-mode group runs a
200+
// different graph we don't load client-side — don't risk a false badge.
201+
const isLiveMode = group.deploymentMode !== 'deployed'
198202
for (const out of group.outputs) {
199203
const block = blocks?.[out.blockId]
200204
const blockConfig = block?.type ? getBlock(block.type) : undefined
201205
const blockIconInfo: BlockIconInfo | undefined = blockConfig?.icon
202206
? { icon: blockConfig.icon, color: blockConfig.bgColor || '#2F55FF' }
203207
: undefined
204208
const blockName = block?.name?.trim() || undefined
205-
map.set(out.columnName, { blockIconInfo, blockName })
209+
// Flag a missing source block only once the workflow state has loaded
210+
// (truthy `blocks`), so a still-loading workflow never flashes the badge.
211+
const blockMissing = Boolean(isLiveMode && blocks && out.blockId && !block)
212+
map.set(out.columnName, { blockIconInfo, blockName, blockMissing })
206213
}
207214
}
208215
return map

apps/sim/background/workflow-column-execution.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,12 +165,18 @@ async function runWorkflowAndWriteTerminal(
165165
): Promise<'completed' | 'error' | 'paused' | 'blocked'> {
166166
const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId, dispatchId } =
167167
payload
168+
// Read from the live `group`, not the payload: in a cascade the payload is the
169+
// first group's snapshot, so a downstream group with a different version must
170+
// use its own setting (same reason `workflowId` is re-derived per iteration).
171+
const deploymentMode = group.deploymentMode
168172
const requestId = `wfgrp-${executionId}`
169173

170174
return runWithRequestContext({ requestId }, async () => {
171175
const { getRowById } = await import('@/lib/table/service')
172176
const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
173-
const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils')
177+
const { loadWorkflowFromNormalizedTables, loadDeployedWorkflowState } = await import(
178+
'@/lib/workflows/persistence/utils'
179+
)
174180
const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } =
175181
await import('@/lib/table/cell-write')
176182
const { stashCellContextForResume } = await import('@/lib/table/workflow-columns')
@@ -382,7 +388,28 @@ async function runWorkflowAndWriteTerminal(
382388
return 'error'
383389
}
384390

385-
const normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
391+
// `deployed` groups run the workflow's latest active deployment; `live`
392+
// (default) runs the editable draft. A `deployed` group whose workflow
393+
// has never been deployed fails the cell — no silent fallback to draft.
394+
let normalizedData: Awaited<ReturnType<typeof loadWorkflowFromNormalizedTables>>
395+
if (deploymentMode === 'deployed') {
396+
try {
397+
normalizedData = await loadDeployedWorkflowState(workflowId, workspaceId)
398+
} catch (err) {
399+
// Surface the real reason (missing deployment vs. transient DB/migration
400+
// failure) rather than always claiming the workflow isn't deployed.
401+
await writeState({
402+
status: 'error',
403+
executionId,
404+
jobId: null,
405+
workflowId,
406+
error: toError(err).message,
407+
})
408+
return 'error'
409+
}
410+
} else {
411+
normalizedData = await loadWorkflowFromNormalizedTables(workflowId)
412+
}
386413
const startBlock = normalizedData
387414
? Object.values(normalizedData.blocks).find((b) => b?.type === 'start_trigger')
388415
: undefined
@@ -665,7 +692,10 @@ async function runWorkflowAndWriteTerminal(
665692
executionMode: 'sync',
666693
workflowTriggerType: 'table',
667694
triggerBlockId: startBlock.id,
668-
useDraftState: true,
695+
// `deployed` groups execute the latest active deployment; everything
696+
// else runs the editable draft (the table default). Matches the
697+
// state loaded above for start-block / output-block resolution.
698+
useDraftState: deploymentMode !== 'deployed',
669699
abortSignal,
670700
onBlockStart,
671701
onBlockComplete,

0 commit comments

Comments
 (0)