From ac50f6e0d780026aa4786cf0ca9ce7ed40988d3b Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 19 Aug 2026 17:35:20 -0700 Subject: [PATCH] fix(workspace-forking): keep a repointed custom block's inputs configurable after the mapping is saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapping a custom block to a different block and syncing showed "no changes required" with no fields to fill, so the inputs #6871 added were unreachable on every sync after the one where the mapping was picked. `parentChanged` comes from `shouldReconfigureEntry`, which asks whether the target was edited IN THIS SESSION. Saving the mapping makes it false, and the reconfigure listing then keeps only fields that are both required and empty — so an optional input disappeared entirely and a filled required one never came back. That test is right for every other kind: an unchanged credential or table mapping leaves its stored dependent picks valid, and a Gmail label picked under the same credential still resolves. A custom block has no such continuity. Its sub-blocks are keyed by the SOURCE block's Start field ids, so under a different target they describe fields that do not exist and nothing carries over — the mapping standing IS the reason to configure, whenever it was made. A custom block mapped to a different block is now always actionable; mapped to itself ("keep the same block across environments") it is not, since its own field ids still describe it. An in-session re-pick still wins over the saved target. Co-Authored-By: Claude Opus 5 (1M context) --- .../fork-sync/use-fork-sync.test.tsx | 69 +++++++++++++++++++ .../components/fork-sync/use-fork-sync.ts | 14 +++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.test.tsx b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.test.tsx index dbf6f10d502..b5c4e09b54c 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.test.tsx +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.test.tsx @@ -49,6 +49,7 @@ import { } from '@/ee/workspace-forking/components/fork-sync/dependent-value' import { type ForkSyncController, + shouldReconfigureEntry, useForkSync, } from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' @@ -556,3 +557,71 @@ describe('useForkSync post-sync reset', () => { expect(get().dirty).toBe(true) }) }) + +describe('shouldReconfigureEntry', () => { + const entry = (overrides: Partial = {}): ForkMappingEntry => + ({ + kind: 'credential', + sourceId: 'cred-src', + targetId: 'cred-tgt', + suggested: false, + ...overrides, + }) as ForkMappingEntry + + it('is false for a settled non-custom-block mapping', () => { + // An unchanged credential mapping leaves its stored dependent picks valid, so its fields + // stay out of the way until something actually changes. + expect(shouldReconfigureEntry(entry(), {})).toBe(false) + }) + + it('is true for a custom block mapped to a DIFFERENT block, even once saved', () => { + // The regression: `parentChanged` drove the reconfigure UI off "was this edited in this + // session", so a saved mapping read as settled and the modal showed "no changes required" + // with no inputs. A custom block pointed elsewhere has sub-blocks keyed by the SOURCE + // block's field ids — they describe nothing on the target and nothing carries over — so it + // needs configuring for as long as the mapping stands, not just the session it was made in. + const saved = entry({ + kind: 'custom-block', + sourceId: 'custom_block_prod01', + targetId: 'custom_block_uat0001', + }) + + expect(shouldReconfigureEntry(saved, {})).toBe(true) + }) + + it('is false for a custom block mapped to itself', () => { + // "Keep the same block across environments": the type never changes, so the source's own + // field ids still describe it and its values carry across untouched. + const identity = entry({ + kind: 'custom-block', + sourceId: 'custom_block_prod01', + targetId: 'custom_block_prod01', + }) + + expect(shouldReconfigureEntry(identity, {})).toBe(false) + }) + + it('follows an in-session custom-block re-pick rather than the saved target', () => { + const saved = entry({ + kind: 'custom-block', + sourceId: 'custom_block_prod01', + targetId: 'custom_block_uat0001', + }) + const key = `${saved.kind}:${saved.sourceId}` + + // Re-pointed back at itself in-session: nothing to configure. + expect(shouldReconfigureEntry(saved, { [key]: 'custom_block_prod01' })).toBe(false) + // Re-pointed at a third block: configure against that one. + expect(shouldReconfigureEntry(saved, { [key]: 'custom_block_sbx0001' })).toBe(true) + }) + + it('is false for an unmapped custom block, which the promote blocks instead', () => { + const unmapped = entry({ + kind: 'custom-block', + sourceId: 'custom_block_prod01', + targetId: null, + }) + + expect(shouldReconfigureEntry(unmapped, {})).toBe(false) + }) +}) diff --git a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts index 10b657a157e..28a26706642 100644 --- a/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts +++ b/apps/sim/ee/workspace-forking/components/fork-sync/use-fork-sync.ts @@ -236,9 +236,21 @@ const entryKey = (entry: ForkMappingEntry) => forkRefKey(entry) * the dependents). Pure over (entry, in-session targets) so the inline render, the Sync * gate, and the payload build share one predicate instead of drifting copies. */ -function shouldReconfigureEntry(entry: ForkMappingEntry, targets: Record): boolean { +export function shouldReconfigureEntry( + entry: ForkMappingEntry, + targets: Record +): boolean { const next = targets[entryKey(entry)] ?? entry.targetId ?? '' if (next === '') return false + // A custom block pointed at a DIFFERENT block needs its inputs configured for as long as + // that mapping stands, not only in the session where it was picked. Every other kind can + // fall through to the in-session test because an unchanged mapping leaves its stored + // dependent values valid — a Gmail label picked under the same credential still resolves. + // A custom block has no such continuity: its sub-blocks are keyed by the SOURCE block's + // Start field ids, so under a different target they describe fields that do not exist and + // nothing carries over. Treating it as settled once saved is what left the fields hidden + // behind "no changes required" on every sync after the first. + if (entry.kind === 'custom-block') return next !== entry.sourceId return entry.suggested || next !== (entry.targetId ?? '') }