diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts index 053e9fa257..fa378b3115 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-diff.spec.ts @@ -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 diff --git a/apps/api/src/frameworks/framework-versioning/framework-diff.ts b/apps/api/src/frameworks/framework-versioning/framework-diff.ts index 0d8113789f..e947160862 100644 --- a/apps/api/src/frameworks/framework-versioning/framework-diff.ts +++ b/apps/api/src/frameworks/framework-versioning/framework-diff.ts @@ -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; requirements: EntityDiff; policies: EntityDiff; @@ -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), @@ -168,6 +181,23 @@ function edgesFromControls( 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); } diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.test.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.test.tsx new file mode 100644 index 0000000000..5d17007c55 --- /dev/null +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.test.tsx @@ -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); + }); +}); diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx index c6c1c10d48..500be7aa28 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/components/VersionDiffView.tsx @@ -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 || @@ -58,6 +59,7 @@ export function hasAnyChanges(diff: DraftDiff['diff']): boolean { export function VersionDiffView({ diff, linkChanges }: VersionDiffViewProps) { return ( <> + +

+ Framework +

+
+ {framework.name && ( + + + Name:{' '} + {framework.name.from}{' '} + → {framework.name.to} + + + )} + {framework.description && ( + + Description updated + + )} +
+ + ); +} + interface DiffDetailSectionProps { title: string; added: T[]; diff --git a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts index 909d9b7994..6e87f74a54 100644 --- a/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts +++ b/apps/framework-editor/app/(pages)/frameworks/[frameworkId]/versions/hooks/useFrameworkDraftDiff.ts @@ -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; requirements: EntityDiffCounts; policies: EntityDiffCounts;