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
83 changes: 51 additions & 32 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<string, unknown> | null
if (!p) return {}
// Handle both wrapped { CFPackage: { ... } } and unwrapped formats
const inner = (p.CFPackage ?? p) as Record<string, unknown>
const defs = inner.CFDefinitions as Record<string, unknown[]> | 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 (
<AuthProvider>
Expand All @@ -74,7 +52,12 @@ function AppInner() {
// Store per-framework edge type from CASE extensions (keyed by framework ID)
const [frameworkEdgeTypes, setFrameworkEdgeTypes] = useState<Record<string, string>>({})

// 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<Record<string, CFDefinition>>({})

// 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<CFItemType[]>([])
const [tenantCfSubjects, setTenantCfSubjects] = useState<CFSubject[]>([])
const [tenantCfConcepts, setTenantCfConcepts] = useState<CFConcept[]>([])
Expand All @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }))
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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}
>
<EditorCanvas
onBack={() => navigateHome()}
Expand All @@ -756,6 +774,7 @@ function AppInner() {
onLoadAlignmentsForTarget={tenantId ? handleLoadAlignmentsForTarget : undefined}
onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined}
mirrorStatus={activeFramework.mirrorStatus}
cfDefinitionsExtensions={activeCfDefinitions?.extensions}
/>
</EditorProvider>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null
if (!p) return undefined
const inner = (p.CFPackage ?? p) as Record<string, unknown>
const defs = inner.CFDefinitions
return defs && typeof defs === 'object' ? (defs as CFDefinition) : undefined
}

function ownFirst<T extends { identifier: string }>(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),
}
}
Loading
Loading