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
140 changes: 139 additions & 1 deletion apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { CreateFrameworkDraft } from '@/ui/home/CreateFrameworkDialog'
import type { Framework } from '@/domain/framework/model/types'
import { AuthProvider, useAuth } from '@/app/providers/AuthProvider'
import { getAppConfig } from '@/app/config'
import { CaseApiClient } from '@/infrastructure/caseApi/CaseApiClient'
import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient'
import { createFetchHttpClient } from '@/infrastructure/caseApi/http'
import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader'
import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage } from '@/ui/editor/reactflow/mapping'
Expand Down Expand Up @@ -75,6 +75,9 @@ function AppInner() {
// (either loaded from the server or successfully saved)
const [publishedFrameworkIds, setPublishedFrameworkIds] = useState<Set<string>>(new Set())

// Server-side framework list — populated from GET /ims/case/v1p1/CFDocuments on auth
const [serverCfDocuments, setServerCfDocuments] = useState<CfDocumentSummary[]>([])

/** Merge CFDefinitions from a loaded CFPackage into the tenant state (additive, no overwrites) */
const mergeCfDefinitions = useCallback((pkg: unknown) => {
const defs = extractCfDefinitions(pkg)
Expand Down Expand Up @@ -210,6 +213,19 @@ function AppInner() {
return () => { cancelled = true }
}, [authStatus, tenantId, api])

// Fetch server framework list so tree-view crosswalk selector can show all tenant frameworks.
useEffect(() => {
if (authStatus !== 'authenticated') return
let cancelled = false
api.listCfDocuments({ caseVersion: 'v1p1' }).then((docs) => {
if (cancelled) return
setServerCfDocuments(docs)
}).catch((err) => {
console.warn('[App] Failed to load server framework list:', err)
})
return () => { cancelled = true }
}, [authStatus, api])

const activeFramework = useMemo(() => {
if (!activeFrameworkId) return null
return frameworks.find((f) => f.id === activeFrameworkId) ?? null
Expand All @@ -221,6 +237,14 @@ function AppInner() {
[frameworks, publishedFrameworkIds],
)

// Summaries of server frameworks passed to the crosswalk target selector (excludes alignment frameworks)
const serverFrameworkSummaries = useMemo(
() => serverCfDocuments
.filter((d) => d.frameworkType !== 'Alignment')
.map((d) => ({ id: d.identifier, title: d.title ?? d.identifier })),
[serverCfDocuments],
)

const openFramework = useCallback((id: string) => {
setActiveFrameworkId(id)
setScreen('editor')
Expand Down Expand Up @@ -352,6 +376,30 @@ function AppInner() {
[api, mergeCfDefinitions],
)

// Load a framework from the server into the local session without navigating to it.
// Used by TreePanelView when the user selects a crosswalk target that isn't loaded locally yet.
const handleLoadTargetFramework = useCallback(
async (docId: string) => {
const pkg = await api.getCfPackage({ docId, caseVersion: 'v1p1' })
mergeCfDefinitions(pkg)
const layout = extractLayoutFromCfPackage(pkg)
const editorSettings = extractEditorSettingsFromCfPackage(pkg)
const framework = loadFrameworkFromCfPackage(pkg)
if (!framework) throw new Error('Failed to load framework from CASE package')
const fw = createHomeFrameworkFromDomain(framework)
if (layout) setFrameworkLayouts((prev) => ({ ...prev, [fw.id]: layout }))
if (editorSettings?.edgeType) setFrameworkEdgeTypes((prev) => ({ ...prev, [fw.id]: editorSettings.edgeType! }))
setFrameworks((prev) => {
if (prev.some((f) => f.id === fw.id)) return prev
const next = [...prev, fw]
saveFrameworks(next)
return next
})
setPublishedFrameworkIds((prev) => new Set(prev).add(fw.id))
},
[api, mergeCfDefinitions],
)

// Derive the graph from the active framework
// This converts the domain Framework to React Flow format.
// When no saved layout exists the topology is detected and an appropriate
Expand Down Expand Up @@ -478,6 +526,90 @@ function AppInner() {
[api, tenantId, caseApiVersion, activeFrameworkId, openRemoteFramework],
)

const handleSaveAlignments = useCallback(
async (cfPackage: unknown) => {
if (!tenantId) throw new Error('Not signed in to a tenant. Please sign in to save.')
const pkg = cfPackage as { CFAssociations?: unknown[]; CFDocument?: { identifier?: string } }
const docId = pkg.CFDocument?.identifier
if (!pkg.CFAssociations?.length && docId) {
// No associations remain — delete the alignment doc so it won't resurface on reload.
// If the doc never existed on the server (new target, never saved) the 404 is harmless.
try {
await api.deleteCfPackage({ tenantId, docId, hardDelete: true })
} catch {
// ignore — doc may not exist on the server yet
}
} else {
await api.saveCfPackage({ tenantId, cfPackage, caseVersion: caseApiVersion })
}
},
[api, tenantId, caseApiVersion],
)

const handleDiscoverAlignedTargets = useCallback(
async (sourceId: string): Promise<Array<{ targetId: string; alignmentDocId: string }>> => {
if (!tenantId) return []
try {
const alignmentDocs = await api.listAlignmentFrameworks({ tenantId, participantId: sourceId })
return alignmentDocs.flatMap((doc) => {
const participants = doc.alignmentParticipants ?? []
const other = participants.find((p) => p.identifier !== sourceId)
// No distinct "other" participant means this is a self (intra-framework) alignment doc.
const targetIdentifier = other?.identifier ?? (participants.some((p) => p.identifier === sourceId) ? sourceId : undefined)
if (!targetIdentifier) return []
return [{ targetId: targetIdentifier, alignmentDocId: doc.sourcedId }]
})
} catch (err) {
console.warn('[App] Failed to discover aligned targets:', err)
return []
}
},
[api, tenantId],
)

const handleLoadAlignmentsForTarget = useCallback(
async (targetId: string): Promise<{
docId: string
associations: Array<{ id: string; fromItemId: string; toItemId: string; toFrameworkId: string; associationType: string; originUri: string; destinationUri: string }>
}> => {
const newDocId = () => globalThis.crypto?.randomUUID?.() ?? `align-${Date.now()}`
if (!tenantId || !activeFrameworkId) return { docId: newDocId(), associations: [] }
try {
const alignmentDocs = await api.listAlignmentFrameworks({ tenantId, participantId: activeFrameworkId })
// Every doc here already has activeFrameworkId as a participant (that's the query filter), so
// for a self-alignment (targetId === activeFrameworkId) matching "any participant === targetId"
// would match the first cross-framework doc too — instead require ALL participants to be self.
const matchingDoc = alignmentDocs.find((doc) => {
const participants = doc.alignmentParticipants ?? []
if (targetId === activeFrameworkId) {
return participants.length > 0 && participants.every((p) => p.identifier === activeFrameworkId)
}
return participants.some((p) => p.identifier === targetId)
})
if (!matchingDoc) return { docId: newDocId(), associations: [] }

const pkg = await api.getCfPackage({ docId: matchingDoc.sourcedId, caseVersion: 'v1p1' })
const cfAssociations = pkg.CFAssociations ?? []
const associations = cfAssociations
.map((a) => ({
id: a.identifier,
fromItemId: a.originNodeURI?.identifier ?? '',
toItemId: a.destinationNodeURI?.identifier ?? '',
toFrameworkId: targetId,
associationType: a.associationType ?? 'isRelatedTo',
originUri: a.originNodeURI?.uri ?? '',
destinationUri: a.destinationNodeURI?.uri ?? '',
}))
.filter((a) => a.fromItemId && a.toItemId)
return { docId: matchingDoc.sourcedId, associations }
} catch (err) {
console.warn('[App] Failed to load alignment associations:', err)
return { docId: newDocId(), associations: [] }
}
},
[api, tenantId, activeFrameworkId],
)

if (authCallbackState === 'processing') {
return (
<div className="min-h-screen w-full bg-slate-50">
Expand Down Expand Up @@ -551,6 +683,12 @@ function AppInner() {
isPublishedToOpenCase={activeFrameworkId ? publishedFrameworkIds.has(activeFrameworkId) : false}
onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined}
onFetchCfPackage={activeFrameworkId ? handleFetchCfPackage : undefined}
availableFrameworks={frameworks}
serverFrameworks={serverFrameworkSummaries}
onLoadTargetFramework={handleLoadTargetFramework}
onSaveAlignments={tenantId ? handleSaveAlignments : undefined}
onLoadAlignmentsForTarget={tenantId ? handleLoadAlignmentsForTarget : undefined}
onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined}
mirrorStatus={activeFramework.mirrorStatus}
/>
</EditorProvider>
Expand Down
30 changes: 28 additions & 2 deletions apps/editor/src/domain/framework/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ export type FrameworkStatus = 'Draft' | 'Published'

export type ItemType = 'Standard' | 'LearningOutcome' | 'Competency' | 'Skill'

export type AssociationType = 'isChildOf' | 'isPartOf' | 'isRelatedTo'
export type AssociationType =
| 'isChildOf'
| 'isPartOf'
| 'isRelatedTo'
| 'isPeerOf'
| 'exactMatchOf'
| 'precedes'
| 'isReplacedBy'

export type FrameworkMetadata = {
title?: string
Expand Down Expand Up @@ -36,7 +43,26 @@ export type FrameworkMetadata = {
}

export type ItemMetadata = Record<string, unknown>
export type AssociationMetadata = Record<string, unknown>

export type AssociationMetadata = {
/** Original CASE association URI — preserved for round-trip fidelity */
caseUri?: string
/** Canonical URI of the origin (from) item — must be preserved verbatim for cross-framework associations */
originUri?: string
/** Canonical URI of the destination (to) item — must be preserved verbatim for cross-framework associations */
destinationUri?: string
sequenceNumber?: number
/** Edge handle position on the origin node — persists user-defined anchor points */
originHandle?: string
/** Edge handle position on the destination node — persists user-defined anchor points */
destinationHandle?: string
CFAssociationGroupingIdentifier?: string
CFAssociationGroupingTitle?: string
notes?: string
lastChangeDateTime?: string
extensions?: Record<string, unknown>
[key: string]: unknown
}

export type Item = {
id: ItemId
Expand Down
57 changes: 57 additions & 0 deletions apps/editor/src/domain/framework/treeDerivation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { CFItem } from '@/domain/case/types'

export interface FrameworkTreeNode {
id: string
cfItem: CFItem
children: FrameworkTreeNode[]
depth: number
}

/** Minimal edge record — always derivable from React Flow edge source/target/flags. */
export type FrameworkEdgeRecord = {
parentId: string
childId: string
sequenceNumber?: number
}

/**
* Pure domain function — no React Flow imports.
*
* @param cfItems All CFItems in the framework.
* @param edges Parent→child relationships (item-to-item only, no framework-root edges).
* @param rootItemIds Top-level item IDs in sequence order (pre-sorted by the caller).
*/
export function buildFrameworkTree(
cfItems: CFItem[],
edges: FrameworkEdgeRecord[],
rootItemIds: string[],
): FrameworkTreeNode[] {
const itemById = new Map(cfItems.map((item) => [item.identifier, item]))

// Build parent → [{childId, seq}] map
const childrenOf = new Map<string, { childId: string; seq: number }[]>()
for (const edge of edges) {
const entry = childrenOf.get(edge.parentId) ?? []
entry.push({ childId: edge.childId, seq: edge.sequenceNumber ?? Infinity })
childrenOf.set(edge.parentId, entry)
}

// Sort each child list by sequence number
for (const entry of childrenOf.values()) {
entry.sort((a, b) => a.seq - b.seq)
}

function buildNode(id: string, depth: number): FrameworkTreeNode | null {
const item = itemById.get(id)
if (!item) return null
const childEntries = childrenOf.get(id) ?? []
const children = childEntries
.map((e) => buildNode(e.childId, depth + 1))
.filter((n): n is FrameworkTreeNode => n !== null)
return { id, cfItem: item, children, depth }
}

return rootItemIds
.map((id) => buildNode(id, 0))
.filter((n): n is FrameworkTreeNode => n !== null)
}
30 changes: 30 additions & 0 deletions apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import type { HttpClient } from './http'

export type OpenCaseCfPackageResponse = { CFPackage: CFPackage }

export type AlignmentFrameworkSummary = {
sourcedId: string
title: string
caseVersion: string
frameworkType?: string
alignmentParticipants?: Array<{ identifier?: string; uri: string }>
lastChangeDateTime?: string
}

export type OpenCaseManagementCfPackageSummary = {
sourcedId?: string
identifier?: string
Expand Down Expand Up @@ -279,6 +288,27 @@ export class CaseApiClient {
return []
}

/**
* List alignment frameworks for a tenant that include a specific framework as a participant.
*
* Uses the management endpoint:
* GET /management/tenants/{tenantId}/CFPackages?frameworkType=Alignment&participantId={participantId}
*/
async listAlignmentFrameworks(params: {
tenantId: string
participantId: string
}): Promise<AlignmentFrameworkSummary[]> {
const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/CFPackages?frameworkType=Alignment&participantId=${encodeURIComponent(params.participantId)}`
const res = (await this._http.get(url)) as unknown

if (res && typeof res === 'object' && 'frameworks' in res) {
const obj = res as { frameworks?: unknown }
if (Array.isArray(obj.frameworks)) return obj.frameworks as AlignmentFrameworkSummary[]
}
if (Array.isArray(res)) return res as AlignmentFrameworkSummary[]
return []
}

// ── API Key Management ──────────────────────────────────────────

/**
Expand Down
Loading
Loading