Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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() } }));
Expand Down
Original file line number Diff line number Diff line change
@@ -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) => (
<button {...props}>{children}</button>
),
}));

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<string>(),
changesSummary: '(2 changes)',
}),
}));

import { FrameworkRequirementsClientPage } from './FrameworkRequirementsClientPage';

function renderPage() {
render(
<FrameworkRequirementsClientPage
frameworkDetails={{ id: 'frk_1', name: 'NIST', version: '1', description: '', visible: true }}
initialRequirements={[]}
/>,
);
}

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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string | null>(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(
() =>
Expand Down Expand Up @@ -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<string>();
for (const row of data) {
Expand Down Expand Up @@ -304,8 +319,11 @@ export function FrameworkRequirementsClientPage({
<Button variant="outline" onClick={handleCancel} size="sm" className="rounded-xs">
Cancel
</Button>
<Button onClick={handleCommit} size="sm" className="rounded-xs">
Commit Changes
<Button variant="outline" onClick={handleCommit} size="sm" className="rounded-xs">
Save as Draft
</Button>
<Button onClick={handleSaveAndCommit} size="sm" className="rounded-xs">
Save and Commit
</Button>
</>
)}
Expand Down Expand Up @@ -430,6 +448,17 @@ export function FrameworkRequirementsClientPage({
frameworkName={frameworkDetails.name}
/>
)}
<PublishVersionDialog
frameworkId={frameworkDetails.id}
open={isPublishOpen}
onClose={() => setIsPublishOpen(false)}
latestVersion={latestPublishedVersion}
onPublished={() => {
setIsPublishOpen(false);
void refetchVersions();
router.refresh();
}}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Loading