diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index 68e3179..b9cc7ab 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -775,6 +775,7 @@ function AppInner() { onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined} mirrorStatus={activeFramework.mirrorStatus} cfDefinitionsExtensions={activeCfDefinitions?.extensions} + loadedFramework={activeFramework.framework} /> ) diff --git a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts index f94d53e..f0fabeb 100644 --- a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts +++ b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts @@ -100,6 +100,12 @@ export type CaseAssociationSnapshot = { destinationIdentifier?: string originUri?: string destinationUri?: string + /** Title supplied on the origin/destination node link */ + originTitle?: string + destinationTitle?: string + /** v1p1: targetType of the origin/destination node (absent or 'CASE' for CASE targets) */ + originTargetType?: string + destinationTargetType?: string /** * v1p1: Sequence number for ordering associations. * Lower numbers appear first in ordered contexts. diff --git a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts index f7d651a..1dd7b97 100644 --- a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts +++ b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts @@ -105,6 +105,10 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): caseUri: a.uri, originUri: a.originUri, destinationUri: a.destinationUri, + originTitle: a.originTitle, + destinationTitle: a.destinationTitle, + originTargetType: a.originTargetType, + destinationTargetType: a.destinationTargetType, sequenceNumber: a.sequenceNumber, CFAssociationGroupingIdentifier: a.CFAssociationGroupingIdentifier, CFAssociationGroupingTitle: a.CFAssociationGroupingTitle, diff --git a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts index 064de9c..7e5eabc 100644 --- a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts @@ -107,7 +107,7 @@ function extractIdFromUri(uri?: string): string | undefined { * v1p0 format: "https://example.com/CFItems/123" (string) * v1p1 format: { identifier: "123", uri: "https://...", title?: "...", targetType?: "..." } */ -function extractLinkIdentifier(link: unknown): { identifier?: string; uri?: string } { +function extractLinkIdentifier(link: unknown): { identifier?: string; uri?: string; title?: string; targetType?: string } { const obj = asRecord(link) // v1p0: Plain URI string @@ -121,7 +121,12 @@ function extractLinkIdentifier(link: unknown): { identifier?: string; uri?: stri const identifier = asString(obj.identifier) ?? asString(obj.sourcedId) ?? undefined const uri = asString(obj.uri) ?? undefined - return { identifier: identifier ?? extractIdFromUri(uri), uri } + return { + identifier: identifier ?? extractIdFromUri(uri), + uri, + title: asString(obj.title) ?? undefined, + targetType: asString(obj.targetType) ?? undefined, + } } /** @@ -276,6 +281,10 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot destinationIdentifier: dest.identifier, originUri: origin.uri, destinationUri: dest.uri, + originTitle: origin.title, + destinationTitle: dest.title, + originTargetType: origin.targetType, + destinationTargetType: dest.targetType, // v1p1: sequenceNumber for ordering within associations sequenceNumber: asNumber(r.sequenceNumber), CFAssociationGroupingIdentifier: groupingLink.identifier, diff --git a/apps/editor/src/application/framework/mappers/case/snapshotToCaseDtos.ts b/apps/editor/src/application/framework/mappers/case/snapshotToCaseDtos.ts index 386d210..0cc35b1 100644 --- a/apps/editor/src/application/framework/mappers/case/snapshotToCaseDtos.ts +++ b/apps/editor/src/application/framework/mappers/case/snapshotToCaseDtos.ts @@ -51,8 +51,8 @@ export function caseSnapshotToCfPackage(snapshot: CasePackageSnapshot): CFPackag identifier: a.identifier, associationType: a.associationType ?? 'isChildOf', uri: `urn:case:association:${a.identifier}`, - originNodeURI: { uri: a.originUri ?? `urn:case:item:${a.originIdentifier ?? ''}`, identifier: a.originIdentifier ?? '' }, - destinationNodeURI: { uri: a.destinationUri ?? `urn:case:item:${a.destinationIdentifier ?? ''}`, identifier: a.destinationIdentifier ?? '' }, + originNodeURI: { uri: a.originUri ?? `urn:case:item:${a.originIdentifier ?? ''}`, identifier: a.originIdentifier ?? '', title: a.originTitle, targetType: a.originTargetType }, + destinationNodeURI: { uri: a.destinationUri ?? `urn:case:item:${a.destinationIdentifier ?? ''}`, identifier: a.destinationIdentifier ?? '', title: a.destinationTitle, targetType: a.destinationTargetType }, lastChangeDateTime: a.lastChangeDateTime ?? nowIso(), extensions: a.extensions, })), diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts new file mode 100644 index 0000000..e6b5ee2 --- /dev/null +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.targetTypeRoundTrip.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest' +import { frameworkToCfPackage, toOpenCaseFormat } from './toCasePackage' +import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader' +import { toReactFlowGraph } from '@/ui/editor/reactflow/mapping/toReactFlow' +import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' +import { withOffCanvasAssociations } from '@/domain/framework/offCanvasAssociations' +import type { CFPackage } from '@/domain/case/types' + +const DOC_ID = '11111111-1111-4111-8111-111111111111' +const ITEM_ID = '22222222-2222-4222-8222-222222222222' + +// CASE 1.1 §1.9.3: an association pointing outside CASE (GitHub #49). +const EXTERNAL_DEST = { + title: 'KO 3.1.2 De kleuters kunnen:', + identifier: 'KO|3.1.2', + uri: 'https://onderwijsdoelen.be/doelen/KO_V2_0?vakdiscipline=Wetenschap%20en%20techniek#3.1.2', + targetType: 'ext:onderwijsdoelen', +} + +// A CASE item in another framework, explicitly marked targetType CASE. +const CASE_DEST = { + title: 'Item in another framework', + identifier: '33333333-3333-4333-8333-333333333333', + uri: 'https://case.example.org/ims/case/v1p1/CFItems/33333333-3333-4333-8333-333333333333', + targetType: 'CASE', +} + +function makePackage(): CFPackage { + return { + CFDocument: { + identifier: DOC_ID, + uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, + title: 'Curriculum', + creator: 'Sync', + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + caseVersion: '1.1', + }, + CFItems: [ + { + identifier: ITEM_ID, + uri: `/ims/case/v1p1/CFItems/${ITEM_ID}`, + fullStatement: 'Item 1', + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + ], + CFAssociations: [ + { + identifier: '44444444-4444-4444-8444-444444444444', + uri: '/ims/case/v1p1/CFAssociations/44444444-4444-4444-8444-444444444444', + associationType: 'isChildOf', + originNodeURI: { identifier: ITEM_ID, uri: `/ims/case/v1p1/CFItems/${ITEM_ID}`, title: 'Item 1' }, + destinationNodeURI: { identifier: DOC_ID, uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, title: 'Curriculum' }, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + { + identifier: '55555555-5555-4555-8555-555555555555', + uri: '/ims/case/v1p1/CFAssociations/55555555-5555-4555-8555-555555555555', + associationType: 'exactMatchOf', + originNodeURI: { identifier: ITEM_ID, uri: `/ims/case/v1p1/CFItems/${ITEM_ID}`, title: 'Item 1' }, + destinationNodeURI: EXTERNAL_DEST, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + { + identifier: '66666666-6666-4666-8666-666666666666', + uri: '/ims/case/v1p1/CFAssociations/66666666-6666-4666-8666-666666666666', + associationType: 'isRelatedTo', + originNodeURI: { identifier: ITEM_ID, uri: `/ims/case/v1p1/CFItems/${ITEM_ID}`, title: 'Item 1' }, + destinationNodeURI: CASE_DEST, + lastChangeDateTime: '2026-01-01T00:00:00.000Z', + }, + ], + } as unknown as CFPackage +} + +function load(pkg: CFPackage = makePackage()) { + const framework = loadFrameworkFromCfPackage(pkg) + if (!framework) throw new Error('failed to load framework') + return framework +} + +/** Load → canvas graph → save, the same path EditorCanvas.handleSave takes. */ +function saveFromEditor(pkg?: CFPackage) { + const loaded = load(pkg) + const framework = withOffCanvasAssociations(loaded, fromEditorGraph({ graph: toReactFlowGraph({ framework: loaded }) }).framework) + return { framework, openCase: toOpenCaseFormat(frameworkToCfPackage({ framework, caseVersion: '1.1' })) } +} + +const assocById = (pkg: ReturnType, id: string) => + pkg.CFAssociations?.find((a) => a.identifier === id) + +describe('association node targetType round-trip (#49)', () => { + it('keeps a non-CASE destination verbatim: identifier, uri, title and targetType', () => { + const { openCase } = saveFromEditor() + expect(assocById(openCase, '55555555-5555-4555-8555-555555555555')?.destinationNodeURI).toEqual(EXTERNAL_DEST) + }) + + it('keeps targetType CASE and the supplied title on a node outside this framework', () => { + const { openCase } = saveFromEditor() + expect(assocById(openCase, '66666666-6666-4666-8666-666666666666')?.destinationNodeURI).toEqual(CASE_DEST) + }) + + it('does not add a targetType to nodes that had none', () => { + const { openCase } = saveFromEditor() + const assoc = assocById(openCase, '44444444-4444-4444-8444-444444444444') + expect(assoc?.originNodeURI).not.toHaveProperty('targetType') + expect(assoc?.destinationNodeURI).not.toHaveProperty('targetType') + }) + + it('is stable across save → reload → save', () => { + const { openCase: first } = saveFromEditor() + const { openCase: second } = saveFromEditor(first as unknown as CFPackage) + expect(second.CFAssociations).toEqual(first.CFAssociations) + }) +}) diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index d66497e..2efc13c 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -63,6 +63,15 @@ function ensureUuid(id: string): string { * URI format specification for CASE entities. * Uses the official CASE v1p1 REST endpoint format. */ +/** + * CASE 1.1 (§1.9.3): a node whose targetType is anything other than 'CASE' points + * outside CASE. Its identifier, uri and title belong to the external source and + * must be kept verbatim. + */ +function isNonCaseTarget(targetType: string | undefined): targetType is string { + return targetType !== undefined && targetType !== 'CASE' +} + function makeDocumentUri(uuid: string): string { return `/ims/case/v1p1/CFDocuments/${uuid}` } @@ -262,8 +271,6 @@ function associationToCfAssociation( // Get item titles for the link URIs const fromItem = framework.items.get(assoc.fromItemId) const toItem = framework.items.get(assoc.toItemId) - const fromTitle = fromItem?.statement ?? `Item ${fromId}` - const toTitle = toItem?.statement ?? `Item ${toId}` const s = (k: string): string | undefined => { const v = md[k] @@ -274,6 +281,11 @@ function associationToCfAssociation( return typeof v === 'number' ? v : undefined } + const fromTitle = fromItem?.statement ?? s('originTitle') ?? `Item ${fromId}` + const toTitle = toItem?.statement ?? s('destinationTitle') ?? `Item ${toId}` + const originTargetType = s('originTargetType') + const destinationTargetType = s('destinationTargetType') + const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined const remoteExt = (existingExtensions?.[OPENCASE_EXT_KEY] as { remoteLink?: boolean; remoteItemUri?: string; remoteItemIdentifier?: string; remoteLabel?: string; localItemUri?: string } | undefined) @@ -301,6 +313,7 @@ function associationToCfAssociation( identifier: fromId, uri: s('originUri') ?? `urn:case:item:${fromId}`, title: fromTitle, + ...(originTargetType ? { targetType: originTargetType } : {}), }, destinationNodeURI: isRemoteLink ? { @@ -312,6 +325,7 @@ function associationToCfAssociation( identifier: toId, uri: s('destinationUri') ?? `urn:case:item:${toId}`, title: toTitle, + ...(destinationTargetType ? { targetType: destinationTargetType } : {}), }, sequenceNumber: n('sequenceNumber'), CFAssociationGroupingURI: s('CFAssociationGroupingIdentifier') @@ -522,6 +536,7 @@ export type CaseLinkURI = { title: string identifier: string uri: string + targetType?: string } /** @@ -752,21 +767,25 @@ export function toOpenCaseFormat(cfPackage: CFPackage): CaseV1p1Package { // Only fall back to reconstruction for a genuinely new/placeholder value. const originUri = resolveUri(a.originNodeURI.uri, `urn:case:item:${originInternalId}`, () => resolveNodeUri(originInternalId, originId)) const destUri = resolveUri(a.destinationNodeURI.uri, `urn:case:item:${destInternalId}`, () => resolveNodeUri(destInternalId, destId)) + const toNodeLink = (link: CFAssociation['originNodeURI'], internalId: string, id: string, uri: string, defaultTitle: string): CaseLinkURI => { + // Non-CASE targets are not ours to normalize: no UUID coercion, no uri rewrite. + if (isNonCaseTarget(link.targetType)) { + return { title: link.title ?? internalId, identifier: internalId, uri: link.uri, targetType: link.targetType } + } + return { + title: link.title ?? defaultTitle, + identifier: id, + uri, + ...(link.targetType ? { targetType: link.targetType } : {}), + } + } return { identifier: assocId, uri: assocUri, associationType: a.associationType, - originNodeURI: { - title: a.originNodeURI.title ?? 'Origin', - identifier: originId, - uri: originUri, - }, - destinationNodeURI: { - title: a.destinationNodeURI.title ?? 'Destination', - identifier: destId, - uri: destUri, - }, + originNodeURI: toNodeLink(a.originNodeURI, originInternalId, originId, originUri, 'Origin'), + destinationNodeURI: toNodeLink(a.destinationNodeURI, destInternalId, destId, destUri, 'Destination'), lastChangeDateTime: a.lastChangeDateTime, sequenceNumber: a.sequenceNumber, CFAssociationGroupingURI: a.CFAssociationGroupingURI ? { diff --git a/apps/editor/src/domain/framework/model/types.ts b/apps/editor/src/domain/framework/model/types.ts index 1535594..c545af0 100644 --- a/apps/editor/src/domain/framework/model/types.ts +++ b/apps/editor/src/domain/framework/model/types.ts @@ -61,6 +61,14 @@ export type AssociationMetadata = { originUri?: string /** Canonical URI of the destination (to) item — must be preserved verbatim for cross-framework associations */ destinationUri?: string + /** Title supplied on the origin node link — used when the origin is not an item in this framework */ + originTitle?: string + /** Title supplied on the destination node link — used when the destination is not an item in this framework */ + destinationTitle?: string + /** CASE 1.1 targetType of the origin node; anything other than 'CASE' is a non-CASE target, kept verbatim */ + originTargetType?: string + /** CASE 1.1 targetType of the destination node; anything other than 'CASE' is a non-CASE target, kept verbatim */ + destinationTargetType?: string sequenceNumber?: number /** Edge handle position on the origin node — persists user-defined anchor points */ originHandle?: string diff --git a/apps/editor/src/domain/framework/offCanvasAssociations.test.ts b/apps/editor/src/domain/framework/offCanvasAssociations.test.ts new file mode 100644 index 0000000..6a8870f --- /dev/null +++ b/apps/editor/src/domain/framework/offCanvasAssociations.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest' +import { withOffCanvasAssociations } from './offCanvasAssociations' +import type { Association, Framework, Item } from '@/domain/framework/model/types' +import type { AssociationId, FrameworkId, ItemId } from '@/domain/shared/types' + +const item = (id: string): Item => ({ id: id as ItemId, statement: id }) +const assoc = (id: string, from: string, to: string, metadata?: Association['metadata']): Association => ({ + id: id as AssociationId, + fromItemId: from as ItemId, + toItemId: to as ItemId, + associationType: 'exactMatchOf', + metadata, +}) +const framework = (items: Item[], associations: Association[]): Framework => ({ + id: 'doc-1' as FrameworkId, + metadata: { title: 'Doc' }, + items: new Map(items.map((i) => [i.id, i])), + associations: new Map(associations.map((a) => [a.id, a])), + status: 'Published', +}) + +const external = assoc('ext-1', 'item-1', 'KO|3.1.2', { destinationTargetType: 'ext:onderwijsdoelen' }) +const local = assoc('local-1', 'item-1', 'item-2') +const toDocument = assoc('root-1', 'item-1', 'doc-1') +const loaded = framework([item('item-1'), item('item-2')], [external, local, toDocument]) + +describe('withOffCanvasAssociations', () => { + it('carries over an association to a node outside the framework', () => { + const saved = framework([item('item-1'), item('item-2')], [local, toDocument]) + expect(withOffCanvasAssociations(loaded, saved).associations.get(external.id)).toBe(external) + }) + + it('does not bring back canvas associations the user deleted', () => { + const saved = framework([item('item-1'), item('item-2')], []) + const result = withOffCanvasAssociations(loaded, saved) + expect(result.associations.has(local.id)).toBe(false) + expect(result.associations.has(toDocument.id)).toBe(false) + }) + + it('drops the association when its local item was deleted', () => { + const saved = framework([item('item-2')], []) + expect(withOffCanvasAssociations(loaded, saved).associations.has(external.id)).toBe(false) + }) + + it('leaves editor-authored remote links to remoteEditorData', () => { + const remote = assoc('remote-1', 'item-1', 'remote-item', { extensions: { 'ext:opencase': { remoteLink: true } } }) + const saved = framework([item('item-1')], []) + expect(withOffCanvasAssociations(framework([item('item-1')], [remote]), saved).associations.has(remote.id)).toBe(false) + }) + + it('returns the saved framework untouched when there is nothing to carry over', () => { + const saved = framework([item('item-1'), item('item-2')], [local, toDocument]) + expect(withOffCanvasAssociations(framework([item('item-1'), item('item-2')], [local]), saved)).toBe(saved) + }) +}) diff --git a/apps/editor/src/domain/framework/offCanvasAssociations.ts b/apps/editor/src/domain/framework/offCanvasAssociations.ts new file mode 100644 index 0000000..eb34160 --- /dev/null +++ b/apps/editor/src/domain/framework/offCanvasAssociations.ts @@ -0,0 +1,38 @@ +import type { Association, Framework } from '@/domain/framework/model/types' +import type { ItemId } from '@/domain/shared/types' + +/** + * The editor graph only has nodes for this framework's own items and its + * CFDocument, so an association whose origin or destination is anything else + * — a CASE 1.1 non-CASE target (`targetType: 'ext:…'`, GitHub #49) or an item + * in another framework — has no edge on the canvas and never comes back out of + * `fromEditorGraph`. Saving would silently delete it. + * + * This carries such associations over from the framework as loaded into the + * framework rebuilt from the graph, unless the local item they hang off was + * deleted in this session. Editor-authored remote links are excluded: they + * already round-trip through `remoteEditorData`. + */ +export function withOffCanvasAssociations(loaded: Framework, saved: Framework): Framework { + const isLoadedNode = (id: ItemId) => loaded.items.has(id) || (id as unknown as string) === (loaded.id as unknown as string) + const isSavedNode = (id: ItemId) => saved.items.has(id) || (id as unknown as string) === (saved.id as unknown as string) + + let associations: Map | undefined + for (const a of loaded.associations.values()) { + if (saved.associations.has(a.id)) continue + if (isRemoteLink(a)) continue + const endpoints = [a.fromItemId, a.toItemId] + // Both ends on the canvas: if it's missing from `saved`, the user deleted it. + if (endpoints.every(isLoadedNode)) continue + if (!endpoints.filter(isLoadedNode).every(isSavedNode)) continue + associations ??= new Map(saved.associations) + associations.set(a.id, a) + } + + return associations ? { ...saved, associations } : saved +} + +function isRemoteLink(a: Association): boolean { + const ext = (a.metadata?.extensions as Record | undefined)?.['ext:opencase'] + return (ext as { remoteLink?: unknown } | undefined)?.remoteLink === true +} diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index 2edd93d..31c25d3 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -39,6 +39,7 @@ import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' import type { Framework } from '@/domain/framework/model/types' import { hasFrameworkDataChanged } from '@/domain/framework/hasFrameworkDataChanged' +import { withOffCanvasAssociations } from '@/domain/framework/offCanvasAssociations' // ── Stable ReactFlow config (module scope — never recreated) ────────────── // @@ -216,6 +217,8 @@ type EditorCanvasProps = { 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 + /** The framework as loaded — its associations to nodes outside this framework have no canvas edge, so they're carried through on save */ + loadedFramework?: Framework } function RemoteLinkInternalsSync({ @@ -247,7 +250,7 @@ function RemoteLinkInternalsSync({ return null } -export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions, loadedFramework }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -353,7 +356,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor // gate below — so a single per-mount baseline is sufficient. const baselineFrameworkRef = useRef(null) if (baselineFrameworkRef.current === null) { - baselineFrameworkRef.current = fromEditorGraph({ graph: { nodes, edges: editorEdges } }).framework + const baseline = fromEditorGraph({ graph: { nodes, edges: editorEdges } }).framework + baselineFrameworkRef.current = loadedFramework ? withOffCanvasAssociations(loadedFramework, baseline) : baseline } const pendingSaveRef = useRef<{ openCasePackage: ReturnType; framework: Framework } | null>(null) @@ -401,8 +405,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, cfDefinitionsExtensions }) - saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions } + const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions, loadedFramework }) + saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings, cfDefinitionsExtensions, loadedFramework } // 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. @@ -451,7 +455,9 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor const handleSave = useCallback(async () => { const { nodes: n, edges: e, remoteLinks: rl } = graphRef.current const ctx = saveCtxRef.current - const { framework, layout, remoteEditorData } = fromEditorGraph({ graph: { nodes: n, edges: e, remoteLinks: rl } }) + const fromGraph = fromEditorGraph({ graph: { nodes: n, edges: e, remoteLinks: rl } }) + const { layout, remoteEditorData } = fromGraph + const framework = ctx.loadedFramework ? withOffCanvasAssociations(ctx.loadedFramework, fromGraph.framework) : fromGraph.framework const cfPackage = frameworkToCfPackage({ framework, layout, caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, diff --git a/apps/opencase/src/domain/case/entities/CFItem.ts b/apps/opencase/src/domain/case/entities/CFItem.ts index c8976d1..0e5853a 100644 --- a/apps/opencase/src/domain/case/entities/CFItem.ts +++ b/apps/opencase/src/domain/case/entities/CFItem.ts @@ -172,18 +172,11 @@ export class CFItem { delete result.subjectURI delete result.extensions // LinkGenURI.targetType is a CASE 1.1 addition; if present in LinkData, remove it. - if (result.CFDocumentURI && typeof result.CFDocumentURI === 'object') { - delete result.CFDocumentURI.targetType - } - if (result.CFItemTypeURI && typeof result.CFItemTypeURI === 'object') { - delete result.CFItemTypeURI.targetType - } - if (result.conceptKeywordsURI && typeof result.conceptKeywordsURI === 'object') { - delete result.conceptKeywordsURI.targetType - } - if (Array.isArray(result.subjectURI)) { - for (const s of result.subjectURI) { - if (s && typeof s === 'object') delete s.targetType + // Copy before stripping so the entity's own LinkData keeps targetType. + for (const key of ['CFDocumentURI', 'CFItemTypeURI', 'conceptKeywordsURI']) { + if (result[key] && typeof result[key] === 'object') { + const { targetType: _targetType, ...link } = result[key] + result[key] = link } } } diff --git a/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts b/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts index ca53c28..c918259 100644 --- a/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts +++ b/apps/opencase/src/domain/case/entities/__tests__/CFItem.test.ts @@ -169,6 +169,26 @@ describe('CFItem', () => { expect(json.caseVersion).toBeUndefined(); expect(json.sourcedId).toBeUndefined(); }); + + it('drops targetType for CASE 1.0 without losing it on the entity', () => { + const item = CFItem.create({ + tenantId, + caseVersion, + sourcedId: 'item-123', + uri: '/ims/case/v1p1/CFItems/item-123', + fullStatement: 'Test statement', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + CFDocumentURI: { + title: 'Document', + identifier: 'doc-123', + uri: '/ims/case/v1p1/CFDocuments/doc-123', + targetType: 'CASE' + } + }); + + expect(item.toJSON('1.0').CFDocumentURI.targetType).toBeUndefined(); + expect(item.toJSON().CFDocumentURI.targetType).toBe('CASE'); + }); }); });