|
| 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