Skip to content

Commit e1f4d4d

Browse files
feat(tables): code-defined enrichment registry run directly per row
Enrichments are now TS configs in apps/sim/enrichments/ (registry, like connectors) that run directly per table row via the existing run/dispatch/ cell-write rails — no workflow execution. - enrichments/{types,registry} + work-email (heuristic) and phone-number (stub). - WorkflowGroup gains enrichmentId; WorkflowGroupOutput gains outputId (workflowId/blockId/path kept required, '' for enrichment groups). - Executor branches on group.type === 'enrichment' → maps inputMappings → enrich() → outputs by outputId → cell-write. Missing required inputs skip (blank cell) instead of erroring. - Sidebar lists the registry; enrichment-config panel maps inputs to columns and creates the enrichment group (no workflow UI). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a067bdf commit e1f4d4d

15 files changed

Lines changed: 532 additions & 128 deletions

File tree

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import { toError } from '@sim/utils/errors'
5+
import { generateId } from '@sim/utils/id'
6+
import { X } from 'lucide-react'
7+
import {
8+
Badge,
9+
Button,
10+
CollapsibleCard,
11+
Combobox,
12+
FieldDivider,
13+
Label,
14+
Switch,
15+
toast,
16+
} from '@/components/emcn'
17+
import { ArrowLeft } from '@/components/emcn/icons'
18+
import type { AddWorkflowGroupBodyInput } from '@/lib/api/contracts/tables'
19+
import type { ColumnDefinition, WorkflowGroup, WorkflowGroupOutput } from '@/lib/table'
20+
import { deriveOutputColumnName } from '@/lib/table/column-naming'
21+
import type { EnrichmentConfig as EnrichmentDef } from '@/enrichments/types'
22+
import { useAddWorkflowGroup } from '@/hooks/queries/tables'
23+
import { RunSettingsSection } from '../workflow-sidebar/run-settings-section'
24+
25+
interface EnrichmentConfigProps {
26+
enrichment: EnrichmentDef
27+
allColumns: ColumnDefinition[]
28+
workspaceId: string
29+
tableId: string
30+
onBack: () => void
31+
onClose: () => void
32+
}
33+
34+
/** Pre-fill an input's column from a same-named column (case-insensitive). */
35+
function defaultColumnFor(
36+
input: EnrichmentDef['inputs'][number],
37+
columns: ColumnDefinition[]
38+
): string {
39+
const match = columns.find(
40+
(c) =>
41+
c.name.toLowerCase() === input.id.toLowerCase() ||
42+
c.name.toLowerCase() === input.name.toLowerCase()
43+
)
44+
return match?.name ?? ''
45+
}
46+
47+
/**
48+
* Config panel for a code-defined enrichment. No workflow: the user maps each
49+
* enrichment input to a table column; outputs are fixed by the enrichment.
50+
* Saving creates an `enrichment` workflow group that the table runs per row.
51+
*/
52+
export function EnrichmentConfig({
53+
enrichment,
54+
allColumns,
55+
workspaceId,
56+
tableId,
57+
onBack,
58+
onClose,
59+
}: EnrichmentConfigProps) {
60+
const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId })
61+
62+
const [inputMappings, setInputMappings] = useState<Record<string, string>>(() => {
63+
const seed: Record<string, string> = {}
64+
for (const input of enrichment.inputs) {
65+
const col = defaultColumnFor(input, allColumns)
66+
if (col) seed[input.id] = col
67+
}
68+
return seed
69+
})
70+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
71+
const [autoRun, setAutoRun] = useState(false)
72+
const [deps, setDeps] = useState<string[]>([])
73+
const [showValidation, setShowValidation] = useState(false)
74+
75+
const columnOptions = allColumns.map((c) => ({ label: c.name, value: c.name }))
76+
const missingRequired = enrichment.inputs.some((i) => i.required && !inputMappings[i.id])
77+
const depsValid = !autoRun || deps.length > 0
78+
const saveDisabled = addWorkflowGroup.isPending || !depsValid
79+
80+
async function handleSave() {
81+
if (missingRequired || (autoRun && deps.length === 0)) {
82+
setShowValidation(true)
83+
return
84+
}
85+
const groupId = generateId()
86+
const taken = new Set(allColumns.map((c) => c.name))
87+
const outputColumns: AddWorkflowGroupBodyInput['outputColumns'] = []
88+
const outputs: WorkflowGroupOutput[] = []
89+
for (const o of enrichment.outputs) {
90+
const colName = deriveOutputColumnName(o.name, taken)
91+
taken.add(colName)
92+
outputColumns.push({
93+
name: colName,
94+
type: o.type,
95+
required: false,
96+
unique: false,
97+
workflowGroupId: groupId,
98+
})
99+
outputs.push({ blockId: '', path: '', outputId: o.id, columnName: colName })
100+
}
101+
const inputMappingsList = Object.entries(inputMappings)
102+
.filter(([, columnName]) => Boolean(columnName))
103+
.map(([inputName, columnName]) => ({ inputName, columnName }))
104+
105+
const group: WorkflowGroup = {
106+
id: groupId,
107+
workflowId: '',
108+
enrichmentId: enrichment.id,
109+
name: enrichment.name,
110+
type: 'enrichment',
111+
dependencies: { columns: deps },
112+
outputs,
113+
inputMappings: inputMappingsList,
114+
autoRun,
115+
}
116+
try {
117+
await addWorkflowGroup.mutateAsync({ group, outputColumns })
118+
toast.success(`Added "${enrichment.name}"`)
119+
onClose()
120+
} catch (err) {
121+
toast.error(toError(err).message)
122+
}
123+
}
124+
125+
return (
126+
<div className='flex h-full flex-col'>
127+
<div className='flex items-center justify-between border-[var(--border)] border-b px-3 py-[8.5px]'>
128+
<div className='flex min-w-0 items-center gap-1.5'>
129+
<Button
130+
variant='ghost'
131+
size='sm'
132+
onClick={onBack}
133+
className='!p-1 size-7 flex-none'
134+
aria-label='Back to enrichments'
135+
>
136+
<ArrowLeft className='size-[14px]' />
137+
</Button>
138+
<h2 className='truncate font-medium text-[var(--text-primary)] text-small'>
139+
{enrichment.name}
140+
</h2>
141+
</div>
142+
<Button
143+
variant='ghost'
144+
size='sm'
145+
onClick={onClose}
146+
className='!p-1 size-7 flex-none'
147+
aria-label='Close'
148+
>
149+
<X className='size-[14px]' />
150+
</Button>
151+
</div>
152+
153+
<div className='flex-1 overflow-y-auto overflow-x-hidden px-2 pt-3 pb-2 [overflow-anchor:none]'>
154+
<div className='flex flex-col gap-[9.5px]'>
155+
<Label className='flex items-baseline gap-1.5 whitespace-nowrap pl-0.5'>Inputs</Label>
156+
{enrichment.inputs.length === 0 ? (
157+
<p className='pl-0.5 text-[var(--text-tertiary)] text-caption'>
158+
This enrichment needs no inputs.
159+
</p>
160+
) : (
161+
<div className='flex flex-col gap-2'>
162+
{enrichment.inputs.map((input) => (
163+
<CollapsibleCard
164+
key={input.id}
165+
title={input.required ? `${input.name} *` : input.name}
166+
badge={
167+
<Badge variant='type' size='sm'>
168+
{input.type}
169+
</Badge>
170+
}
171+
collapsed={collapsed[input.id] ?? false}
172+
onToggleCollapse={() =>
173+
setCollapsed((prev) => ({ ...prev, [input.id]: !prev[input.id] }))
174+
}
175+
>
176+
<Label className='text-small'>Column</Label>
177+
<Combobox
178+
searchable
179+
searchPlaceholder='Search columns…'
180+
size='sm'
181+
className='h-[32px] w-full rounded-md'
182+
dropdownWidth='trigger'
183+
maxHeight={240}
184+
disabled={columnOptions.length === 0}
185+
emptyMessage='No columns.'
186+
placeholder='Select a column'
187+
options={columnOptions}
188+
value={inputMappings[input.id] ?? ''}
189+
onChange={(columnName: string) =>
190+
setInputMappings((prev) => ({ ...prev, [input.id]: columnName }))
191+
}
192+
error={
193+
showValidation && input.required && !inputMappings[input.id]
194+
? 'Required'
195+
: null
196+
}
197+
/>
198+
</CollapsibleCard>
199+
))}
200+
</div>
201+
)}
202+
</div>
203+
204+
<FieldDivider />
205+
206+
<div className='flex flex-col gap-[9.5px]'>
207+
<Label className='pl-0.5'>Output columns</Label>
208+
<p className='pl-0.5 text-[var(--text-tertiary)] text-caption'>
209+
Creates: {enrichment.outputs.map((o) => o.name).join(', ')}
210+
</p>
211+
</div>
212+
213+
<FieldDivider />
214+
215+
<div className='flex items-center justify-between pl-0.5'>
216+
<Label htmlFor='enrichment-auto-run'>Auto-run</Label>
217+
<Switch
218+
id='enrichment-auto-run'
219+
checked={autoRun}
220+
onCheckedChange={(v) => setAutoRun(!!v)}
221+
/>
222+
</div>
223+
{autoRun && (
224+
<>
225+
<FieldDivider />
226+
<RunSettingsSection
227+
depOptions={allColumns}
228+
deps={deps}
229+
onChangeDeps={setDeps}
230+
error={showValidation && deps.length === 0 ? 'Select at least one column' : null}
231+
/>
232+
</>
233+
)}
234+
</div>
235+
236+
<div className='flex items-center justify-end gap-2 border-[var(--border)] border-t px-2 py-3'>
237+
<Button variant='default' size='sm' onClick={onClose}>
238+
Cancel
239+
</Button>
240+
<Button variant='primary' size='sm' onClick={handleSave} disabled={saveDisabled}>
241+
{saveDisabled ? 'Saving…' : 'Save'}
242+
</Button>
243+
</div>
244+
</div>
245+
)
246+
}

0 commit comments

Comments
 (0)