diff --git a/services/platform/app/features/automations/components/automation-editor.test.tsx b/services/platform/app/features/automations/components/automation-editor.test.tsx index dbbabd8411..be713f5e79 100644 --- a/services/platform/app/features/automations/components/automation-editor.test.tsx +++ b/services/platform/app/features/automations/components/automation-editor.test.tsx @@ -3,7 +3,7 @@ import { useState, type AnchorHTMLAttributes } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { checkAccessibility } from '@/tests/utils/a11y'; -import { render, screen, waitFor, within } from '@/tests/utils/render'; +import { act, render, screen, waitFor, within } from '@/tests/utils/render'; import { AutomationVersionPicker } from './automation-version-picker'; import { AutomationVersionPickerTarget } from './automation-version-picker-target'; @@ -296,7 +296,10 @@ async function editTheNode(user: ReturnType['user']) { } beforeEach(() => { - saveMutation.mutateAsync = vi.fn().mockResolvedValue(undefined); + saveMutation.mutateAsync = vi.fn().mockResolvedValue({ + name: 'billing/dunning', + version: 4, + }); saveMutation.isPending = false; toastSpy.mockClear(); onSelectVersion.mockClear(); @@ -1010,7 +1013,7 @@ describe('AutomationEditor', () => { baseVersion: 3, }, }) - .mockResolvedValue(undefined); + .mockResolvedValue({ name: 'billing/dunning', version: 5 }); const { user } = renderPage(); await editTheNode(user); await user.click(saveButton()); @@ -1042,6 +1045,208 @@ describe('AutomationEditor', () => { }); }); + it.each([false, true])( + 'keeps later edits dirty after Save anyway succeeds (query refreshed: %s)', + async (refreshQuery) => { + const append = Promise.withResolvers<{ name: string; version: number }>(); + saveMutation.mutateAsync = vi + .fn() + .mockRejectedValueOnce({ + data: { + code: 'AUTOMATION_VERSION_STALE', + message: 'v4 landed.', + latestVersion: 4, + baseVersion: 3, + }, + }) + .mockImplementationOnce(() => append.promise) + .mockResolvedValue({ name: 'billing/dunning', version: 6 }); + const { user, rerender } = renderPage(); + await user.click(screen.getByRole('button', { name: 'select summary' })); + const prompt = () => screen.getByRole('textbox', { name: 'Prompt' }); + await user.clear(prompt()); + await user.paste('Submitted draft'); + await user.click(saveButton()); + await user.click(screen.getByRole('button', { name: 'Save version' })); + await screen.findByText('v4 landed.'); + await user.click(screen.getByRole('button', { name: 'Save anyway' })); + + saveMutation.isPending = true; + rerender(page()); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(prompt()).not.toHaveAttribute('readonly'); + expect(saveButton()).toBeDisabled(); + expect(discardButton()).toBeDisabled(); + expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + baseVersion: 4, + automation: expect.objectContaining({ + nodes: [expect.objectContaining({ prompt: 'Submitted draft' })], + }), + }), + ); + + await user.clear(prompt()); + await user.paste('Later unsaved draft'); + expect(prompt()).toHaveValue('Later unsaved draft'); + const submitted = saveMutation.mutateAsync.mock.calls[1]?.[0].automation; + expect(submitted.nodes[0].prompt).toBe('Submitted draft'); + if (refreshQuery) { + state.document = submitted; + state.version = 5; + rerender(page()); + } + await act(async () => { + saveMutation.isPending = false; + append.resolve({ name: 'billing/dunning', version: 5 }); + }); + rerender(page()); + + expect(prompt()).toHaveValue('Later unsaved draft'); + expect(saveButton()).toBeEnabled(); + expect(discardButton()).toBeEnabled(); + expect(toastSpy).not.toHaveBeenCalled(); + expect(onSelectVersion).toHaveBeenCalledWith(undefined); + + await user.click(saveButton()); + await user.click(screen.getByRole('button', { name: 'Save version' })); + await waitFor(() => expect(discardButton()).toBeDisabled()); + expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + baseVersion: 5, + automation: expect.objectContaining({ + nodes: [expect.objectContaining({ prompt: 'Later unsaved draft' })], + }), + }), + ); + }, + 30_000, + ); + + it.each([true, false])( + 'settles only its own draft after a pending append is discarded (replacement edited while pending: %s)', + async (editWhilePending) => { + state.deployedDocument = { + name: 'billing/dunning', + description: 'The v2 document.', + nodes: [{ id: 'summary', type: 'llm', prompt: 'Stored on v2' }], + }; + const append = Promise.withResolvers<{ name: string; version: number }>(); + saveMutation.mutateAsync = vi + .fn() + .mockRejectedValueOnce({ + data: { + code: 'AUTOMATION_VERSION_STALE', + message: 'v4 landed.', + latestVersion: 4, + baseVersion: 3, + }, + }) + .mockImplementationOnce(() => append.promise) + .mockRejectedValueOnce({ + data: { + code: 'AUTOMATION_VERSION_STALE', + message: 'v5 landed after v2.', + latestVersion: 5, + baseVersion: 2, + }, + }); + const { user, rerender } = renderPage(); + const prompt = () => screen.getByRole('textbox', { name: 'Prompt' }); + const editReplacement = async () => { + await user.clear(prompt()); + await user.paste('Edited on v2'); + }; + await user.click(screen.getByRole('button', { name: 'select summary' })); + await user.clear(prompt()); + await user.paste('Submitted draft'); + await user.click(saveButton()); + await user.click(screen.getByRole('button', { name: 'Save version' })); + await screen.findByText('v4 landed.'); + await user.click(screen.getByRole('button', { name: 'Save anyway' })); + saveMutation.isPending = true; + rerender(page()); + + expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + baseVersion: 4, + automation: expect.objectContaining({ + nodes: [expect.objectContaining({ prompt: 'Submitted draft' })], + }), + }), + ); + const submitted = saveMutation.mutateAsync.mock.calls[1]?.[0].automation; + await user.click(versionPicker()); + await user.click(screen.getByRole('radio', { name: /^v2/ })); + await user.click( + screen.getByRole('button', { name: 'Discard and switch' }), + ); + expect(prompt()).toHaveValue('Stored on v2'); + if (editWhilePending) await editReplacement(); + expect(versionPicker()).toHaveTextContent('v2'); + onSelectVersion.mockClear(); + + state.document = submitted; + state.version = 5; + rerender(page()); + await act(async () => { + saveMutation.isPending = false; + append.resolve({ name: 'billing/dunning', version: 5 }); + }); + rerender(page()); + + expect(versionPicker()).toHaveTextContent('v2'); + expect(onSelectVersion).not.toHaveBeenCalled(); + expect(prompt()).toHaveValue( + editWhilePending ? 'Edited on v2' : 'Stored on v2', + ); + expect(submitted.nodes[0].prompt).toBe('Submitted draft'); + if (!editWhilePending) { + expect(discardButton()).toBeDisabled(); + await editReplacement(); + } + expect(saveButton()).toBeEnabled(); + expect(discardButton()).toBeEnabled(); + await user.click(saveButton()); + await user.click(screen.getByRole('button', { name: 'Save version' })); + await screen.findByText('v5 landed after v2.'); + expect(saveMutation.mutateAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + baseVersion: 2, + automation: expect.objectContaining({ + description: 'The v2 document.', + nodes: [expect.objectContaining({ prompt: 'Edited on v2' })], + }), + }), + ); + expect(toastSpy).not.toHaveBeenCalled(); + }, + 30_000, + ); + + it('keeps the draft when the stale-version decision is cancelled', async () => { + saveMutation.mutateAsync = vi.fn().mockRejectedValue({ + data: { + code: 'AUTOMATION_VERSION_STALE', + message: 'v4 landed.', + latestVersion: 4, + baseVersion: 3, + }, + }); + const { user } = renderPage(); + await editTheNode(user); + await user.click(saveButton()); + await user.click(screen.getByRole('button', { name: 'Save version' })); + await screen.findByText('v4 landed.'); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(whenField()).toHaveValue('x'); + expect(saveButton()).toBeEnabled(); + expect(discardButton()).toBeEnabled(); + expect(saveMutation.mutateAsync).toHaveBeenCalledTimes(1); + expect(toastSpy).not.toHaveBeenCalled(); + }); + it('drops the draft and shows the newer version on reload after a stale refusal', async () => { saveMutation.mutateAsync = vi.fn().mockRejectedValue({ data: { diff --git a/services/platform/app/features/automations/components/automation-editor.tsx b/services/platform/app/features/automations/components/automation-editor.tsx index 6b451807a3..451ef1d7b1 100644 --- a/services/platform/app/features/automations/components/automation-editor.tsx +++ b/services/platform/app/features/automations/components/automation-editor.tsx @@ -229,6 +229,7 @@ function AutomationEditorScope({ /** The version the draft was built on โ€” pinned on its first edit, sent * with the save so the store can refuse a draft another tab overtook. */ const draftBaseRef = useRef(undefined); + const draftEpochRef = useRef(0); /** A save the store refused because a version landed after the draft * started: the author decides โ€” drop the draft and reload, or save on * top of what landed. Never resolved silently either way. */ @@ -357,7 +358,10 @@ function AutomationEditorScope({ // follows every version another tab saves (its hint invalidates the // read), so reading the version at save time would name the one that // overtook the draft, not the one it was built on. - if (draft === null) draftBaseRef.current = automationQuery.data?.version; + if (draft === null) { + draftBaseRef.current = automationQuery.data?.version; + draftEpochRef.current += 1; + } setDraft(patchNode(automation, selectedNodeId, patch)); }, [automation, selectedNodeId, draft, automationQuery.data?.version], @@ -407,6 +411,7 @@ function AutomationEditorScope({ }, []); const discardDraft = useCallback(() => { + draftEpochRef.current += 1; setDraft(null); }, []); @@ -532,7 +537,8 @@ function AutomationEditorScope({ /** Append the draft as a version built on `baseVersion` (none: append * whatever the latest is), then show the version that landed. */ const submitSave = async (baseVersion: number | undefined): Promise => { - await save.mutateAsync({ + const submittedEpoch = draftEpochRef.current; + const saved = await save.mutateAsync({ organizationId, automation, // Package metadata belongs to the version being edited, even when @@ -553,8 +559,10 @@ function AutomationEditorScope({ ...(projectId !== undefined && { projectId }), ...(baseVersion !== undefined && { baseVersion }), }); + if (draftEpochRef.current !== submittedEpoch) return; setSaveDialogOpen(false); - setDraft(null); + draftBaseRef.current = saved.version; + setDraft((current) => (current === automation ? null : current)); setSaveMessage(''); // The save appended a version; show it, whichever one was on screen. onSelectVersion(undefined); @@ -602,7 +610,7 @@ function AutomationEditorScope({ /** Drop the draft and show the version that landed. */ const reloadAfterStale = (): void => { setStaleSave(null); - setDraft(null); + discardDraft(); onSelectVersion(undefined); }; @@ -1002,7 +1010,7 @@ function AutomationEditorScope({ confirmText={t('detail.switchVersion.confirm')} variant="destructive" onConfirm={() => { - setDraft(null); + discardDraft(); if (pendingVersion !== null) onSelectVersion(pendingVersion); setPendingVersion(null); }} diff --git a/services/platform/tests/manual/reference/automation.md b/services/platform/tests/manual/reference/automation.md index 5ee0ddfeff..e7ff022e33 100644 --- a/services/platform/tests/manual/reference/automation.md +++ b/services/platform/tests/manual/reference/automation.md @@ -323,6 +323,7 @@ Legend: โœ… fully automated ยท ๐Ÿ”ถ partially automated ยท โ›” manual-only (no s | [automations](../suites/automations.md) | `AUTO-F39` (the General tab): Trigger and Projects as sections under one Save/Discard cluster; Save writes every edited section, Discard restores both, a refused save raises one toast and keeps the edit, a refetch that only moves the last fire or answers the same project set keeps an edit in progress, a removed trigger leaves no unsaved edit behind; members see no cluster | ๐Ÿ”ถ component | `automation-general-tab.test.tsx`, `trigger-editor.test.tsx`, `project-bindings-section.test.tsx`, `automation-detail-shell.test.tsx` (tab order, the General tab's dot); the unsaved-changes dialog on leaving stays manual | | [automations](../suites/automations.md) | Everything else in `AUTO-F1`โ€“`AUTO-F34`, `AUTO-B1`โ€“`AUTO-B6` | โ›” manual-only | โ€” | | [automations](../suites/automations.md) | `AUTO-F5`, `AUTO-F13`โ€“`AUTO-F16`, `AUTO-F19`, `AUTO-F35`, `AUTO-F36` | ๐Ÿ”ถ partial | unit: `automations-list.test.tsx`, `automation-detail-shell.test.tsx`, `automation-editor.test.tsx`, `automation-canvas.test.tsx`, `node-inspector.test.tsx`, `version-list.test.tsx`, `detail-paths.test.ts` | +| [automations](../suites/automations.md) | Save anyway keeps edits made during a pending append as a dirty draft; the submitted snapshot is unchanged, and the next save uses the appended version as its base even before the detail query refreshes. Cancelling a stale-version decision retains the draft; a save without later edits still clears it. | ๐Ÿ”ถ component | `app/features/automations/components/automation-editor.test.tsx` (real editor, inspector and shared controls; deferred data-hook response, canvas fixture); live two-session/network timing remains manual | | [automations](../suites/automations.md) | `AUTO-F18`, `AUTO-F20` (manual run input) | ๐Ÿ”ถ partial | `automation-editor.test.tsx`: required JSON input, malformed/type-invalid refusal, displayed mock version, deployed live schema/version and explicit confirmation; real connector effects stay manual | | [automations](../suites/automations.md) | `AUTO-F8`โ€“`AUTO-F11` | ๐Ÿ”ถ partial | unit: `upload-automation-dialog.test.tsx` (lanes, zip cap, skill-conflict panel โ€” no end-to-end run) | | [automations](../suites/automations.md) | `AUTO-F22`โ€“`AUTO-F23`, `AUTO-F26` | ๐Ÿ”ถ partial | unit: `effect-list.test.tsx`, `agent-execution-log.test.tsx`, `run-ask-card.test.tsx` |