Skip to content

Commit 319969d

Browse files
fix(enrichment): wait for valid work email inputs
1 parent 4785802 commit 319969d

6 files changed

Lines changed: 152 additions & 26 deletions

File tree

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

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { PlayOutline, Square } from '@sim/emcn/icons'
66
import type { ActiveDispatch } from '@/lib/api/contracts/tables'
77
import type { TableRow as TableRowType, WorkflowGroup } from '@/lib/table'
88
import { getUnmetGroupDeps } from '@/lib/table/deps'
9+
import { getEnrichmentReadiness } from '@/enrichments/readiness'
10+
import { getEnrichment } from '@/enrichments/registry'
911
import type { SaveReason } from '../../types'
1012
import { CellContent } from './cells'
1113
import {
@@ -180,27 +182,48 @@ export const DataRow = React.memo(function DataRow({
180182
}: DataRowProps) {
181183
const sel = normalizedSelection
182184
/**
183-
* Per-row "Waiting on …" labels keyed by group id. A group has labels iff
184-
* at least one of its dependencies is unmet for this row — drives the
185-
* "Waiting" pill rendered by `CellContent` for empty workflow-output cells.
186-
* Computed once per render rather than per cell so all cells in a group
187-
* share the same array reference.
185+
* Per-row "Waiting on …" labels keyed by group id. A group has labels when
186+
* its dependencies are unmet or an enrichment has no provider that can build
187+
* a valid request from the row. Computed once per render rather than per cell
188+
* so all cells in a group share the same array reference.
188189
*/
189190
const waitingByGroupId = React.useMemo(() => {
190191
if (workflowGroups.length === 0) return null
191192
// Deps are stored as column ids; the "Waiting on …" pill shows display names.
192193
const nameByColumnId = new Map(columns.map((c) => [c.key, c.name]))
193194
const map = new Map<string, string[]>()
194195
for (const group of workflowGroups) {
195-
// autoRun=false groups never fire from the scheduler — there's nothing
196-
// to wait on. The cell stays empty until the user clicks Run manually.
197-
if (group.autoRun === false) continue
198-
const unmet = getUnmetGroupDeps(group, row)
199-
if (unmet.columns.length === 0) continue
200-
map.set(
201-
group.id,
202-
unmet.columns.map((id) => nameByColumnId.get(id) ?? id)
203-
)
196+
const labels = new Set<string>()
197+
if (group.autoRun !== false) {
198+
const unmet = getUnmetGroupDeps(group, row)
199+
for (const id of unmet.columns) labels.add(nameByColumnId.get(id) ?? id)
200+
}
201+
202+
if (group.type === 'enrichment') {
203+
const enrichment = getEnrichment(group.enrichmentId)
204+
if (enrichment) {
205+
const inputColumnById = new Map<string, string>()
206+
const inputs: Record<string, unknown> = {}
207+
for (const mapping of group.inputMappings ?? []) {
208+
inputColumnById.set(mapping.inputName, mapping.columnName)
209+
inputs[mapping.inputName] = row.data[mapping.columnName]
210+
}
211+
const readiness = getEnrichmentReadiness(enrichment, inputs)
212+
if (!readiness.ready) {
213+
const mappedLabels = readiness.missingInputs.flatMap((input) => {
214+
const columnId = inputColumnById.get(input.id)
215+
return columnId ? [nameByColumnId.get(columnId) ?? columnId] : []
216+
})
217+
const waitingLabels =
218+
mappedLabels.length > 0
219+
? mappedLabels
220+
: readiness.missingInputs.map((input) => input.name)
221+
for (const label of waitingLabels) labels.add(label)
222+
}
223+
}
224+
}
225+
226+
if (labels.size > 0) map.set(group.id, [...labels])
204227
}
205228
return map
206229
}, [workflowGroups, row, columns])

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

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ async function runWorkflowAndWriteTerminal(
208208
// workflow path rather than erroring.
209209
if (group.type === 'enrichment' && group.enrichmentId) {
210210
const { getEnrichment } = await import('@/enrichments/registry')
211+
const { getEnrichmentReadiness } = await import('@/enrichments/readiness')
211212
const { runEnrichment, skippedEnrichmentDetail } = await import('@/enrichments/run')
212213
const enrichment = getEnrichment(group.enrichmentId)
213214
// `tableRowExecutions.workflowId` is an opaque id for status; use the
@@ -300,15 +301,16 @@ async function runWorkflowAndWriteTerminal(
300301
enrichInputs[m.inputName] = row.data[m.columnName]
301302
}
302303

303-
// Skip (don't error) rows missing a required input — common when a table
304-
// is partially filled. Clear any prior output values so a stale result
305-
// doesn't linger (and doesn't mark the group `completed`-and-filled, which
306-
// would block the auto cascade from re-enriching once inputs return).
304+
/**
305+
* Skip (don't error) rows where no provider can build a valid request —
306+
* common when a table is partially filled. Clear any prior output values
307+
* so a stale result doesn't linger (and doesn't mark the group
308+
* `completed`-and-filled, which would block the auto cascade from
309+
* re-enriching once inputs return).
310+
*/
307311
const isEmpty = (v: unknown) => v === undefined || v === null || v === ''
308-
const missingRequired = enrichment.inputs.some(
309-
(i) => i.required && isEmpty(enrichInputs[i.id])
310-
)
311-
if (missingRequired) {
312+
const readiness = getEnrichmentReadiness(enrichment, enrichInputs)
313+
if (!readiness.ready) {
312314
const clearPatch: RowData = {}
313315
for (const out of group.outputs) {
314316
if (!isEmpty(row.data[out.columnName])) clearPatch[out.columnName] = ''
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { getEnrichmentReadiness } from '@/enrichments/readiness'
6+
import { workEmailEnrichment } from '@/enrichments/work-email/work-email'
7+
8+
describe('getEnrichmentReadiness', () => {
9+
it('waits for an identifier when a work-email row only has a name', () => {
10+
const readiness = getEnrichmentReadiness(workEmailEnrichment, { fullName: 'John Doe' })
11+
12+
expect(readiness.ready).toBe(false)
13+
expect(readiness.missingInputs.map((input) => input.id)).toEqual([
14+
'companyDomain',
15+
'linkedinUrl',
16+
])
17+
})
18+
19+
it('accepts either a company domain or LinkedIn URL', () => {
20+
expect(
21+
getEnrichmentReadiness(workEmailEnrichment, {
22+
fullName: 'John Doe',
23+
companyDomain: 'acme.com',
24+
}).ready
25+
).toBe(true)
26+
expect(
27+
getEnrichmentReadiness(workEmailEnrichment, {
28+
fullName: 'John Doe',
29+
linkedinUrl: 'https://linkedin.com/in/johndoe',
30+
}).ready
31+
).toBe(true)
32+
})
33+
34+
it('reports missing required inputs before provider-specific inputs', () => {
35+
const readiness = getEnrichmentReadiness(workEmailEnrichment, {
36+
companyDomain: 'acme.com',
37+
})
38+
39+
expect(readiness.ready).toBe(false)
40+
expect(readiness.missingInputs.map((input) => input.id)).toEqual(['fullName'])
41+
})
42+
})

apps/sim/enrichments/readiness.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { EnrichmentConfig, EnrichmentInputField } from '@/enrichments/types'
2+
3+
export interface EnrichmentReadiness {
4+
ready: boolean
5+
missingInputs: EnrichmentInputField[]
6+
}
7+
8+
/**
9+
* Checks whether an enrichment has its required inputs and at least one
10+
* provider can build a valid request from the available values.
11+
*/
12+
export function getEnrichmentReadiness(
13+
enrichment: EnrichmentConfig,
14+
inputs: Record<string, unknown>
15+
): EnrichmentReadiness {
16+
const isEmpty = (value: unknown) =>
17+
value === undefined || value === null || (typeof value === 'string' && value.trim() === '')
18+
const missingRequired = enrichment.inputs.filter(
19+
(input) => input.required && isEmpty(inputs[input.id])
20+
)
21+
if (missingRequired.length > 0) {
22+
return { ready: false, missingInputs: missingRequired }
23+
}
24+
25+
const ready = enrichment.providers.some((provider) => provider.buildParams(inputs) !== null)
26+
if (ready) return { ready: true, missingInputs: [] }
27+
28+
return {
29+
ready: false,
30+
missingInputs: enrichment.inputs.filter((input) => isEmpty(inputs[input.id])),
31+
}
32+
}

apps/sim/enrichments/work-email/work-email.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ describe('work-email enrichment cascade', () => {
8989
})
9090
})
9191

92+
describe('pdl', () => {
93+
const p = provider('pdl')
94+
it('uses a valid profile or name-and-company match and skips name alone', () => {
95+
expect(p.buildParams(linkedinOnly)).toEqual({
96+
profile: 'https://linkedin.com/in/johndoe',
97+
min_likelihood: 6,
98+
required: 'work_email',
99+
})
100+
expect(p.buildParams(nameDomain)).toEqual({
101+
name: 'John Doe',
102+
company: 'acme.com',
103+
min_likelihood: 6,
104+
required: 'work_email',
105+
})
106+
expect(p.buildParams({ fullName: 'John Doe' })).toBeNull()
107+
})
108+
})
109+
92110
describe('datagma', () => {
93111
const p = provider('datagma')
94112
it('maps name + normalized company domain', () => {

apps/sim/enrichments/work-email/work-email.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,25 @@ export const workEmailEnrichment: EnrichmentConfig = {
120120
label: 'People Data Labs',
121121
toolId: 'pdl_person_enrich',
122122
buildParams: (inputs) => {
123+
const profile = str(inputs.linkedinUrl)
124+
if (profile) {
125+
return {
126+
profile,
127+
min_likelihood: 6,
128+
required: 'work_email',
129+
}
130+
}
123131
const name = str(inputs.fullName)
124-
if (!name) return null
132+
const company = normalizeDomain(inputs.companyDomain)
133+
if (!name || !company) return null
125134
// `required` makes PDL 404 (free) when the profile has no work email,
126135
// instead of charging a credit for a match we'd discard as a no-match.
127-
return filterUndefined({
136+
return {
128137
name,
129-
company: normalizeDomain(inputs.companyDomain) || undefined,
138+
company,
130139
min_likelihood: 6,
131140
required: 'work_email',
132-
})
141+
}
133142
},
134143
mapOutput: (output) => {
135144
const person = output.person as Record<string, unknown> | undefined

0 commit comments

Comments
 (0)