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 @@ -38,6 +38,12 @@ export type CaseDocumentSnapshot = {
licenseURI?: { title?: string; identifier?: string; uri: string }
/** OpenCASE: unauthenticated CASE API reads. Absent means sign-in is required. */
publicAccess?: boolean
/**
* Non-OpenCASE CASE extensions (e.g. a source system's identifiers), preserved
* verbatim for round-trip fidelity. `ext:opencase` is excluded — the editor
* reads what it needs from it (e.g. publicAccess) and regenerates it on save.
*/
extensions?: Record<string, unknown>
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot):
lastChangeDateTime: doc.lastChangeDateTime,
licenseURI: doc.licenseURI,
publicAccess: doc.publicAccess === true ? true : undefined,
extensions: doc.extensions,
}

const items: Framework['items'] = new Map()
Expand Down Expand Up @@ -84,7 +85,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot):
statusEndDate: it.statusEndDate,
caseUri: it.uri,
lastChangeDateTime: it.lastChangeDateTime,
...it.extensions,
extensions: it.extensions,
},
}
items.set(item.id, item)
Expand All @@ -109,7 +110,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot):
CFAssociationGroupingTitle: a.CFAssociationGroupingTitle,
notes: a.notes,
lastChangeDateTime: a.lastChangeDateTime,
...a.extensions,
extensions: a.extensions,
},
}
associations.set(assoc.id, assoc)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,8 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot
}
: undefined

const opencaseExt = asRecord(asRecord(doc.extensions)?.['ext:opencase'])
const { 'ext:opencase': rawOpencaseExt, ...customDocExtensions } = asRecord(doc.extensions) ?? {}
const opencaseExt = asRecord(rawOpencaseExt)
const publicAccess = opencaseExt?.publicAccess === true ? true : undefined

// Extract CFAssociationGroupings from CFDefinitions
Expand Down Expand Up @@ -344,6 +345,7 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot
lastChangeDateTime: asString(doc.lastChangeDateTime),
licenseURI,
publicAccess,
extensions: Object.keys(customDocExtensions).length > 0 ? customDocExtensions : undefined,
},
items,
associations,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect } from 'vitest'
import { frameworkToCfPackage } 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 { hasFrameworkDataChanged } from '@/domain/framework/hasFrameworkDataChanged'
import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types'
import type { Framework, Item } from '@/domain/framework/model/types'
import type { ItemId } from '@/domain/shared/types'

const DOC_ID = 'doc-1'

// A package as created through the API by an external sync script (GitHub #42/#43):
// custom extensions sit alongside the editor's own ext:opencase on every entity.
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',
extensions: {
'ext:opencase': { publicAccess: true },
sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' },
},
},
CFItems: [
{
identifier: 'item-1',
uri: '/ims/case/v1p1/CFItems/item-1',
fullStatement: 'Item 1',
lastChangeDateTime: '2026-01-01T00:00:00.000Z',
extensions: { 'ext:opencase': { colorBand: '#ff0000' }, smartschoolId: 'ss-1' },
},
],
CFAssociations: [
{
identifier: 'assoc-root',
uri: '/ims/case/v1p1/CFAssociations/assoc-root',
associationType: 'isChildOf',
originNodeURI: { identifier: 'item-1', uri: '/ims/case/v1p1/CFItems/item-1', title: 'Item 1' },
destinationNodeURI: { identifier: DOC_ID, uri: `/ims/case/v1p1/CFDocuments/${DOC_ID}`, title: 'Curriculum' },
lastChangeDateTime: '2026-01-01T00:00:00.000Z',
extensions: { 'ext:opencase': { originHandle: 'top' }, minimumGoal: 'MG-7' },
},
],
} as unknown as CFPackage
}

function loadIntoEditor() {
const framework = loadFrameworkFromCfPackage(makePackage())
if (!framework) throw new Error('failed to load framework')
return toReactFlowGraph({ framework })
}

function saveFromEditor(graph: ReturnType<typeof toReactFlowGraph>) {
const { framework, layout } = fromEditorGraph({ graph })
return frameworkToCfPackage({ framework, caseVersion: '1.1', layout })
}

function withItemMetadata(framework: Framework, patch: Record<string, unknown>): Framework {
const items = new Map(framework.items)
const item = items.get('item-1' as ItemId) as Item
items.set(item.id, { ...item, metadata: { ...item.metadata, ...patch } })
return { ...framework, items }
}

describe('CASE extensions round-trip through the editor', () => {
it('shows custom item and document extensions on the canvas, without ext:opencase on the document', () => {
const graph = loadIntoEditor()
const itemNode = graph.nodes.find((n) => (n.data as { cfItem?: CFItem }).cfItem?.identifier === 'item-1')
const docNode = graph.nodes.find((n) => (n.data as { cfDocument?: CFDocument }).cfDocument?.identifier === DOC_ID)

expect((itemNode?.data as { cfItem: CFItem }).cfItem.extensions).toMatchObject({ smartschoolId: 'ss-1' })
expect((docNode?.data as { cfDocument: CFDocument }).cfDocument.extensions).toEqual({
sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' },
})
})

it('preserves custom extensions on the document, items and associations when saving', () => {
const saved = saveFromEditor(loadIntoEditor())

expect(saved.CFDocument.extensions).toEqual({
sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' },
'ext:opencase': expect.objectContaining({ publicAccess: true }),
})

const item = saved.CFItems?.find((i) => i.identifier === 'item-1')
expect(item?.extensions).toMatchObject({ smartschoolId: 'ss-1', 'ext:opencase': { colorBand: '#ff0000' } })

const assoc = saved.CFAssociations?.find((a) => a.identifier === 'assoc-root')
expect(assoc?.extensions).toMatchObject({ minimumGoal: 'MG-7', 'ext:opencase': { originHandle: 'top' } })
})

it('keeps custom document extensions even when there is no ext:opencase data to write', () => {
const framework = loadFrameworkFromCfPackage(makePackage())!
const noOpencase = { ...framework, metadata: { ...framework.metadata, publicAccess: undefined } }
const saved = frameworkToCfPackage({ framework: noOpencase, caseVersion: '1.1' })

expect(saved.CFDocument.extensions).toEqual({ sourceSystem: { name: 'smartschool', curriculumId: 'cur-9' } })
})

it('regenerates item ext:opencase from editor state so a cleared colorBand does not come back', () => {
const { framework } = fromEditorGraph({ graph: loadIntoEditor() })
const cleared = withItemMetadata(framework, { colorBand: undefined })
const saved = frameworkToCfPackage({ framework: cleared, caseVersion: '1.1' })

expect(saved.CFItems?.[0].extensions).toEqual({ smartschoolId: 'ss-1' })
})

it('treats an edited custom extension as a data change, but not a change to ext:opencase', () => {
const { framework } = fromEditorGraph({ graph: loadIntoEditor() })
const original = framework.items.get('item-1' as ItemId)!.metadata!.extensions as Record<string, unknown>

const layoutOnly = withItemMetadata(framework, { extensions: { ...original, 'ext:opencase': { colorBand: '#00ff00' } } })
const customEdit = withItemMetadata(framework, { extensions: { ...original, smartschoolId: 'ss-2' } })

expect(hasFrameworkDataChanged(framework, layoutOnly)).toBe(false)
expect(hasFrameworkDataChanged(framework, customEdit)).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,17 @@ function frameworkToCfDocument(
title: docTitle,
identifier: fwId,
},
// meta.extensions holds the document's non-OpenCASE extensions (never ext:opencase),
// so ext:opencase is always regenerated from editor state while the rest round-trip.
extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks || meta.publicAccess === true)
? mergeOpencaseExtension(undefined, {
? mergeOpencaseExtension(meta.extensions, {
layout,
edgeType: options?.edgeType,
linkedFrameworks: options?.linkedFrameworks,
remoteItemLinks: options?.remoteItemLinks,
...(meta.publicAccess === true ? { publicAccess: true } : {}),
})
: undefined,
: meta.extensions,
}

return document
Expand Down Expand Up @@ -197,7 +199,11 @@ function itemToCfItem(
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : undefined
}

const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined
// Keep every non-OpenCASE extension verbatim. An item's ext:opencase only holds
// layout and colorBand, both regenerated below from current editor state, so the
// loaded copy is dropped rather than letting stale values (e.g. a cleared colorBand) survive.
const { [OPENCASE_EXT_KEY]: _staleOpencaseExt, ...customExtensions } = (md.extensions as CaseExtensions | undefined) ?? {}
const existingExtensions = Object.keys(customExtensions).length > 0 ? customExtensions : undefined

const cfItem: CFItem & { sourcedId: string } = {
identifier: itemId,
Expand Down
13 changes: 9 additions & 4 deletions apps/editor/src/domain/framework/hasFrameworkDataChanged.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import type { Association, Framework, Item } from '@/domain/framework/model/type
* Canvas-only concerns (node position, per-node color band, edge handle
* anchors, edge render style) show up in TWO different metadata shapes
* depending on where the Framework snapshot came from:
* - Freshly loaded from CASE data (`caseToDomainFramework.ts`'s
* `...it.extensions`/`...a.extensions` spread): nested one level, under
* a literal `metadata['ext:opencase']` key.
* - Stored as CASE extensions: under `metadata.extensions['ext:opencase']`
* (or a top-level `metadata['ext:opencase']` key from older builds).
* Other keys in `metadata.extensions` are real framework data and ARE
* compared — editing a custom extension is a data change.
* - Re-derived from the live canvas during an editing session
* (`fromEditorGraph.ts`, called on every save and to capture the
* fork-detection baseline): `colorBand` (items) and `originHandle`/
Expand All @@ -19,7 +20,11 @@ import type { Association, Framework, Item } from '@/domain/framework/model/type
*/
function stripLayoutMetadata(metadata: Record<string, unknown> | undefined): Record<string, unknown> {
if (!metadata) return {}
const { 'ext:opencase': _omit, colorBand: _colorBand, originHandle: _originHandle, destinationHandle: _destinationHandle, ...rest } = metadata
const { 'ext:opencase': _omit, colorBand: _colorBand, originHandle: _originHandle, destinationHandle: _destinationHandle, extensions, ...rest } = metadata
if (extensions && typeof extensions === 'object') {
const { 'ext:opencase': _omitExt, ...customExtensions } = extensions as Record<string, unknown>
if (Object.keys(customExtensions).length > 0) rest.extensions = customExtensions
}
return rest
}

Expand Down
5 changes: 5 additions & 0 deletions apps/editor/src/domain/framework/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ export type FrameworkMetadata = {
* Persisted as `ext:opencase.publicAccess`. Absent means sign-in is required.
*/
publicAccess?: boolean
/**
* Non-OpenCASE CASE extensions on the CFDocument, preserved verbatim on save.
* Never contains `ext:opencase`, which is regenerated from editor state.
*/
extensions?: Record<string, unknown>
}

export type ItemMetadata = Record<string, unknown>
Expand Down
7 changes: 5 additions & 2 deletions apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ export default memo(function NodePropertiesPanel({
const cfItem: CFItem | undefined = node && isItemNode(node) ? node.data.cfItem : undefined
const cfDocument: CFDocument | undefined = node && isFrameworkNode(node) ? node.data.cfDocument : undefined
const externalData: ExternalFrameworkNodeData | undefined = node && isExternalFrameworkNode(node) ? node.data : undefined
// Custom fields shown in the Extensions section; ext:opencase is editor-internal state.
const customExtensionEntries = Object.entries((isFramework ? cfDocument?.extensions : cfItem?.extensions) ?? {})
.filter(([k]) => k !== 'ext:opencase')

useEffect(() => {
setConceptInput(cfItem?.conceptKeywordsURI?.title ?? '')
Expand Down Expand Up @@ -692,9 +695,9 @@ export default memo(function NodePropertiesPanel({

{/* ── Extensions ── */}
<SidebarSection title="Extensions" subtitle="Custom fields" accentColor={accentColor} defaultOpen={false}>
{Object.entries(isFramework ? cfDocument?.extensions ?? {} : cfItem?.extensions ?? {}).length ? (
{customExtensionEntries.length ? (
<div className="space-y-2">
{Object.entries(isFramework ? cfDocument?.extensions ?? {} : cfItem?.extensions ?? {}).map(([k, v]) => (
{customExtensionEntries.map(([k, v]) => (
<div key={k} className="rounded-lg border border-black/10 bg-slate-50 p-3">
<div className="text-sm font-semibold text-slate-700">{k}</div>
<div className="mt-1 text-sm text-slate-900">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr
lastChangeDateTime: doc?.lastChangeDateTime,
licenseURI: doc?.licenseURI,
publicAccess: doc?.publicAccess === true ? true : undefined,
extensions: doc?.extensions,
}

const items: Framework['items'] = new Map()
Expand Down
9 changes: 5 additions & 4 deletions apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ function mapDomainFrameworkToCfDocument(framework: Framework): CFDocument {
CFPackageURI: { uri: `urn:case:package:${id}` },
licenseURI: meta.licenseURI,
publicAccess: meta.publicAccess === true ? true : undefined,
extensions: meta.extensions,
}
}

Expand All @@ -119,7 +120,8 @@ function mapDomainItemToCfItem(framework: Framework, itemId: string): CFItem {
const s = (k: string) => (typeof md[k] === 'string' ? (md[k] as string) : undefined)
const a = (k: string) => (Array.isArray(md[k]) ? (md[k] as unknown[]).filter((x): x is string => typeof x === 'string') : undefined)

// Extract colorBand from ext:opencase extension (may be in extensions map or flattened into metadata)
// Extract colorBand from ext:opencase extension (in the extensions map; a top-level
// metadata['ext:opencase'] key is accepted for frameworks mapped by older builds)
const rawExtensions = md.extensions as Record<string, unknown> | undefined
const opencaseExt = (rawExtensions?.[OPENCASE_EXT_KEY] ?? md[OPENCASE_EXT_KEY]) as Record<string, unknown> | undefined
const colorBand = typeof opencaseExt?.colorBand === 'string' ? opencaseExt.colorBand : s('colorBand')
Expand Down Expand Up @@ -161,9 +163,8 @@ function mapDomainAssociationToCfAssociation(framework: Framework, association:
const s = (k: string) => (typeof md[k] === 'string' ? (md[k] as string) : undefined)
const n = (k: string) => (typeof md[k] === 'number' ? (md[k] as number) : undefined)

// Reconstruct extensions: caseToDomainFramework spreads extensions directly into metadata
// (e.g. metadata["ext:opencase"] = {...}), while fromEditorGraph stores them as metadata.extensions.
// Handle both patterns.
// Extensions live in metadata.extensions (both caseToDomainFramework and fromEditorGraph).
// A top-level metadata["ext:opencase"] key is accepted for frameworks mapped by older builds.
const rawExtensions = md.extensions as Record<string, unknown> | undefined
const opencaseExt = md[OPENCASE_EXT_KEY] as Record<string, unknown> | undefined
const extensions = rawExtensions ?? (opencaseExt ? { [OPENCASE_EXT_KEY]: opencaseExt } : undefined)
Expand Down
22 changes: 17 additions & 5 deletions apps/opencase/src/application/case/endpoints/CreateFramework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,19 @@ export class CreateFramework {
}
}

const storedPkg = resolvedStorageKey ? await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey) : null

// The editor can't author rubrics or CFPackage-level extensions and omits
// them on save. Omitted (undefined) means "keep what's stored"; an explicit
// value — including an empty CFRubrics array — replaces it.
if (payload.CFRubrics === undefined && storedPkg) {
cfRubricsPayload = storedPkg.rubrics.map(r => r.toJSON())
}
const packageExtensions = payload.extensions !== undefined ? payload.extensions : storedPkg?.extensions

if (resolvedStorageKey) {
const existingMeta = this.store.getDocumentMetadata(tenantId, caseVersion, resolvedStorageKey)
const existingPkg = existingMeta ? await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey) : null
const existingPkg = existingMeta ? storedPkg : null

if (getOpenCaseIsModifiedFromSource(existingMeta) !== undefined && existingPkg) {
preserveUris = { preserveUris: getOpenCaseIsModifiedFromSource(existingMeta) === false }
Expand Down Expand Up @@ -287,7 +297,7 @@ export class CreateFramework {
)
const definitions = payload.CFDefinitions ?? null

const pkg = new CFPackage({ document, items, associations, rubrics, definitions })
const pkg = new CFPackage({ document, items, associations, rubrics, definitions, extensions: packageExtensions })

if (forked) {
// A fork always writes a new version — no idempotency short-circuit.
Expand All @@ -307,21 +317,23 @@ export class CreateFramework {
// Idempotency: if this doc already exists and the resulting stored bundle would be identical,
// don't create a new version.
// Compare using CFPackage format (CFDocument, CFItems, etc.) to match validation format
const existing = await this.pkgRepo.load(tenantId, caseVersion, resolvedStorageKey)
const existing = storedPkg
if (existing) {
const existingBundle = {
CFDocument: existing.document.toJSON(),
CFItems: sortById(existing.items.map(i => i.toJSON())),
CFAssociations: sortById(existing.associations.map(a => a.toJSON())),
CFRubrics: sortById((existing.rubrics ?? []).map(r => r.toJSON())),
CFDefinitions: existing.definitions ?? null
CFDefinitions: existing.definitions ?? null,
extensions: existing.extensions ?? null
}
const newBundle = {
CFDocument: docJSON,
CFItems: sortById(items.map(i => i.toJSON())),
CFAssociations: sortById(associations.map(a => a.toJSON())),
CFRubrics: sortById(rubrics.map(r => r.toJSON())),
CFDefinitions: pkg.definitions
CFDefinitions: pkg.definitions,
extensions: pkg.extensions ?? null
}

if (stableStringify(existingBundle) === stableStringify(newBundle)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export class DeleteCFAssociation {
items: existingPkg.items,
associations,
rubrics: existingPkg.rubrics,
definitions: existingPkg.definitions
definitions: existingPkg.definitions,
extensions: existingPkg.extensions
})

await this.pkgRepo.saveNewVersion(tenantId, caseVersion, updatedPkg, storageKey)
Expand Down
3 changes: 2 additions & 1 deletion apps/opencase/src/application/case/endpoints/DeleteCFItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ export class DeleteCFItem {
items,
associations,
rubrics: existingPkg.rubrics,
definitions: existingPkg.definitions
definitions: existingPkg.definitions,
extensions: existingPkg.extensions
})

await this.pkgRepo.saveNewVersion(tenantId, caseVersion, updatedPkg, storageKey)
Expand Down
Loading
Loading