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 @@ -65,6 +65,29 @@ describe('diffManifests', () => {
expect(diff.requirementMapEdges.removed).toContainEqual({ controlTemplateId: 'c1', requirementTemplateId: 'r1' });
});

it('reports no framework-metadata change for identical manifests', () => {
const m = emptyManifest();
expect(diffManifests(m, m).framework.changed).toBe(false);
});

it('detects a framework name change (FRAME-9)', () => {
const from = emptyManifest();
const to = { ...emptyManifest(), framework: { ...from.framework, name: 'New Name' } };
const diff = diffManifests(from, to);
expect(diff.framework.changed).toBe(true);
expect(diff.framework.name).toEqual({ from: 'n', to: 'New Name' });
expect(diff.framework.description).toBeUndefined();
});

it('detects a framework description change (FRAME-9)', () => {
const from = { ...emptyManifest(), framework: { id: 'f', name: 'n', catalogVersion: '1', description: 'old' } };
const to = { ...emptyManifest(), framework: { id: 'f', name: 'n', catalogVersion: '1', description: 'new' } };
const diff = diffManifests(from, to);
expect(diff.framework.changed).toBe(true);
expect(diff.framework.description).toEqual({ from: 'old', to: 'new' });
expect(diff.framework.name).toBeUndefined();
});

it('drops phantom edges that reference entities missing from the manifest', () => {
// Older snapshots sometimes stored cross-framework requirement IDs in
// control.requirementIds. Those IDs are not in manifest.requirements, so
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/frameworks/framework-versioning/framework-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,19 @@ export interface ControlDocumentTypeEdge {
formType: string;
}

/**
* Changes to the framework's own metadata (name / description). These don't
* live in any entity list, so without this the diff treats a name- or
* description-only edit as "no changes" and the Publish button stays disabled.
*/
export interface FrameworkMetaDiff {
changed: boolean;
name?: { from: string; to: string };
description?: { from: string | null; to: string | null };
}

export interface ManifestDiff {
framework: FrameworkMetaDiff;
controls: EntityDiff<ManifestControl>;
requirements: EntityDiff<ManifestRequirement>;
policies: EntityDiff<ManifestPolicy>;
Expand Down Expand Up @@ -85,6 +97,7 @@ export function diffManifests(fromRaw: FrameworkManifest, toRaw: FrameworkManife
const from = sanitizeManifestEdges(fromRaw);
const to = sanitizeManifestEdges(toRaw);
return {
framework: diffFrameworkMeta(from.framework, to.framework),
controls: diffEntities(from.controls, to.controls, controlEqual),
requirements: diffEntities(from.requirements, to.requirements, requirementEqual),
policies: diffEntities(from.policies, to.policies, policyEqual),
Expand Down Expand Up @@ -168,6 +181,23 @@ function edgesFromControls<E>(
return controls.flatMap(extract);
}

function diffFrameworkMeta(
from: FrameworkManifest['framework'],
to: FrameworkManifest['framework'],
): FrameworkMetaDiff {
const nameChanged = from.name !== to.name;
const fromDescription = from.description ?? null;
const toDescription = to.description ?? null;
const descriptionChanged = fromDescription !== toDescription;
return {
changed: nameChanged || descriptionChanged,
...(nameChanged ? { name: { from: from.name, to: to.name } } : {}),
...(descriptionChanged
? { description: { from: fromDescription, to: toDescription } }
: {}),
};
}

function controlEqual(a: ManifestControl, b: ManifestControl): boolean {
return a.name === b.name && a.description === b.description && (a.controlFamily ?? null) === (b.controlFamily ?? null);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import type { DraftDiff } from '../hooks/useFrameworkDraftDiff';
import { hasAnyChanges } from './VersionDiffView';

function emptyDiff(): DraftDiff['diff'] {
const entity = { added: [], removed: [], updated: [] };
const edge = { added: [], removed: [] };
return {
controls: entity,
requirements: entity,
policies: entity,
tasks: entity,
requirementMapEdges: edge,
controlPolicyEdges: edge,
controlTaskEdges: edge,
controlDocumentTypeEdges: edge,
};
}

describe('hasAnyChanges', () => {
it('is false for an empty diff', () => {
expect(hasAnyChanges(emptyDiff())).toBe(false);
});

// FRAME-9: a name/description-only edit must count as a change so Publish
// doesn't stay greyed out with "no changes detected".
it('is true when only the framework name changed', () => {
const diff = { ...emptyDiff(), framework: { changed: true, name: { from: 'A', to: 'B' } } };
expect(hasAnyChanges(diff)).toBe(true);
});

it('is true when only the framework description changed', () => {
const diff = {
...emptyDiff(),
framework: { changed: true, description: { from: 'old', to: 'new' } },
};
expect(hasAnyChanges(diff)).toBe(true);
});

it('is false when framework.changed is false', () => {
const diff = { ...emptyDiff(), framework: { changed: false } };
expect(hasAnyChanges(diff)).toBe(false);
});

it('still detects entity changes (sanity)', () => {
const diff = emptyDiff();
diff.controls = { added: [{ id: 'c1', name: 'C1' }], removed: [], updated: [] };
expect(hasAnyChanges(diff)).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function hasAnyChanges(diff: DraftDiff['diff']): boolean {
} = diff;
const docTypeEdges = controlDocumentTypeEdges ?? { added: [], removed: [] };
return (
(diff.framework?.changed ?? false) ||
controls.added.length > 0 ||
controls.removed.length > 0 ||
controls.updated.length > 0 ||
Expand All @@ -58,6 +59,7 @@ export function hasAnyChanges(diff: DraftDiff['diff']): boolean {
export function VersionDiffView({ diff, linkChanges }: VersionDiffViewProps) {
return (
<>
<FrameworkMetaSection framework={diff.framework} />
<DiffDetailSection
title="Requirements"
added={diff.requirements.added}
Expand Down Expand Up @@ -172,6 +174,37 @@ export function VersionDiffView({ diff, linkChanges }: VersionDiffViewProps) {
);
}

function FrameworkMetaSection({
framework,
}: {
framework: DraftDiff['diff']['framework'];
}) {
if (!framework?.changed) return null;
return (
<div className="border-b last:border-b-0 px-4 py-3">
<p className="text-muted-foreground mb-2 text-xs font-semibold uppercase tracking-wide">
Framework
</p>
<div className="flex flex-col gap-1">
{framework.name && (
<DiffRow kind="modified">
<span>
Name:{' '}
<span className="text-muted-foreground line-through">{framework.name.from}</span>{' '}
→ <span className="font-medium">{framework.name.to}</span>
</span>
</DiffRow>
)}
{framework.description && (
<DiffRow kind="modified">
<span>Description updated</span>
</DiffRow>
)}
</div>
</div>
);
}

interface DiffDetailSectionProps<T extends { id: string }> {
title: string;
added: T[];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export interface EdgeDiffCounts {
export interface DraftDiff {
latestVersion: { id: string; version: string } | null;
diff: {
// Optional for older API responses / historical diffs that predate the
// framework-metadata diff (FRAME-9).
framework?: {
changed: boolean;
name?: { from: string; to: string };
description?: { from: string | null; to: string | null };
};
controls: EntityDiffCounts<DiffControl>;
requirements: EntityDiffCounts<DiffRequirement>;
policies: EntityDiffCounts<DiffPolicy>;
Expand Down
Loading