fix: snapshot selected-step mutation payloads
This commit is contained in:
@@ -681,4 +681,126 @@ describe("useDraftAuthoring", () => {
|
||||
});
|
||||
expect(result.current.draft).toBe(canonical);
|
||||
});
|
||||
|
||||
it("reapplies an immutable input snapshot after caller-owned values mutate", async () => {
|
||||
const initial = workspace({ revision: 7 });
|
||||
const conflict = workspace({ revision: 7, status: "conflict" });
|
||||
const reloaded = workspace({ revision: 8, status: "invalid" });
|
||||
const canonical = workspace({ revision: 9 });
|
||||
const pathBinding = {
|
||||
path: { root: "state" as const, parts: ["fallback", "value"] },
|
||||
target: { root: "local" as const, parts: ["fallback"] },
|
||||
} satisfies InputBinding;
|
||||
const literalBinding = {
|
||||
target: { root: "local" as const, parts: ["options"] },
|
||||
value: { nested: { items: [{ enabled: true }] } },
|
||||
} satisfies InputBinding;
|
||||
const bindings: InputBinding[] = [pathBinding, literalBinding];
|
||||
const expectedBindings: ReadonlyArray<InputBinding> = [
|
||||
{
|
||||
path: { root: "state", parts: ["fallback", "value"] },
|
||||
target: { root: "local", parts: ["fallback"] },
|
||||
},
|
||||
{
|
||||
target: { root: "local", parts: ["options"] },
|
||||
value: { nested: { items: [{ enabled: true }] } },
|
||||
},
|
||||
];
|
||||
setStepInputBindings.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
|
||||
load.mockResolvedValue(reloaded);
|
||||
const { result } = renderHook(() => useDraftAuthoring({
|
||||
draft: initial,
|
||||
initialSelection: { kind: "node", nodeId: "render" },
|
||||
}));
|
||||
|
||||
await act(async () => result.current.setStepInputs(bindings));
|
||||
pathBinding.path.parts[0] = "context";
|
||||
pathBinding.target.parts[0] = "mutated-target";
|
||||
Object.assign(literalBinding, { target: "mutated-target" });
|
||||
const firstItem = literalBinding.value.nested.items[0];
|
||||
if (firstItem !== undefined) firstItem.enabled = false;
|
||||
bindings.push({ target: "added", value: true });
|
||||
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
|
||||
await act(async () => result.current.reload());
|
||||
await act(async () => result.current.reapply());
|
||||
|
||||
expect(setStepInputBindings).toHaveBeenLastCalledWith({
|
||||
workspaceId: "draft-report",
|
||||
revision: 8,
|
||||
stepId: "render",
|
||||
bindings: expectedBindings,
|
||||
});
|
||||
});
|
||||
|
||||
it("reapplies an immutable output snapshot after caller-owned values mutate", async () => {
|
||||
const initial = workspace({ revision: 7 });
|
||||
const conflict = workspace({ revision: 7, status: "conflict" });
|
||||
const reloaded = workspace({ revision: 8, status: "invalid" });
|
||||
const canonical = workspace({ revision: 9 });
|
||||
const firstBinding = {
|
||||
source: { root: "local" as const, parts: ["text"] },
|
||||
target: { root: "state" as const, parts: ["report"] },
|
||||
} satisfies OutputBinding;
|
||||
const secondBinding = {
|
||||
source: { root: "local" as const, parts: ["text"] },
|
||||
target: "state.audit.latest",
|
||||
} satisfies OutputBinding;
|
||||
const bindings: OutputBinding[] = [firstBinding, secondBinding];
|
||||
const expectedBindings: ReadonlyArray<OutputBinding> = [
|
||||
{
|
||||
source: { root: "local", parts: ["text"] },
|
||||
target: { root: "state", parts: ["report"] },
|
||||
},
|
||||
{ source: { root: "local", parts: ["text"] }, target: "state.audit.latest" },
|
||||
];
|
||||
setStepOutputBindings.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
|
||||
load.mockResolvedValue(reloaded);
|
||||
const { result } = renderHook(() => useDraftAuthoring({
|
||||
draft: initial,
|
||||
initialSelection: { kind: "node", nodeId: "render" },
|
||||
}));
|
||||
|
||||
await act(async () => result.current.setStepOutputs(bindings));
|
||||
firstBinding.source.parts[0] = "mutated-source";
|
||||
firstBinding.target.parts[0] = "mutated-target";
|
||||
Object.assign(secondBinding, { source: "mutated-source", target: "mutated-target" });
|
||||
bindings.push({ source: "added", target: "state.added" });
|
||||
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
|
||||
await act(async () => result.current.reload());
|
||||
await act(async () => result.current.reapply());
|
||||
|
||||
expect(setStepOutputBindings).toHaveBeenLastCalledWith({
|
||||
workspaceId: "draft-report",
|
||||
revision: 8,
|
||||
stepId: "render",
|
||||
bindings: expectedBindings,
|
||||
});
|
||||
});
|
||||
|
||||
it("reapplies an immutable setup patch after the caller mutates it", async () => {
|
||||
const initial = workspace({ revision: 7 });
|
||||
const conflict = workspace({ revision: 7, status: "conflict" });
|
||||
const reloaded = workspace({ revision: 8, status: "invalid" });
|
||||
const canonical = workspace({ revision: 9 });
|
||||
const patch = { description: "Original", retry: 0, timeoutSeconds: null } satisfies CapabilitySetupPatch;
|
||||
updateCapabilityStep.mockResolvedValueOnce(conflict).mockResolvedValueOnce(canonical);
|
||||
load.mockResolvedValue(reloaded);
|
||||
const { result } = renderHook(() => useDraftAuthoring({
|
||||
draft: initial,
|
||||
initialSelection: { kind: "node", nodeId: "render" },
|
||||
}));
|
||||
|
||||
await act(async () => result.current.updateSetup(patch));
|
||||
Object.assign(patch, { description: "Mutated", retry: 4, timeoutSeconds: 30 });
|
||||
act(() => result.current.select({ kind: "node", nodeId: "publish" }));
|
||||
await act(async () => result.current.reload());
|
||||
await act(async () => result.current.reapply());
|
||||
|
||||
expect(updateCapabilityStep).toHaveBeenLastCalledWith({
|
||||
workspaceId: "draft-report",
|
||||
revision: 8,
|
||||
stepId: "render",
|
||||
update: { description: "Original", retry: 0, timeoutSeconds: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -156,6 +156,63 @@ const mutationKey = (kind: string, input: unknown, revision: number): string =>
|
||||
return `${kind}:${revision}:${encoded ?? "undefined"}`;
|
||||
};
|
||||
|
||||
const copyJsonValue = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(copyJsonValue);
|
||||
if (value !== null && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, nestedValue]) => [key, copyJsonValue(nestedValue)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const copyInputBinding = (binding: InputBinding): InputBinding => {
|
||||
if ("path" in binding) {
|
||||
return {
|
||||
path:
|
||||
typeof binding.path === "string"
|
||||
? binding.path
|
||||
: { root: binding.path.root, parts: [...binding.path.parts] },
|
||||
target:
|
||||
typeof binding.target === "string"
|
||||
? binding.target
|
||||
: { root: binding.target.root, parts: [...binding.target.parts] },
|
||||
};
|
||||
}
|
||||
return {
|
||||
target:
|
||||
typeof binding.target === "string"
|
||||
? binding.target
|
||||
: { root: binding.target.root, parts: [...binding.target.parts] },
|
||||
value: copyJsonValue(binding.value),
|
||||
};
|
||||
};
|
||||
|
||||
const copyOutputBinding = (binding: OutputBinding): OutputBinding => ({
|
||||
source:
|
||||
typeof binding.source === "string"
|
||||
? binding.source
|
||||
: { root: binding.source.root, parts: [...binding.source.parts] },
|
||||
target:
|
||||
typeof binding.target === "string"
|
||||
? binding.target
|
||||
: { root: binding.target.root, parts: [...binding.target.parts] },
|
||||
});
|
||||
|
||||
const copyInputBindings = (
|
||||
bindings: ReadonlyArray<InputBinding>,
|
||||
): ReadonlyArray<InputBinding> => bindings.map(copyInputBinding);
|
||||
|
||||
const copyOutputBindings = (
|
||||
bindings: ReadonlyArray<OutputBinding>,
|
||||
): ReadonlyArray<OutputBinding> => bindings.map(copyOutputBinding);
|
||||
|
||||
const copySetupPatch = (patch: CapabilitySetupPatch): CapabilitySetupPatch => ({
|
||||
...(patch.description !== undefined ? { description: patch.description } : {}),
|
||||
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
|
||||
...(patch.timeoutSeconds !== undefined ? { timeoutSeconds: patch.timeoutSeconds } : {}),
|
||||
});
|
||||
|
||||
export const useDraftAuthoring = ({
|
||||
draft: initialDraft,
|
||||
initialSelection = canvasSelection,
|
||||
@@ -442,14 +499,19 @@ export const useDraftAuthoring = ({
|
||||
patch: CapabilitySetupPatch,
|
||||
allowTargetSelectionChange = false,
|
||||
): Promise<void> => {
|
||||
const submittedPatch = copySetupPatch(patch);
|
||||
const update = {
|
||||
...(patch.description !== undefined ? { description: patch.description } : {}),
|
||||
...(patch.retry !== undefined ? { retry: patch.retry } : {}),
|
||||
...(patch.timeoutSeconds !== undefined ? { timeoutSeconds: patch.timeoutSeconds } : {}),
|
||||
...(submittedPatch.description !== undefined
|
||||
? { description: submittedPatch.description }
|
||||
: {}),
|
||||
...(submittedPatch.retry !== undefined ? { retry: submittedPatch.retry } : {}),
|
||||
...(submittedPatch.timeoutSeconds !== undefined
|
||||
? { timeoutSeconds: submittedPatch.timeoutSeconds }
|
||||
: {}),
|
||||
};
|
||||
return runMutation(
|
||||
"setup",
|
||||
{ targetStepId, patch },
|
||||
{ targetStepId, patch: submittedPatch },
|
||||
(client, requestDraft) =>
|
||||
client.updateCapabilityStep({
|
||||
workspaceId: requestDraft.workspaceId,
|
||||
@@ -460,7 +522,7 @@ export const useDraftAuthoring = ({
|
||||
{
|
||||
targetStepId,
|
||||
allowTargetSelectionChange,
|
||||
submission: { kind: "setup", targetStepId, patch },
|
||||
submission: { kind: "setup", targetStepId, patch: submittedPatch },
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -472,23 +534,25 @@ export const useDraftAuthoring = ({
|
||||
targetStepId: string,
|
||||
bindings: ReadonlyArray<InputBinding>,
|
||||
allowTargetSelectionChange = false,
|
||||
): Promise<void> =>
|
||||
runMutation(
|
||||
): Promise<void> => {
|
||||
const submittedBindings = copyInputBindings(bindings);
|
||||
return runMutation(
|
||||
"inputs",
|
||||
{ targetStepId, bindings },
|
||||
{ targetStepId, bindings: submittedBindings },
|
||||
(client, requestDraft) =>
|
||||
client.setStepInputBindings({
|
||||
workspaceId: requestDraft.workspaceId,
|
||||
revision: requestDraft.revision,
|
||||
stepId: targetStepId,
|
||||
bindings,
|
||||
bindings: submittedBindings,
|
||||
}),
|
||||
{
|
||||
targetStepId,
|
||||
allowTargetSelectionChange,
|
||||
submission: { kind: "inputs", targetStepId, bindings },
|
||||
submission: { kind: "inputs", targetStepId, bindings: submittedBindings },
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[runMutation],
|
||||
);
|
||||
|
||||
@@ -497,23 +561,25 @@ export const useDraftAuthoring = ({
|
||||
targetStepId: string,
|
||||
bindings: ReadonlyArray<OutputBinding>,
|
||||
allowTargetSelectionChange = false,
|
||||
): Promise<void> =>
|
||||
runMutation(
|
||||
): Promise<void> => {
|
||||
const submittedBindings = copyOutputBindings(bindings);
|
||||
return runMutation(
|
||||
"outputs",
|
||||
{ targetStepId, bindings },
|
||||
{ targetStepId, bindings: submittedBindings },
|
||||
(client, requestDraft) =>
|
||||
client.setStepOutputBindings({
|
||||
workspaceId: requestDraft.workspaceId,
|
||||
revision: requestDraft.revision,
|
||||
stepId: targetStepId,
|
||||
bindings,
|
||||
bindings: submittedBindings,
|
||||
}),
|
||||
{
|
||||
targetStepId,
|
||||
allowTargetSelectionChange,
|
||||
submission: { kind: "outputs", targetStepId, bindings },
|
||||
submission: { kind: "outputs", targetStepId, bindings: submittedBindings },
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
[runMutation],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user