From 7f73e2f5507514037ddf01b00564e1826aaec679 Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Mon, 19 Oct 2026 12:40:58 -0400 Subject: [PATCH] Save each framework's own CFDefinitions, matched by identifier (CGE-184) The editor built CFDefinitions on save from the tenant-wide catalogue, matching definitions by identifier or by title. When two frameworks had a definition with the same title, saving either one pulled in the other's. Bad data in one framework could then make another framework's saves fail validation. - Match definitions only by URI identifier. Items that name a type or subject by text alone, with no URI, no longer pick up definitions with a matching title. - Keep each framework's own definitions from its CFPackage and put them ahead of the tenant catalogue, for both the save and the pickers. - Carry CFDefinitions-level extensions through on save. The server keeps the stored ones when a save leaves them out. --- apps/editor/src/app/App.tsx | 83 +++++--- .../mappers/case/frameworkDefinitions.test.ts | 183 ++++++++++++++++++ .../mappers/case/frameworkDefinitions.ts | 43 ++++ .../framework/mappers/case/toCasePackage.ts | 72 +++---- apps/editor/src/ui/editor/EditorCanvas.tsx | 11 +- .../case/endpoints/CreateFramework.ts | 9 +- .../CreateFramework.preserveStored.test.ts | 81 +++++++- 7 files changed, 406 insertions(+), 76 deletions(-) create mode 100644 apps/editor/src/application/framework/mappers/case/frameworkDefinitions.test.ts create mode 100644 apps/editor/src/application/framework/mappers/case/frameworkDefinitions.ts diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index 6238445..68e3179 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -14,7 +14,8 @@ import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFrom import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' import type { LayoutState } from '@/ui/editor/reactflow/mapping' import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot' -import type { CFAssociationGrouping, CFItemType, CFLicense, CFSubject, CFConcept } from '@/domain/case/types' +import type { CFAssociationGrouping, CFItemType, CFLicense, CFSubject, CFConcept, CFDefinition } from '@/domain/case/types' +import { extractCfDefinitions, preferFrameworkDefinitions } from '@/application/framework/mappers/case/frameworkDefinitions' import LoginScreen from '@/ui/auth/LoginScreen' import { detectTopology } from '@/ui/editor/layout/detectTopology' import { applyInitialLayout } from '@/ui/editor/layout/applyInitialLayout' @@ -26,29 +27,6 @@ function getFrameworkIdFromHash(): string | null { return match ? decodeURIComponent(match[1]) : null } -/** Extract CFDefinitions from a raw CFPackage response and merge into tenant state */ -function extractCfDefinitions(pkg: unknown): { - CFItemTypes?: CFItemType[] - CFSubjects?: CFSubject[] - CFConcepts?: CFConcept[] - CFLicenses?: CFLicense[] - CFAssociationGroupings?: CFAssociationGrouping[] -} { - const p = pkg as Record | null - if (!p) return {} - // Handle both wrapped { CFPackage: { ... } } and unwrapped formats - const inner = (p.CFPackage ?? p) as Record - const defs = inner.CFDefinitions as Record | undefined - if (!defs) return {} - return { - CFItemTypes: Array.isArray(defs.CFItemTypes) ? (defs.CFItemTypes as CFItemType[]) : undefined, - CFSubjects: Array.isArray(defs.CFSubjects) ? (defs.CFSubjects as CFSubject[]) : undefined, - CFConcepts: Array.isArray(defs.CFConcepts) ? (defs.CFConcepts as CFConcept[]) : undefined, - CFLicenses: Array.isArray(defs.CFLicenses) ? (defs.CFLicenses as CFLicense[]) : undefined, - CFAssociationGroupings: Array.isArray(defs.CFAssociationGroupings) ? (defs.CFAssociationGroupings as CFAssociationGrouping[]) : undefined, - } -} - export default function App() { return ( @@ -74,7 +52,12 @@ function AppInner() { // Store per-framework edge type from CASE extensions (keyed by framework ID) const [frameworkEdgeTypes, setFrameworkEdgeTypes] = useState>({}) - // Tenant-wide definitions catalogue (loaded from management endpoint) + // Each framework's own CFDefinitions as returned by its CFPackage (keyed by framework ID). + // These take precedence over the tenant catalogue when the framework is saved. + const [frameworkCfDefinitions, setFrameworkCfDefinitions] = useState>({}) + + // Tenant-wide definitions catalogue (loaded from management endpoint). Feeds the + // pickers; a framework's save only uses it for definitions picked from it. const [tenantCfItemTypes, setTenantCfItemTypes] = useState([]) const [tenantCfSubjects, setTenantCfSubjects] = useState([]) const [tenantCfConcepts, setTenantCfConcepts] = useState([]) @@ -86,7 +69,7 @@ function AppInner() { /** Merge CFDefinitions from a loaded CFPackage into the tenant state (additive, no overwrites) */ const mergeCfDefinitions = useCallback((pkg: unknown) => { - const defs = extractCfDefinitions(pkg) + const defs = extractCfDefinitions(pkg) ?? {} if (defs.CFItemTypes?.length) { setTenantCfItemTypes((prev) => { const ids = new Set(prev.map((d) => d.identifier)) @@ -274,6 +257,19 @@ function AppInner() { return frameworks.find((f) => f.id === activeFrameworkId) ?? null }, [frameworks, activeFrameworkId]) + // Definitions offered to the editor: the active framework's own, ahead of the tenant catalogue. + const activeCfDefinitions = activeFrameworkId ? frameworkCfDefinitions[activeFrameworkId] : undefined + const editorDefinitions = useMemo( + () => preferFrameworkDefinitions(activeCfDefinitions, { + cfItemTypes: tenantCfItemTypes, + cfSubjects: tenantCfSubjects, + cfConcepts: tenantCfConcepts, + cfLicenses: tenantCfLicenses, + cfAssociationGroupings: tenantCfAssociationGroupings, + }), + [activeCfDefinitions, tenantCfItemTypes, tenantCfSubjects, tenantCfConcepts, tenantCfLicenses, tenantCfAssociationGroupings], + ) + // Summaries of server frameworks passed to the crosswalk target selector (excludes alignment frameworks) const serverFrameworkSummaries = useMemo( () => serverCfDocuments @@ -394,6 +390,13 @@ function AppInner() { fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions } } + // Store the framework's own definitions + const ownDefinitions = extractCfDefinitions(pkg) + setFrameworkCfDefinitions((prev) => { + const { [fw.id]: _replaced, ...rest } = prev + return ownDefinitions ? { ...rest, [fw.id]: ownDefinitions } : rest + }) + // Store the extracted layout if (layout) { setFrameworkLayouts((prev) => ({ ...prev, [fw.id]: layout })) @@ -574,7 +577,22 @@ function AppInner() { const { [oldId]: _dropped, ...rest } = prev return rest }) + setFrameworkCfDefinitions((prev) => { + const { [oldId]: _dropped, ...rest } = prev + return rest + }) } else if (activeFrameworkId) { + // The saved definitions are now this framework's own, including any + // minted in this session — record them so they survive leaving and + // re-entering the editor without a re-fetch. + const savedDefinitions = extractCfDefinitions(openCasePackage) + const savedId = activeFrameworkId + setFrameworkCfDefinitions((prev) => { + const { [savedId]: _replaced, ...rest } = prev + return savedDefinitions ? { ...rest, [savedId]: savedDefinitions } : rest + }) + mergeCfDefinitions(openCasePackage) + // Refresh the local cache with the just-saved Framework (items, associations, // metadata) so a hard refresh reflects the server state instead of the // pre-save snapshot. Without this, edits made and saved in this session @@ -595,7 +613,7 @@ function AppInner() { console.log('[App] Saved successfully') }, - [api, tenantId, caseApiVersion, activeFrameworkId, openRemoteFramework], + [api, tenantId, caseApiVersion, activeFrameworkId, openRemoteFramework, mergeCfDefinitions], ) const handleSaveAlignments = useCallback( @@ -738,11 +756,11 @@ function AppInner() { caseVersion={activeCaseVersion} skipAutoLayout={Boolean(frameworkLayouts[activeFramework.id]) || Boolean(autoEdgeType)} initialEdgeType={frameworkEdgeTypes[activeFramework.id] ?? autoEdgeType} - initialCfItemTypes={tenantCfItemTypes} - initialCfSubjects={tenantCfSubjects} - initialCfConcepts={tenantCfConcepts} - initialCfLicenses={tenantCfLicenses} - initialCfAssociationGroupings={tenantCfAssociationGroupings} + initialCfItemTypes={editorDefinitions.cfItemTypes} + initialCfSubjects={editorDefinitions.cfSubjects} + initialCfConcepts={editorDefinitions.cfConcepts} + initialCfLicenses={editorDefinitions.cfLicenses} + initialCfAssociationGroupings={editorDefinitions.cfAssociationGroupings} > navigateHome()} @@ -756,6 +774,7 @@ function AppInner() { onLoadAlignmentsForTarget={tenantId ? handleLoadAlignmentsForTarget : undefined} onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined} mirrorStatus={activeFramework.mirrorStatus} + cfDefinitionsExtensions={activeCfDefinitions?.extensions} /> ) diff --git a/apps/editor/src/application/framework/mappers/case/frameworkDefinitions.test.ts b/apps/editor/src/application/framework/mappers/case/frameworkDefinitions.test.ts new file mode 100644 index 0000000..3214e75 --- /dev/null +++ b/apps/editor/src/application/framework/mappers/case/frameworkDefinitions.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest' +import { frameworkToCfPackage } from './toCasePackage' +import { extractCfDefinitions, preferFrameworkDefinitions, type DefinitionLists } from './frameworkDefinitions' +import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader' +import { toReactFlowGraph } from '@/ui/editor/reactflow/mapping/toReactFlow' +import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' +import type { CFItemType, CFPackage, CFSubject } from '@/domain/case/types' + +const DOC_ID = 'sc-doc' + +const scStandard: CFItemType = { + identifier: 'sc-standard', + uri: '/ims/case/v1p1/CFItemTypes/sc-standard', + title: 'Standard', + description: 'SC standard', + hierarchyCode: '1', + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + extensions: { scTypeCode: 'STD' }, +} + +// Another framework's definitions with the same titles, as found in the tenant +// catalogue — including invalid data (integer hierarchyCode, as in the WIDA package). +const widaStandard = { + identifier: 'wida-standard', + uri: '/ims/case/v1p1/CFItemTypes/wida-standard', + title: 'Standard', + description: 'WIDA standard', + hierarchyCode: 1, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', +} as unknown as CFItemType +const widaComponent = { ...widaStandard, identifier: 'wida-component', uri: '/ims/case/v1p1/CFItemTypes/wida-component', title: 'Component' } as CFItemType +const widaMath: CFSubject = { identifier: 'wida-math', uri: '/ims/case/v1p1/CFSubjects/wida-math', title: 'Math' } + +function emptyTenant(): DefinitionLists { + return { cfItemTypes: [], cfSubjects: [], cfConcepts: [], cfLicenses: [], cfAssociationGroupings: [] } +} + +function tenantWithWida(): DefinitionLists { + // The tenant copy of sc-standard lost its extensions — what the old + // first-copy-wins catalogue could hand back for a shared identifier. + const { extensions: _dropped, ...scStandardWithoutExtensions } = scStandard + return { + ...emptyTenant(), + cfItemTypes: [widaStandard, widaComponent, scStandardWithoutExtensions], + cfSubjects: [widaMath], + } +} + +// SC: one item links to its own "Standard" type by URI; another names "Component" +// and a subject by string only (spec-valid, but not a link to any definition). +function makeScPackage(): CFPackage { + return { + CFDocument: { + identifier: DOC_ID, + uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, + title: 'SC Standards', + creator: 'SC', + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + caseVersion: '1.1', + }, + CFItems: [ + { + identifier: 'item-linked', + uri: '/ims/case/v1p1/CFItems/item-linked', + fullStatement: 'Linked', + CFItemType: 'Standard', + CFItemTypeURI: { title: 'Standard', identifier: scStandard.identifier, uri: scStandard.uri }, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + { + identifier: 'item-title-only', + uri: '/ims/case/v1p1/CFItems/item-title-only', + fullStatement: 'Title only', + CFItemType: 'Component', + subject: ['Math'], + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + ], + CFAssociations: [ + { + identifier: 'assoc-1', + uri: '/ims/case/v1p1/CFAssociations/assoc-1', + associationType: 'isChildOf', + originNodeURI: { identifier: 'item-linked', uri: '/ims/case/v1p1/CFItems/item-linked', title: 'Linked' }, + destinationNodeURI: { identifier: DOC_ID, uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, title: 'SC Standards' }, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + { + identifier: 'assoc-2', + uri: '/ims/case/v1p1/CFAssociations/assoc-2', + associationType: 'isChildOf', + originNodeURI: { identifier: 'item-title-only', uri: '/ims/case/v1p1/CFItems/item-title-only', title: 'Title only' }, + destinationNodeURI: { identifier: 'item-linked', uri: '/ims/case/v1p1/CFItems/item-linked', title: 'Linked' }, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + ], + CFDefinitions: { + CFItemTypes: [scStandard], + extensions: { definitionsSource: 'sc-sync' }, + }, + } as unknown as CFPackage +} + +// Open the package in the editor and save it again, the way App + EditorCanvas do. +function openAndSave(pkg: CFPackage, tenant: DefinitionLists) { + const own = extractCfDefinitions(pkg) + const loaded = loadFrameworkFromCfPackage(pkg) + if (!loaded) throw new Error('failed to load framework') + const { framework, layout } = fromEditorGraph({ graph: toReactFlowGraph({ framework: loaded }) }) + return frameworkToCfPackage({ + framework, + layout, + caseVersion: '1.1', + ...preferFrameworkDefinitions(own, tenant), + definitionsExtensions: own?.extensions, + }) +} + +describe('CFDefinitions on save', () => { + it("does not pull in another framework's definitions that share a title", () => { + const saved = openAndSave(makeScPackage(), tenantWithWida()) + + expect(saved.CFDefinitions?.CFItemTypes?.map((t) => t.identifier)).toEqual(['sc-standard']) + expect(saved.CFDefinitions?.CFSubjects).toBeUndefined() + }) + + it('keeps title-only types and subjects as plain strings', () => { + const saved = openAndSave(makeScPackage(), tenantWithWida()) + const item = saved.CFItems?.find((i) => i.identifier === 'item-title-only') + + expect(item?.CFItemType).toBe('Component') + expect(item?.CFItemTypeURI).toBeUndefined() + expect(item?.subject).toEqual(['Math']) + expect(item?.subjectURI).toBeUndefined() + }) + + it("uses the framework's own copy of a definition over the tenant copy, keeping its extensions", () => { + const saved = openAndSave(makeScPackage(), tenantWithWida()) + + expect(saved.CFDefinitions?.CFItemTypes).toEqual([scStandard]) + }) + + it('carries the CFDefinitions-level extensions through unchanged', () => { + const saved = openAndSave(makeScPackage(), tenantWithWida()) + + expect(saved.CFDefinitions?.extensions).toEqual({ definitionsSource: 'sc-sync' }) + }) + + it('keeps CFDefinitions-level extensions even when no definitions are referenced', () => { + const pkg = makeScPackage() + pkg.CFItems = pkg.CFItems?.map(({ CFItemTypeURI: _unlinked, ...item }) => item) + + const saved = openAndSave(pkg, tenantWithWida()) + + expect(saved.CFDefinitions).toEqual({ extensions: { definitionsSource: 'sc-sync' } }) + }) + + it('still includes a tenant definition the framework links to but does not own', () => { + const pkg = makeScPackage() + pkg.CFItems = pkg.CFItems?.map((item) => item.identifier === 'item-title-only' + ? { ...item, CFItemTypeURI: { title: 'Component', identifier: widaComponent.identifier, uri: widaComponent.uri } } + : item) + + const saved = openAndSave(pkg, tenantWithWida()) + + expect(saved.CFDefinitions?.CFItemTypes?.map((t) => t.identifier)).toEqual(['sc-standard', 'wida-component']) + }) +}) + +describe('preferFrameworkDefinitions', () => { + it("puts the framework's own definitions first so a title lookup finds them before the tenant's", () => { + const lists = preferFrameworkDefinitions({ CFItemTypes: [scStandard] }, tenantWithWida()) + + expect(lists.cfItemTypes.find((t) => t.title === 'Standard')).toBe(scStandard) + expect(lists.cfItemTypes.map((t) => t.identifier)).toEqual(['sc-standard', 'wida-standard', 'wida-component']) + }) + + it('returns the tenant lists unchanged when the framework has no definitions', () => { + const tenant = tenantWithWida() + + expect(preferFrameworkDefinitions(undefined, tenant)).toEqual(tenant) + }) +}) diff --git a/apps/editor/src/application/framework/mappers/case/frameworkDefinitions.ts b/apps/editor/src/application/framework/mappers/case/frameworkDefinitions.ts new file mode 100644 index 0000000..9733513 --- /dev/null +++ b/apps/editor/src/application/framework/mappers/case/frameworkDefinitions.ts @@ -0,0 +1,43 @@ +import type { CFAssociationGrouping, CFConcept, CFDefinition, CFItemType, CFLicense, CFSubject } from '@/domain/case/types' + +export type DefinitionLists = { + cfItemTypes: CFItemType[] + cfSubjects: CFSubject[] + cfConcepts: CFConcept[] + cfLicenses: CFLicense[] + cfAssociationGroupings: CFAssociationGrouping[] +} + +/** Pull CFDefinitions out of a raw CFPackage response, wrapped ({ CFPackage: … }) or not. */ +export function extractCfDefinitions(pkg: unknown): CFDefinition | undefined { + const p = pkg as Record | null + if (!p) return undefined + const inner = (p.CFPackage ?? p) as Record + const defs = inner.CFDefinitions + return defs && typeof defs === 'object' ? (defs as CFDefinition) : undefined +} + +function ownFirst(own: T[] | undefined, tenant: T[]): T[] { + if (!own?.length) return tenant + const ownIds = new Set(own.map((d) => d.identifier)) + return [...own, ...tenant.filter((d) => !ownIds.has(d.identifier))] +} + +/** + * Combine a framework's own definitions with the tenant-wide catalogue, own + * copies first and replacing any tenant copy with the same identifier. + * + * The save path looks definitions up by identifier and the pickers take the + * first title match, so both see the framework's own copy (with its own + * extensions and field values) ahead of one that another framework happens to + * share the identifier or title with. + */ +export function preferFrameworkDefinitions(own: CFDefinition | undefined, tenant: DefinitionLists): DefinitionLists { + return { + cfItemTypes: ownFirst(own?.CFItemTypes, tenant.cfItemTypes), + cfSubjects: ownFirst(own?.CFSubjects, tenant.cfSubjects), + cfConcepts: ownFirst(own?.CFConcepts, tenant.cfConcepts), + cfLicenses: ownFirst(own?.CFLicenses, tenant.cfLicenses), + cfAssociationGroupings: ownFirst(own?.CFAssociationGroupings, tenant.cfAssociationGroupings), + } +} diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index 8783e8c..d66497e 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -360,10 +360,12 @@ export function frameworkToCfPackage(params: { cfAssociationGroupings?: CFAssociationGrouping[] /** CFLicense definitions to include in CFDefinitions (from editor state) */ cfLicenses?: CFLicense[] + /** Extensions on the framework's own CFDefinitions object, carried through unchanged */ + definitionsExtensions?: CaseExtensions /** Remote framework editor data from canvas */ remoteEditorData?: { linkedFrameworks: unknown[]; remoteItemLinks: unknown[] } }): CFPackage { - const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses, remoteEditorData } = params + const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses, definitionsExtensions, remoteEditorData } = params const fwId = String(framework.id) // Build CFDocument @@ -387,50 +389,42 @@ export function frameworkToCfPackage(params: { associationToCfAssociation(framework, assocId) ) - // Build CFDefinitions — only include definitions actually referenced by items. - // This mirrors the backend GetCFPackage filtering for spec compliance. + // Build CFDefinitions — only include definitions an item, the document or an + // association links to by URI identifier, matching the backend GetCFPackage + // filtering. A CFItemType / subject string on its own is just a label in CASE: + // it doesn't refer to a definition, so titles are never used to pick one — + // doing so pulled in same-titled definitions belonging to other frameworks. + // Callers pass lists with the framework's own definitions first (see + // preferFrameworkDefinitions), so the first copy of an identifier wins. // Ensure every definition has all fields required by the CASE v1.1 schema. let cfDefinitions: CFDefinition | undefined const now = nowIso() - // Collect referenced identifiers and titles from the built CFItems + // Collect referenced identifiers from the built CFItems const refItemTypeIds = new Set() - const refItemTypeTitles = new Set() const refSubjectIds = new Set() - const refSubjectTitles = new Set() const refConceptIds = new Set() for (const item of items) { - // CFItemType: match by CFItemTypeURI.identifier or by CFItemType title string if (item.CFItemTypeURI?.identifier) refItemTypeIds.add(item.CFItemTypeURI.identifier) - if (item.CFItemType) refItemTypeTitles.add(item.CFItemType) - - // CFSubject: match by subjectURI[].identifier or by subject[] title string if (Array.isArray(item.subjectURI)) { for (const s of item.subjectURI) { if (s.identifier) refSubjectIds.add(s.identifier) } } - if (Array.isArray(item.subject)) { - for (const s of item.subject) { - if (s) refSubjectTitles.add(s) - } - } - - // CFConcept: match by conceptKeywordsURI.identifier if (item.conceptKeywordsURI?.identifier) refConceptIds.add(item.conceptKeywordsURI.identifier) } + // CFDocument.subjectURI also links to CFSubjects + if (Array.isArray(document.subjectURI)) { + for (const s of document.subjectURI) { + if (s.identifier) refSubjectIds.add(s.identifier) + } + } // Filter each category to only referenced definitions - const filteredItemTypes = cfItemTypes?.filter( - (t) => refItemTypeIds.has(t.identifier) || refItemTypeTitles.has(t.title ?? ''), - ) ?? [] - const filteredSubjects = cfSubjects?.filter( - (s) => refSubjectIds.has(s.identifier) || refSubjectTitles.has(s.title ?? ''), - ) ?? [] - const filteredConcepts = cfConcepts?.filter( - (c) => refConceptIds.has(c.identifier), - ) ?? [] + const filteredItemTypes = pickReferenced(cfItemTypes, refItemTypeIds) + const filteredSubjects = pickReferenced(cfSubjects, refSubjectIds) + const filteredConcepts = pickReferenced(cfConcepts, refConceptIds) // Collect referenced license identifiers from document and items const refLicenseIds = new Set() @@ -438,20 +432,17 @@ export function frameworkToCfPackage(params: { for (const item of items) { if (item.licenseURI?.identifier) refLicenseIds.add(item.licenseURI.identifier) } - const filteredLicenses = cfLicenses?.filter( - (l) => refLicenseIds.has(l.identifier), - ) ?? [] + const filteredLicenses = pickReferenced(cfLicenses, refLicenseIds) // Collect referenced grouping identifiers from built CFAssociations const refGroupingIds = new Set() for (const assoc of associations) { if (assoc.CFAssociationGroupingURI?.identifier) refGroupingIds.add(assoc.CFAssociationGroupingURI.identifier) } - const filteredGroupings = cfAssociationGroupings?.filter( - (g) => refGroupingIds.has(g.identifier), - ) ?? [] + const filteredGroupings = pickReferenced(cfAssociationGroupings, refGroupingIds) - if (filteredItemTypes.length > 0 || filteredSubjects.length > 0 || filteredConcepts.length > 0 || filteredGroupings.length > 0 || filteredLicenses.length > 0) { + const hasDefinitionsExtensions = definitionsExtensions !== undefined && Object.keys(definitionsExtensions).length > 0 + if (filteredItemTypes.length > 0 || filteredSubjects.length > 0 || filteredConcepts.length > 0 || filteredGroupings.length > 0 || filteredLicenses.length > 0 || hasDefinitionsExtensions) { cfDefinitions = {} if (filteredItemTypes.length > 0) { @@ -497,6 +488,10 @@ export function frameworkToCfPackage(params: { lastChangeDateTime: g.lastChangeDateTime || now, })) } + + if (hasDefinitionsExtensions) { + cfDefinitions.extensions = definitionsExtensions + } } const cfPackage: CFPackage = { @@ -509,6 +504,17 @@ export function frameworkToCfPackage(params: { return cfPackage } +/** The definitions whose identifier is in `ids`, keeping the first copy of each identifier. */ +function pickReferenced(list: T[] | undefined, ids: Set): T[] { + if (!list || ids.size === 0) return [] + const seen = new Set() + return list.filter((d) => { + if (!ids.has(d.identifier) || seen.has(d.identifier)) return false + seen.add(d.identifier) + return true + }) +} + /** * Link URI reference format (LinkGenURI) per CASE v1p1 spec. */ diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index 94683be..2edd93d 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -32,7 +32,7 @@ import { import { useEditor } from '@/ui/editor/state/EditorContext' import { isFrameworkNode, getNodeSize } from '@/ui/editor/state/helpers/nodeGeometry' import type { CaseEditorNodeType, CaseEditorEdge } from '@/ui/editor/reactflow/types' -import type { CFAssociationGrouping, CFDocument, CFItem, CFPackage } from '@/domain/case/types' +import type { CFAssociationGrouping, CFDocument, CFItem, CFPackage, CaseExtensions } from '@/domain/case/types' import type { HomeFramework } from '@/ui/home/frameworkStore' import { useAuth } from '@/app/providers/AuthProvider' import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' @@ -214,6 +214,8 @@ type EditorCanvasProps = { onDiscoverAlignedTargets?: (sourceId: string) => Promise> /** Mirror/fork status of the open framework, if it was ever imported */ mirrorStatus?: MirrorStatus + /** Extensions on the open framework's own CFDefinitions object — the editor can't author them, so they're carried through on save */ + cfDefinitionsExtensions?: CaseExtensions } function RemoteLinkInternalsSync({ @@ -245,7 +247,7 @@ function RemoteLinkInternalsSync({ return null } -export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -399,8 +401,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor // ── Refs for save/export (keeps callbacks stable, avoids re-renders) ── const graphRef = useRef({ nodes, edges: editorEdges, remoteLinks }) graphRef.current = { nodes, edges: editorEdges, remoteLinks } - const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings }) - saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings } + const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions }) + saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions } // Open the CFPackage viewer. Every framework open in the editor has been saved to the // server, so the server copy (absolute URIs, no OpenCASE extensions) is the export. @@ -455,6 +457,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, + definitionsExtensions: ctx.cfDefinitionsExtensions, remoteEditorData, }) const openCasePackage = toOpenCaseFormat(cfPackage) diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index 150cad1..ce71448 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -204,6 +204,11 @@ export class CreateFramework { cfRubricsPayload = storedPkg.rubrics.map(r => r.toJSON()) } const packageExtensions = payload.extensions !== undefined ? payload.extensions : storedPkg?.extensions + // Same for the extensions on the CFDefinitions object itself. + const storedDefinitionsExtensions = storedPkg?.definitions?.extensions + const definitionsPayload = payload.CFDefinitions?.extensions === undefined && storedDefinitionsExtensions + ? { ...(payload.CFDefinitions ?? {}), extensions: storedDefinitionsExtensions } + : payload.CFDefinitions if (resolvedStorageKey) { const existingMeta = this.store.getDocumentMetadata(tenantId, caseVersion, resolvedStorageKey) @@ -225,7 +230,7 @@ export class CreateFramework { CFItems: candidate.items.map(i => i.toJSON()), CFAssociations: candidate.associations.map(a => a.toJSON()), CFRubrics: candidate.rubrics.map(r => r.toJSON()), - CFDefinitions: payload.CFDefinitions ?? null + CFDefinitions: definitionsPayload ?? null } const dataChanged = stableStringifyBundle(stripNonDataFields(existingComparable)) !== stableStringifyBundle(stripNonDataFields(newComparable)) @@ -295,7 +300,7 @@ export class CreateFramework { const rubrics = cfRubricsPayload.map((r: any) => CFRubric.fromRaw(tenantId, caseVersion, r, preserveUris) ) - const definitions = payload.CFDefinitions ?? null + const definitions = definitionsPayload ?? null const pkg = new CFPackage({ document, items, associations, rubrics, definitions, extensions: packageExtensions }) diff --git a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts index b57d232..edd97b3 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/CreateFramework.preserveStored.test.ts @@ -6,7 +6,8 @@ import { CFItem } from '../../../../domain/case/entities/CFItem' import { CFRubric } from '../../../../domain/case/entities/CFRubric' import { CFPackage } from '../../../../domain/case/entities/CFPackage' -// The editor can't author rubrics or CFPackage-level extensions and never sends them +// The editor can't author rubrics, CFPackage-level extensions or CFDefinitions-level +// extensions and never sends the first two (the last only as it loaded them) // on save. Omitting them must keep what's stored rather than wiping it. describe('CreateFramework — keeps stored rubrics and package extensions when omitted', () => { let mockRepository: jest.Mocked @@ -17,8 +18,24 @@ describe('CreateFramework — keeps stored rubrics and package extensions when o const caseVersion = '1.1' const docId = 'doc-123' const packageExtensions = { syncedFrom: 'smartschool', syncRunId: 'run-42' } + const storedItemType = { + identifier: 'type-1', + uri: '/ims/case/v1p1/CFItemTypes/type-1', + title: 'Standard', + description: 'Standard', + hierarchyCode: '1', + lastChangeDateTime: '2024-01-01T00:00:00Z', + extensions: { providerTypeCode: 'STD' } + } + const definitionsExtensions = { definitionsSource: 'provider-sync' } + const itemTypeLink = { + CFItemType: 'Standard', + CFItemTypeURI: { title: 'Standard', identifier: storedItemType.identifier, uri: storedItemType.uri } + } + + type StoredOpts = { pristineMirror?: boolean; withDefinitions?: boolean } - const buildStoredPkg = (opts: { pristineMirror?: boolean } = {}) => { + const buildStoredPkg = (opts: StoredOpts = {}) => { const preserveUris = { preserveUris: opts.pristineMirror === true } const document = CFDocument.fromRaw(tenantId, caseVersion, { sourcedId: docId, @@ -34,7 +51,8 @@ describe('CreateFramework — keeps stored rubrics and package extensions when o sourcedId: 'item-1', uri: '/ims/case/v1p1/CFItems/item-1', fullStatement: 'Original statement', - lastChangeDateTime: '2024-01-01T00:00:00Z' + lastChangeDateTime: '2024-01-01T00:00:00Z', + ...(opts.withDefinitions ? itemTypeLink : {}) }, docId, document.toJSON().uri, preserveUris) const rubric = CFRubric.fromRaw(tenantId, caseVersion, { identifier: 'rubric-1', @@ -43,7 +61,8 @@ describe('CreateFramework — keeps stored rubrics and package extensions when o lastChangeDateTime: '2024-01-01T00:00:00Z', extensions: { rubricSourceId: 'rs-1' } }, preserveUris) - return new CFPackage({ document, items: [item], associations: [], rubrics: [rubric], definitions: null, extensions: packageExtensions }) + const definitions = opts.withDefinitions ? { CFItemTypes: [storedItemType], extensions: definitionsExtensions } : null + return new CFPackage({ document, items: [item], associations: [], rubrics: [rubric], definitions, extensions: packageExtensions }) } // Shaped like an editor save: no CFRubrics and no package-level extensions. @@ -64,7 +83,7 @@ describe('CreateFramework — keeps stored rubrics and package extensions when o CFAssociations: [] }) - const existingDocument = (opts: { pristineMirror?: boolean } = {}) => { + const existingDocument = (opts: StoredOpts = {}) => { const meta: DocumentMetadata = { sourcedId: docId, title: 'Framework', @@ -132,4 +151,56 @@ describe('CreateFramework — keeps stored rubrics and package extensions when o expect((result as any).forked).toBeFalsy() }) + + describe('CFDefinitions-level extensions', () => { + const withItemTypeLink = (payload: ReturnType) => ({ + ...payload, + CFItems: payload.CFItems.map(i => ({ ...i, ...itemTypeLink })) + }) + + it('keeps the stored CFDefinitions extensions when the save sends definitions without them', async () => { + existingDocument({ withDefinitions: true }) + + await createFramework.execute({ + tenantId, + caseVersion, + payload: { ...withItemTypeLink(editorPayload('Edited statement')), CFDefinitions: { CFItemTypes: [storedItemType] } } + }) + + expect(savedPkg().definitions).toEqual({ CFItemTypes: [storedItemType], extensions: definitionsExtensions }) + }) + + it('keeps the stored CFDefinitions extensions when the save sends no CFDefinitions at all', async () => { + existingDocument({ withDefinitions: true }) + + await createFramework.execute({ tenantId, caseVersion, payload: editorPayload('Edited statement') }) + + expect(savedPkg().definitions?.extensions).toEqual(definitionsExtensions) + }) + + it('replaces the stored CFDefinitions extensions when the payload provides them explicitly', async () => { + existingDocument({ withDefinitions: true }) + + await createFramework.execute({ + tenantId, + caseVersion, + payload: { ...editorPayload('Edited statement'), CFDefinitions: { extensions: { replaced: true } } } + }) + + expect(savedPkg().definitions).toEqual({ extensions: { replaced: true } }) + }) + + it("re-saving a pristine mirror's own definitions unchanged neither forks nor writes a new version", async () => { + existingDocument({ pristineMirror: true, withDefinitions: true }) + + const result = await createFramework.execute({ + tenantId, + caseVersion, + payload: { ...withItemTypeLink(editorPayload()), CFDefinitions: { CFItemTypes: [storedItemType], extensions: definitionsExtensions } } + }) + + expect((result as any).forked).toBeFalsy() + expect(mockRepository.saveNewVersion).not.toHaveBeenCalled() + }) + }) })