From bb7064b8f8d0ac14855b4afc85ff61ccc1043a31 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 12 Jun 2026 13:05:55 -0400 Subject: [PATCH] feat(framework-editor): add save-as-draft and save-and-commit buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the single "Commit Changes" button with the three buttons Joe asked for (FRAME-4, interpretation A): - Cancel — discard the uncommitted grid edits (unchanged). - Save as Draft — persist edits to the live templates without publishing (the previous "Commit Changes" behaviour). - Save and Commit — persist edits, then open the Publish Version dialog so the accumulated changes go out as a new version. Publish only opens when every edit saved cleanly. handleCommit now returns whether the save succeeded so Save-and-Commit can chain the publish step safely. Reuses the existing PublishVersionDialog and useFrameworkVersions (for the next-version suggestion). Closes FRAME-4 Co-Authored-By: Claude Opus 4.8 --- ...RequirementsClientPage.expandable.test.tsx | 6 ++ ...orkRequirementsClientPage.toolbar.test.tsx | 101 ++++++++++++++++++ .../FrameworkRequirementsClientPage.tsx | 33 +++++- .../hooks/useRequirementChangeTracking.ts | 4 + 4 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx index 51836f7cf1..ea4910bf86 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.expandable.test.tsx @@ -22,6 +22,12 @@ vi.mock('../../../components/table', () => ({ vi.mock('./components/EditFrameworkDialog', () => ({ EditFrameworkDialog: () => null })); vi.mock('./components/DeleteFrameworkDialog', () => ({ DeleteFrameworkDialog: () => null })); +vi.mock('./versions/components/PublishVersionDialog', () => ({ + PublishVersionDialog: () => null, +})); +vi.mock('./versions/hooks/useFrameworkVersions', () => ({ + useFrameworkVersions: () => ({ data: [], refetch: vi.fn() }), +})); vi.mock('@/app/lib/api-client', () => ({ apiClient: vi.fn() })); vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx new file mode 100644 index 0000000000..48b8bf20fc --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.toolbar.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Shared, hoisted handles so the mock factory and the assertions see the same +// references. publishProps records each render's `open` value. +const { handleCommit, handleCancel, publishProps } = vi.hoisted(() => ({ + handleCommit: vi.fn(async () => true), + handleCancel: vi.fn(), + publishProps: [] as Array<{ open: boolean }>, +})); + +vi.mock('../../../components/table', () => ({ + ComboboxCell: () => null, + DateCell: () => null, + RelationalCell: () => null, + EditableCell: () => null, +})); +vi.mock('./components/EditFrameworkDialog', () => ({ EditFrameworkDialog: () => null })); +vi.mock('./components/DeleteFrameworkDialog', () => ({ DeleteFrameworkDialog: () => null })); +vi.mock('./versions/components/PublishVersionDialog', () => ({ + PublishVersionDialog: (props: { open: boolean }) => { + publishProps.push({ open: props.open }); + return null; + }, +})); +vi.mock('./versions/hooks/useFrameworkVersions', () => ({ + useFrameworkVersions: () => ({ data: [{ version: '1.0.0' }], refetch: vi.fn() }), +})); +vi.mock('@/app/lib/api-client', () => ({ apiClient: vi.fn() })); +vi.mock('next/navigation', () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) })); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock('@trycompai/ui', () => ({ + Button: ({ children, variant: _v, size: _s, ...props }: any) => ( + + ), +})); + +vi.mock('./hooks/useRequirementChangeTracking', () => ({ + simpleUUID: () => 'temp-id', + useRequirementChangeTracking: () => ({ + data: [], + updateCell: vi.fn(), + updateRelational: vi.fn(), + addRow: vi.fn(), + deleteRow: vi.fn(), + getRowClassName: () => '', + handleCommit, + handleCancel, + isDirty: true, + createdIds: new Set(), + changesSummary: '(2 changes)', + }), +})); + +import { FrameworkRequirementsClientPage } from './FrameworkRequirementsClientPage'; + +function renderPage() { + render( + , + ); +} + +describe('FrameworkRequirementsClientPage — Save as Draft / Save and Commit (FRAME-4)', () => { + beforeEach(() => { + vi.clearAllMocks(); + handleCommit.mockImplementation(async () => true); + publishProps.length = 0; + }); + + it('shows all three buttons when there are uncommitted changes', () => { + renderPage(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Save as Draft' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Save and Commit' })).toBeTruthy(); + }); + + it('Save as Draft commits without opening the publish dialog', () => { + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Save as Draft' })); + expect(handleCommit).toHaveBeenCalledTimes(1); + expect(publishProps.every((p) => p.open === false)).toBe(true); + }); + + it('Save and Commit saves then opens the publish dialog', async () => { + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Save and Commit' })); + expect(handleCommit).toHaveBeenCalledTimes(1); + await waitFor(() => expect(publishProps.some((p) => p.open === true)).toBe(true)); + }); + + it('does not open the publish dialog when the save fails', async () => { + handleCommit.mockImplementation(async () => false); + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Save and Commit' })); + await waitFor(() => expect(handleCommit).toHaveBeenCalled()); + expect(publishProps.every((p) => p.open === false)).toBe(true); + }); +}); diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx index da517c096c..43e752d9b0 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/FrameworkRequirementsClientPage.tsx @@ -22,6 +22,8 @@ import { useRequirementChangeTracking, type RequirementGridRow, } from './hooks/useRequirementChangeTracking'; +import { PublishVersionDialog } from './versions/components/PublishVersionDialog'; +import { useFrameworkVersions } from './versions/hooks/useFrameworkVersions'; interface FrameworkDetails { id: string; @@ -76,6 +78,12 @@ export function FrameworkRequirementsClientPage({ // Row whose large description editor is currently open — highlighted so the // edited row is obvious behind the (semi-transparent) editor dialog. const [expandedRowId, setExpandedRowId] = useState(null); + // "Save and Commit" saves the edits then opens the publish flow (FRAME-4). + const [isPublishOpen, setIsPublishOpen] = useState(false); + const { data: publishedVersions, refetch: refetchVersions } = useFrameworkVersions( + frameworkDetails.id, + ); + const latestPublishedVersion = publishedVersions?.[0]?.version; const initialGridData: RequirementGridRow[] = useMemo( () => @@ -107,6 +115,13 @@ export function FrameworkRequirementsClientPage({ changesSummary, } = useRequirementChangeTracking(initialGridData, frameworkDetails.id); + // Save edits, then (only if they all persisted) open the publish dialog so + // the accumulated changes can be committed as a new version. + const handleSaveAndCommit = useCallback(async () => { + const ok = await handleCommit(); + if (ok) setIsPublishOpen(true); + }, [handleCommit]); + const uniqueFamilies = useMemo(() => { const families = new Set(); for (const row of data) { @@ -304,8 +319,11 @@ export function FrameworkRequirementsClientPage({ - + )} @@ -430,6 +448,17 @@ export function FrameworkRequirementsClientPage({ frameworkName={frameworkDetails.name} /> )} + setIsPublishOpen(false)} + latestVersion={latestPublishedVersion} + onPublished={() => { + setIsPublishOpen(false); + void refetchVersions(); + router.refresh(); + }} + /> ); } diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts index 3d699e390d..ebf5d1213b 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/hooks/useRequirementChangeTracking.ts @@ -256,6 +256,10 @@ export function useRequirementChangeTracking( // Re-sync the grid with server truth (ids, timestamps, links). router.refresh(); } + + // Report success so callers (e.g. "Save and Commit") can chain a publish + // only when every edit persisted cleanly. + return results.errors.length === 0; }, [data, createdIds, updatedIds, deletedIds, frameworkId, router]); const handleCancel = useCallback(() => {