Skip to content

Commit c761851

Browse files
committed
fix(forking): invalidate stale dependent selectors
1 parent 6b86486 commit c761851

5 files changed

Lines changed: 182 additions & 26 deletions

File tree

apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { describe, expect, it } from 'vitest'
55
import type { ForkDependentReconfig } from '@/lib/api/contracts/workspace-fork'
66
import {
7+
applyDependentRepick,
78
dependentKey,
89
effectiveCopyDependentValue,
910
effectiveDependentValue,
@@ -100,6 +101,74 @@ describe('effectiveCopyDependentValue', () => {
100101
})
101102
})
102103

104+
describe('applyDependentRepick', () => {
105+
it('clears direct and transitive descendants without touching unrelated fields', () => {
106+
const site = field({
107+
subBlockKey: 'siteId',
108+
currentValue: 'site-old',
109+
providesContextKey: 'siteId',
110+
})
111+
const drive = field({
112+
subBlockKey: 'driveId',
113+
currentValue: 'drive-old',
114+
providesContextKey: 'driveId',
115+
consumesContextKeys: ['siteId'],
116+
})
117+
const spreadsheet = field({
118+
subBlockKey: 'spreadsheetId',
119+
currentValue: 'spreadsheet-old',
120+
providesContextKey: 'spreadsheetId',
121+
consumesContextKeys: ['driveId'],
122+
})
123+
const sheet = field({
124+
subBlockKey: 'sheetName',
125+
currentValue: 'Sheet1',
126+
consumesContextKeys: ['spreadsheetId'],
127+
})
128+
const unrelated = field({ subBlockKey: 'label', currentValue: 'keep-me' })
129+
const previous = {
130+
[dependentKey(drive)]: 'drive-repicked',
131+
[dependentKey(spreadsheet)]: 'spreadsheet-repicked',
132+
[dependentKey(sheet)]: 'Sheet2',
133+
[dependentKey(unrelated)]: 'still-keep-me',
134+
}
135+
136+
const next = applyDependentRepick(
137+
previous,
138+
site,
139+
[site, drive, spreadsheet, sheet, unrelated],
140+
'site-new'
141+
)
142+
143+
expect(next).toEqual({
144+
[dependentKey(site)]: 'site-new',
145+
[dependentKey(drive)]: '',
146+
[dependentKey(spreadsheet)]: '',
147+
[dependentKey(sheet)]: '',
148+
[dependentKey(unrelated)]: 'still-keep-me',
149+
})
150+
expect(effectiveDependentValue(drive, next, false)).toBe('')
151+
expect(effectiveCopyDependentValue(sheet, next)).toBe('')
152+
})
153+
154+
it('only changes the selected field when it provides no selector context', () => {
155+
const leaf = field({ subBlockKey: 'issueKey', currentValue: 'ISSUE-1' })
156+
const unrelated = field({ subBlockKey: 'label', currentValue: 'keep-me' })
157+
158+
expect(
159+
applyDependentRepick(
160+
{ [dependentKey(unrelated)]: 'still-keep-me' },
161+
leaf,
162+
[leaf, unrelated],
163+
'ISSUE-2'
164+
)
165+
).toEqual({
166+
[dependentKey(leaf)]: 'ISSUE-2',
167+
[dependentKey(unrelated)]: 'still-keep-me',
168+
})
169+
})
170+
})
171+
103172
describe('isDependentConfigurationActionable', () => {
104173
it('hides stored values when the mapped parent is unchanged', () => {
105174
expect(

apps/sim/ee/workspace-forking/components/fork-sync/dependent-value.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,40 @@ export function dependentKey(dependent: ForkDependentReconfig): string {
55
return `${dependent.targetWorkflowId}:${dependent.targetBlockId}:${dependent.subBlockKey}`
66
}
77

8+
/**
9+
* Store a dependent re-pick and clear every selector transitively scoped by it. Empty-string
10+
* overrides are intentional: an absent override means "fall back to the stored value", while a
11+
* changed provider makes every stored descendant stale for both mapped and copied parents.
12+
*/
13+
export function applyDependentRepick(
14+
reconfig: Record<string, string>,
15+
changedField: ForkDependentReconfig,
16+
blockFields: ForkDependentReconfig[],
17+
value: string
18+
): Record<string, string> {
19+
const changedKey = dependentKey(changedField)
20+
const nextState = { ...reconfig, [changedKey]: value }
21+
if (!changedField.providesContextKey) return nextState
22+
23+
const pendingContextKeys = [changedField.providesContextKey]
24+
const visitedFields = new Set([changedKey])
25+
for (let index = 0; index < pendingContextKeys.length; index += 1) {
26+
const contextKey = pendingContextKeys[index]
27+
if (!contextKey) continue
28+
29+
for (const field of blockFields) {
30+
const fieldKey = dependentKey(field)
31+
if (visitedFields.has(fieldKey) || !field.consumesContextKeys.includes(contextKey)) continue
32+
33+
visitedFields.add(fieldKey)
34+
nextState[fieldKey] = ''
35+
if (field.providesContextKey) pendingContextKeys.push(field.providesContextKey)
36+
}
37+
}
38+
39+
return nextState
40+
}
41+
842
/**
943
* The value sent + displayed for a dependent: the user's in-session re-pick if present, else the
1044
* stored value (`currentValue`). Blank when the parent target changed in-session, since the old

apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
import { forkRefKey } from '@/ee/workspace-forking/components/fork-sync/copy-reconciliation'
3535
import { DependentFieldSelector } from '@/ee/workspace-forking/components/fork-sync/dependent-field-selector'
3636
import {
37+
applyDependentRepick,
3738
type DependentConfigurationState,
3839
dependentKey,
3940
effectiveCopyDependentValue,
@@ -162,28 +163,6 @@ function blockChainState(
162163
return { providedValues, providedContextKeys }
163164
}
164165

165-
/** Store a re-pick and invalidate in-block children chained off the changed field. */
166-
function applyDependentRepick(
167-
setReconfig: Dispatch<SetStateAction<Record<string, string>>>,
168-
field: ForkDependentReconfig,
169-
blockFields: ForkDependentReconfig[],
170-
value: string
171-
) {
172-
setReconfig((prev) => {
173-
const nextState = { ...prev, [dependentKey(field)]: value }
174-
// A changed parent invalidates its children's stale re-picks.
175-
const providedKey = field.providesContextKey
176-
if (providedKey) {
177-
for (const sibling of blockFields) {
178-
if (sibling.consumesContextKeys.includes(providedKey)) {
179-
delete nextState[dependentKey(sibling)]
180-
}
181-
}
182-
}
183-
return nextState
184-
})
185-
}
186-
187166
interface DependentSelectorProps {
188167
field: ForkDependentReconfig
189168
block: DependentBlock
@@ -241,7 +220,9 @@ function DependentSelector({
241220
}}
242221
enabled={parentValue !== '' && ready}
243222
value={effectiveValue(field)}
244-
onChange={(value) => applyDependentRepick(setReconfig, field, block.fields, value)}
223+
onChange={(value) =>
224+
setReconfig((current) => applyDependentRepick(current, field, block.fields, value))
225+
}
245226
title={field.title}
246227
/>
247228
)

apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.test.ts

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,19 @@ const blockWith = (subBlocks: SubBlockConfig[]): BlockConfig =>
2020

2121
const sourceState = (
2222
blockType: string,
23-
subBlocks: Record<string, { value: unknown }>
23+
subBlocks: Record<string, { value: unknown }>,
24+
data?: Record<string, unknown>
2425
): WorkflowState =>
2526
({
26-
blocks: { 'block-1': { id: 'block-1', type: blockType, name: 'Block', subBlocks } },
27+
blocks: {
28+
'block-1': {
29+
id: 'block-1',
30+
type: blockType,
31+
name: 'Block',
32+
subBlocks,
33+
...(data && { data }),
34+
},
35+
},
2736
edges: [],
2837
loops: {},
2938
parallels: {},
@@ -325,6 +334,67 @@ describe('collectForkDependentReconfigs', () => {
325334
expect(sheet?.context.spreadsheetId).toBe('ss-src')
326335
})
327336

337+
it('uses the persisted canonical mode when building a dependent selector context', () => {
338+
vi.mocked(getBlock).mockReturnValue(
339+
blockWith([
340+
{ id: 'credential', title: 'Credential', type: 'oauth-input' },
341+
{ id: 'domain', title: 'Domain', type: 'short-input' },
342+
{
343+
id: 'projectId',
344+
title: 'Project',
345+
type: 'project-selector',
346+
canonicalParamId: 'projectId',
347+
mode: 'basic',
348+
selectorKey: 'jira.projects',
349+
dependsOn: ['credential', 'domain'],
350+
},
351+
{
352+
id: 'manualProjectId',
353+
title: 'Project ID',
354+
type: 'short-input',
355+
canonicalParamId: 'projectId',
356+
mode: 'advanced',
357+
dependsOn: ['credential', 'domain'],
358+
},
359+
{
360+
id: 'issueKey',
361+
title: 'Issue',
362+
type: 'file-selector',
363+
selectorKey: 'jira.issues',
364+
dependsOn: ['credential', 'domain', 'projectId'],
365+
required: true,
366+
},
367+
])
368+
)
369+
const states = new Map<string, WorkflowState>([
370+
[
371+
'wf-src',
372+
sourceState(
373+
'jira',
374+
{
375+
credential: { value: 'cred-src' },
376+
domain: { value: 'example.atlassian.net' },
377+
projectId: { value: 'project-basic-stale' },
378+
manualProjectId: { value: 'project-advanced' },
379+
issueKey: { value: 'ADV-1' },
380+
},
381+
{ canonicalModes: { projectId: 'advanced' } }
382+
),
383+
],
384+
])
385+
386+
const result = collectForkDependentReconfigs([replaceItem], states, resolve)
387+
388+
expect(result).toHaveLength(1)
389+
expect(result[0]).toMatchObject({
390+
subBlockKey: 'issueKey',
391+
context: {
392+
domain: 'example.atlassian.net',
393+
projectId: 'project-advanced',
394+
},
395+
})
396+
})
397+
328398
it('emits a credential-dependent selector nested inside a tool-input tool', () => {
329399
vi.mocked(getBlock).mockImplementation((type) => {
330400
if (type === 'agent') return blockWith([{ id: 'tools', title: 'Tools', type: 'tool-input' }])

apps/sim/ee/workspace-forking/lib/mapping/dependent-reconfigs.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,9 @@ function emitAnchoredDependents(params: EmitAnchoredParams): void {
109109
chaining,
110110
out,
111111
} = params
112-
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks)
112+
const fullContext = buildSelectorContextFromBlock(contextBlockType, contextSubBlocks, {
113+
canonicalModes,
114+
})
113115
const canonicalIndex = buildCanonicalIndex(config.subBlocks)
114116
const gates = createCanonicalModeGates(config.subBlocks, values, canonicalModes)
115117
const configById = new Map(config.subBlocks.filter((cfg) => cfg.id).map((cfg) => [cfg.id, cfg]))

0 commit comments

Comments
 (0)