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
1 change: 1 addition & 0 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,7 @@ function AppInner() {
onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined}
mirrorStatus={activeFramework.mirrorStatus}
cfDefinitionsExtensions={activeCfDefinitions?.extensions}
loadedFramework={activeFramework.framework}
/>
</EditorProvider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}

/**
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof toOpenCaseFormat>, 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)
})
})
43 changes: 31 additions & 12 deletions apps/editor/src/application/framework/mappers/case/toCasePackage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`
}
Expand Down Expand Up @@ -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]
Expand All @@ -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)

Expand Down Expand Up @@ -301,6 +313,7 @@ function associationToCfAssociation(
identifier: fromId,
uri: s('originUri') ?? `urn:case:item:${fromId}`,
title: fromTitle,
...(originTargetType ? { targetType: originTargetType } : {}),
},
destinationNodeURI: isRemoteLink
? {
Expand All @@ -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')
Expand Down Expand Up @@ -522,6 +536,7 @@ export type CaseLinkURI = {
title: string
identifier: string
uri: string
targetType?: string
}

/**
Expand Down Expand Up @@ -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 ? {
Expand Down
8 changes: 8 additions & 0 deletions apps/editor/src/domain/framework/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions apps/editor/src/domain/framework/offCanvasAssociations.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
38 changes: 38 additions & 0 deletions apps/editor/src/domain/framework/offCanvasAssociations.ts
Original file line number Diff line number Diff line change
@@ -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<Association['id'], Association> | 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<string, unknown> | undefined)?.['ext:opencase']
return (ext as { remoteLink?: unknown } | undefined)?.remoteLink === true
}
Loading
Loading