diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index 10a7c6b..b36b023 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -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' @@ -75,6 +75,9 @@ function AppInner() { // (either loaded from the server or successfully saved) const [publishedFrameworkIds, setPublishedFrameworkIds] = useState>(new Set()) + // Server-side framework list — populated from GET /ims/case/v1p1/CFDocuments on auth + const [serverCfDocuments, setServerCfDocuments] = useState([]) + /** Merge CFDefinitions from a loaded CFPackage into the tenant state (additive, no overwrites) */ const mergeCfDefinitions = useCallback((pkg: unknown) => { const defs = extractCfDefinitions(pkg) @@ -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 @@ -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') @@ -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 @@ -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> => { + 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 (
@@ -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} /> diff --git a/apps/editor/src/domain/framework/model/types.ts b/apps/editor/src/domain/framework/model/types.ts index 0f308ca..f2e09df 100644 --- a/apps/editor/src/domain/framework/model/types.ts +++ b/apps/editor/src/domain/framework/model/types.ts @@ -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 @@ -36,7 +43,26 @@ export type FrameworkMetadata = { } export type ItemMetadata = Record -export type AssociationMetadata = Record + +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 + [key: string]: unknown +} export type Item = { id: ItemId diff --git a/apps/editor/src/domain/framework/treeDerivation.ts b/apps/editor/src/domain/framework/treeDerivation.ts new file mode 100644 index 0000000..53571d8 --- /dev/null +++ b/apps/editor/src/domain/framework/treeDerivation.ts @@ -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() + 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) +} diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index 292a57a..8aaa014 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -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 @@ -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 { + 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 ────────────────────────────────────────── /** diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index 90b3b28..c3ea320 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -16,10 +16,12 @@ import SettingsModal from '@/ui/editor/components/SettingsModal' import FloatingAddButton from '@/ui/editor/components/FloatingAddButton' import AddExternalFrameworkDialog from '@/ui/editor/components/AddExternalFrameworkDialog' import ViewCFPackageDialog from '@/ui/editor/components/ViewCFPackageDialog' +import TreePanelView from '@/ui/editor/treePanel/TreePanelView' import { useEditor } from '@/ui/editor/state/EditorContext' import { isFrameworkNode, getNodeSize } from '@/ui/editor/state/helpers/nodeGeometry' import type { CaseEditorNodeType, CaseEditorEdge } from '@/ui/editor/reactflow/types' import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types' +import type { HomeFramework } from '@/ui/home/frameworkStore' import { useAuth } from '@/app/providers/AuthProvider' import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' import { absolutizeCaseUris, frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' @@ -37,11 +39,26 @@ type EditorCanvasProps = { onArchiveFramework?: () => Promise /** Fetch the published CFPackage from the server (returns CASE JSON with absolute URIs) */ onFetchCfPackage?: () => Promise + /** All locally-available frameworks — used to populate the crosswalk target selector in tree view */ + availableFrameworks?: HomeFramework[] + /** Server-side framework summaries not yet loaded locally — shown in crosswalk target selector for auto-load */ + serverFrameworks?: Array<{ id: string; title: string }> + /** Load a framework from the server into the local session (called when a server-only crosswalk target is selected) */ + onLoadTargetFramework?: (id: string) => Promise + /** Save a serialized alignment CFPackage to the server */ + onSaveAlignments?: (cfPackage: unknown) => Promise + /** Load existing alignment associations for a target framework pairing */ + onLoadAlignmentsForTarget?: (targetId: string) => Promise<{ + docId: string + associations: Array<{ id: string; fromItemId: string; toItemId: string; toFrameworkId: string; associationType: string; originUri: string; destinationUri: string }> + }> + /** Discover all frameworks that already have saved alignment docs with the given source framework */ + onDiscoverAlignedTargets?: (sourceId: string) => Promise> /** Mirror/fork status of the open framework, if it was ever imported */ mirrorStatus?: MirrorStatus } -export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage, mirrorStatus }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -102,6 +119,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const [viewCaseLoading, setViewCaseLoading] = useState(false) const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveError, setSaveError] = useState(null) + const [activeView, setActiveView] = useState<'canvas' | 'tree'>('tree') const [forkWarningOpen, setForkWarningOpen] = useState(false) // Baseline Framework snapshot for fork-detection — captured once when this @@ -1236,14 +1254,30 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen : undefined } onOpenSettings={() => setSettingsOpen(true)} - onResetHierarchy={applyHierarchyLayout} - onResetStar={applyStarLayout} + onResetHierarchy={() => { setActiveView('canvas'); applyHierarchyLayout() }} + onResetStar={() => { setActiveView('canvas'); applyStarLayout() }} + onSwitchTreeView={() => setActiveView(activeView === 'tree' ? 'canvas' : 'tree')} + activeView={activeView} cfAssociationGroupings={inUseGroupings} activeGroupingFilter={activeGroupingFilter} onSetGroupingFilter={setActiveGroupingFilter} /> -
+ {activeView === 'tree' ? ( +
+ +
+ ) : null} + +
nodes={nodesWithCallbacks} edges={edgesWithType} @@ -1305,6 +1339,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen node={selectedNode} onClose={clearSelection} onChangeNode={updateNodeData} + hideColorBand={activeView === 'tree'} onViewCFPackage={handleViewCFPackage} isPublishedToOpenCase={isPublishedToOpenCase} availableLicenses={availableLicenses} diff --git a/apps/editor/src/ui/editor/components/CanvasHeader.tsx b/apps/editor/src/ui/editor/components/CanvasHeader.tsx index 71ad80c..8731b58 100644 --- a/apps/editor/src/ui/editor/components/CanvasHeader.tsx +++ b/apps/editor/src/ui/editor/components/CanvasHeader.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ComponentType } from 'react' -import { Cog6ToothIcon, QuestionMarkCircleIcon, ArrowRightStartOnRectangleIcon, ChevronLeftIcon, Bars3BottomLeftIcon, SparklesIcon, CloudArrowUpIcon, CheckCircleIcon, KeyIcon, ShareIcon } from '@heroicons/react/24/solid' +import { Cog6ToothIcon, QuestionMarkCircleIcon, ArrowRightStartOnRectangleIcon, ChevronLeftIcon, Bars3BottomLeftIcon, SparklesIcon, CloudArrowUpIcon, CheckCircleIcon, KeyIcon, ShareIcon, QueueListIcon } from '@heroicons/react/24/solid' import { Button } from '@/ui/shared/components/ui/button' import type { CFAssociationGrouping } from '@/domain/case/types' @@ -200,6 +200,8 @@ export default function CanvasHeader({ onOpenSettings, onResetHierarchy, onResetStar, + onSwitchTreeView, + activeView = 'canvas', cfAssociationGroupings, activeGroupingFilter, onSetGroupingFilter, @@ -228,6 +230,10 @@ export default function CanvasHeader({ onResetHierarchy?: () => void /** Re-layout graph in star/radial topology mode */ onResetStar?: () => void + /** Toggle between the tree panel view and canvas view */ + onSwitchTreeView?: () => void + /** Currently active view mode */ + activeView?: 'canvas' | 'tree' /** Association grouping definitions for filter dropdown */ cfAssociationGroupings?: CFAssociationGrouping[] /** Currently active grouping filter (null = show all) */ @@ -376,6 +382,7 @@ export default function CanvasHeader({ { label: 'Settings', icon: Cog6ToothIcon, onClick: onOpenSettings }, { label: 'Hierarchy layout', icon: Bars3BottomLeftIcon, onClick: onResetHierarchy, disabled: !onResetHierarchy }, { label: 'Star layout', icon: SparklesIcon, onClick: onResetStar, disabled: !onResetStar }, + { label: activeView === 'tree' ? '✓ Tree view' : 'Tree view', icon: QueueListIcon, onClick: onSwitchTreeView, disabled: !onSwitchTreeView }, 'divider', { label: 'Help', icon: QuestionMarkCircleIcon, onClick: () => {} }, ]} diff --git a/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx index 0bf7f8c..bbb02f5 100644 --- a/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx @@ -142,8 +142,8 @@ export default memo(function EdgePropertiesPanel({ edge, nodes, onClose, onChang return (
- updateItem({ colorBand: color ?? '' })} labelClassName={LABEL_CLS} /> + {!hideColorBand && updateItem({ colorBand: color ?? '' })} labelClassName={LABEL_CLS} />}
void applyHierarchyLayout: () => void applyStarLayout: () => void + cfDocument: CFDocument | undefined + frameworkNodeId: string | null + cfItems: CFItem[] + frameworkEdges: FrameworkEdgeRecord[] + rootItemIds: string[] } const EditorContext = createContext(null) @@ -407,14 +413,17 @@ export function EditorProvider({ const applyHierarchyLayout = useCallback(() => { const { positions, edgeHandles } = computeHierarchyLayout(state.nodes, state.edges) dispatch({ type: 'layout/applyHierarchy', positions, edgeHandles }) - updateSettings({ ...settings, edgeType: 'smoothstep' }) - }, [state.nodes, state.edges, settings, updateSettings]) + // Edge style here is a byproduct of the chosen layout/view, not a deliberate + // settings edit — set it directly so it doesn't dirty the document (matches + // position/handle changes from the same action, and 'star' below). + setSettings((prev) => ({ ...prev, edgeType: 'smoothstep' })) + }, [state.nodes, state.edges]) const applyStarLayout = useCallback(() => { const { positions, edgeHandles } = computeStarLayout(state.nodes, state.edges) dispatch({ type: 'layout/applyHierarchy', positions, edgeHandles }) - updateSettings({ ...settings, edgeType: 'default' }) - }, [state.nodes, state.edges, settings, updateSettings]) + setSettings((prev) => ({ ...prev, edgeType: 'default' })) + }, [state.nodes, state.edges]) // ── CRUD callbacks ─────────────────────────────────────────────────── @@ -627,6 +636,44 @@ export function EditorProvider({ }, []) const clearDirty = useCallback(() => dispatch({ type: 'dirty/clear' }), []) + // ── Domain accessors (memoized, always in sync with reducer state) ──── + + const cfDocument = useMemo( + () => state.nodes.find(isFrameworkNode)?.data.cfDocument, + [state.nodes], + ) + + const frameworkNodeId = useMemo( + () => state.nodes.find(isFrameworkNode)?.id ?? null, + [state.nodes], + ) + + const cfItems = useMemo( + () => state.nodes.filter(isItemNode).map((n) => n.data.cfItem), + [state.nodes], + ) + + const frameworkEdges = useMemo( + (): FrameworkEdgeRecord[] => + state.edges + .filter((e) => !e.data?.isFrameworkRootConnection) + .map((e) => ({ + parentId: e.source, + childId: e.target, + sequenceNumber: e.data?.sequenceNumber, + })), + [state.edges], + ) + + const rootItemIds = useMemo( + () => + state.edges + .filter((e) => e.data?.isFrameworkRootConnection) + .sort((a, b) => (a.data?.sequenceNumber ?? Infinity) - (b.data?.sequenceNumber ?? Infinity)) + .map((e) => e.target), + [state.edges], + ) + // ── Context value ──────────────────────────────────────────────────── const value: EditorContextValue = useMemo( @@ -682,6 +729,11 @@ export function EditorProvider({ deleteElements, applyHierarchyLayout, applyStarLayout, + cfDocument, + frameworkNodeId, + cfItems, + frameworkEdges, + rootItemIds, }), [ state.nodes, state.edges, selectedNodeIds, selectedEdgeIds, @@ -698,6 +750,7 @@ export function EditorProvider({ addChild, addDetachedItem, addExternalFramework, addItemDialog, setAddItemDraft, cancelAddItem, confirmAddItem, deleteElements, applyHierarchyLayout, applyStarLayout, + cfDocument, frameworkNodeId, cfItems, frameworkEdges, rootItemIds, ], ) diff --git a/apps/editor/src/ui/editor/state/editorReducer.test.ts b/apps/editor/src/ui/editor/state/editorReducer.test.ts index 96c95e7..d837e6e 100644 --- a/apps/editor/src/ui/editor/state/editorReducer.test.ts +++ b/apps/editor/src/ui/editor/state/editorReducer.test.ts @@ -106,7 +106,7 @@ describe('layout actions', () => { expect(next.dirty).toBe(false) }) - it('layout/applyHierarchy updates positions, handles, and marks dirty', () => { + it('layout/applyHierarchy updates positions and handles but does NOT mark dirty (switching layout/view is not a data edit)', () => { const state = makeState() const edgeId = state.edges[0].id @@ -131,7 +131,7 @@ describe('layout actions', () => { expect(edge?.sourceHandle).toBe('bottom') expect(edge?.targetHandle).toBe('left') expect(edge?.data?.edgeType).toBe('smoothstep') - expect(next.dirty).toBe(true) + expect(next.dirty).toBe(false) expect(next.layoutVersion).toBe(state.layoutVersion + 1) }) }) diff --git a/apps/editor/src/ui/editor/state/editorReducer.ts b/apps/editor/src/ui/editor/state/editorReducer.ts index f081394..b1d881c 100644 --- a/apps/editor/src/ui/editor/state/editorReducer.ts +++ b/apps/editor/src/ui/editor/state/editorReducer.ts @@ -604,7 +604,9 @@ export function editorReducer(state: EditorState, action: Action): EditorState { data: { ...e.data, edgeType: h.edgeType, labelPosition: h.labelPosition }, } as CaseEditorEdge }) - return { ...state, nodes: nextNodes, edges: nextEdges, layoutVersion: state.layoutVersion + 1, dirty: true } + // Switching layout/view mode is not a data edit — position/handle recomputation + // alone must not require a save (mirrors 'layout/apply' below). + return { ...state, nodes: nextNodes, edges: nextEdges, layoutVersion: state.layoutVersion + 1 } } case 'graph/load': { return { diff --git a/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx b/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx new file mode 100644 index 0000000..95b18ab --- /dev/null +++ b/apps/editor/src/ui/editor/treePanel/FrameworkTreeItem.tsx @@ -0,0 +1,193 @@ +import { useState } from 'react' +import { ChevronDown, ChevronRight, Link, Plus } from 'lucide-react' +import type { FrameworkTreeNode } from '@/domain/framework/treeDerivation' + +type Props = { + node: FrameworkTreeNode + selectedId: string | null + expandedIds: Set + onToggleExpand: (_id: string) => void + onSelect?: (_id: string) => void + onAddChild?: (_parentId: string) => void + isDraggable?: boolean + onDragStart?: (_id: string, _e: React.DragEvent) => void + isDropTarget?: boolean + onDragOver?: (_id: string, _e: React.DragEvent) => void + onDragLeave?: (_id: string, _e: React.DragEvent) => void + onDrop?: (_id: string, _e: React.DragEvent) => void + /** Threaded through children — each item derives its own isDraggedOver / associationCount */ + dragOverItemId?: string | null + associationCounts?: Map + /** Called when the association-count badge is clicked */ + onBadgeClick?: (_id: string, _e: React.MouseEvent) => void +} + +export default function FrameworkTreeItem({ + node, + selectedId, + expandedIds, + onToggleExpand, + onSelect, + onAddChild, + isDraggable, + onDragStart, + isDropTarget, + onDragOver, + onDragLeave, + onDrop, + dragOverItemId, + associationCounts, + onBadgeClick, +}: Readonly) { + const [hovered, setHovered] = useState(false) + const isSelected = selectedId === node.id + const isExpanded = expandedIds.has(node.id) + const hasChildren = node.children.length > 0 + const isDraggedOver = dragOverItemId === node.id + const associationCount = associationCounts?.get(node.id) ?? 0 + + const rowClass = [ + 'flex w-full items-start gap-2 rounded-lg border px-3 py-2 transition-colors', + isDraggedOver + ? 'border-teal-400 bg-teal-50 ring-2 ring-teal-400/40' + : isSelected + ? 'border-teal-400 bg-teal-50' + : 'border-black/15 bg-white hover:bg-slate-50 hover:border-black/25', + isDraggable ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer', + isDropTarget ? 'cursor-copy' : '', + ].join(' ') + + return ( +
+
onSelect?.(node.id)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + onDragStart={(e) => { + if (!isDraggable) return + e.dataTransfer.setData('text/plain', node.id) + e.dataTransfer.effectAllowed = 'link' + onDragStart?.(node.id, e) + }} + onDragOver={(e) => { + if (!isDropTarget) return + e.preventDefault() + e.dataTransfer.dropEffect = 'link' + onDragOver?.(node.id, e) + }} + onDragLeave={(e) => { + if (!isDropTarget) return + onDragLeave?.(node.id, e) + }} + onDrop={(e) => { + if (!isDropTarget) return + e.preventDefault() + onDrop?.(node.id, e) + }} + role="treeitem" + aria-selected={isSelected} + aria-expanded={hasChildren ? isExpanded : undefined} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect?.(node.id) } + if (e.key === 'ArrowRight' && hasChildren && !isExpanded) onToggleExpand(node.id) + if (e.key === 'ArrowLeft' && isExpanded) onToggleExpand(node.id) + }} + > + + +
+
+ {node.cfItem.humanCodingScheme ? ( + + {node.cfItem.humanCodingScheme} + + ) : null} + + {node.cfItem.identifier} + + {associationCount > 0 && ( + + )} +
+ {node.cfItem.abbreviatedStatement?.trim() ? ( +

+ {node.cfItem.abbreviatedStatement.trim()} +

+ ) : null} +

+ {node.cfItem.fullStatement} +

+ {hasChildren && ( + + {node.children.length} {node.children.length === 1 ? 'item' : 'items'} + + )} +
+ + {onAddChild && ( + + )} +
+ + {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ) +} diff --git a/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx b/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx new file mode 100644 index 0000000..51ebc26 --- /dev/null +++ b/apps/editor/src/ui/editor/treePanel/FrameworkTreeList.tsx @@ -0,0 +1,137 @@ +import { Plus } from 'lucide-react' +import { Button } from '@/ui/shared/components/ui/button' +import FrameworkTreeItem from './FrameworkTreeItem' +import type { FrameworkTreeNode } from '@/domain/framework/treeDerivation' + +type Props = { + title: string + description?: string + publisher?: string + frameworkNodeId: string | null + roots: FrameworkTreeNode[] + selectedId: string | null + expandedIds: Set + onToggleExpand: (_id: string) => void + /** When true, suppresses the title/description header button — used when the caller renders its own header (e.g. an accordion). */ + noHeader?: boolean + /** Optional extra content rendered inside the header (e.g. a framework selector dropdown) */ + headerSlot?: React.ReactNode + onSelect?: (_id: string) => void + onAddChild?: (_parentId: string) => void + onAddRoot?: () => void + /** Drag source — all items become draggable */ + isDraggable?: boolean + onDragStart?: (_id: string, _e: React.DragEvent) => void + /** Drop target — all items accept drops */ + isDropTarget?: boolean + onDragOver?: (_id: string, _e: React.DragEvent) => void + onDragLeave?: (_id: string, _e: React.DragEvent) => void + onDrop?: (_id: string, _e: React.DragEvent) => void + /** Currently-hovered item ID (for drop highlight) */ + dragOverItemId?: string | null + /** Pending association counts per item ID */ + associationCounts?: Map + /** Called when an association-count badge is clicked */ + onBadgeClick?: (_id: string, _e: React.MouseEvent) => void +} + +export default function FrameworkTreeList({ + title, + description, + publisher, + frameworkNodeId, + roots, + selectedId, + expandedIds, + onToggleExpand, + noHeader, + headerSlot, + onSelect, + onAddChild, + onAddRoot, + isDraggable, + onDragStart, + isDropTarget, + onDragOver, + onDragLeave, + onDrop, + dragOverItemId, + associationCounts, + onBadgeClick, +}: Readonly) { + const frameworkSelected = frameworkNodeId !== null && selectedId === frameworkNodeId + + return ( +
+ {!noHeader && } + +
+ {roots.length === 0 ? ( +
+

No items yet.

+ {onAddRoot && ( + + )} +
+ ) : ( +
+ {roots.map((node) => ( + + ))} +
+ )} +
+
+ ) +} diff --git a/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx b/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx new file mode 100644 index 0000000..016ad54 --- /dev/null +++ b/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx @@ -0,0 +1,1141 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ChevronRight, Plus, X } from 'lucide-react' +import { useEditor } from '@/ui/editor/state/EditorContext' +import { buildFrameworkTree, type FrameworkEdgeRecord, type FrameworkTreeNode } from '@/domain/framework/treeDerivation' +import type { CFItem } from '@/domain/case/types' +import type { HomeFramework } from '@/ui/home/frameworkStore' +import type { Framework } from '@/domain/framework/model/types' +import FrameworkTreeList from './FrameworkTreeList' + +// ── Types ───────────────────────────────────────────────────────────────── + +type PendingAssociation = { + id: string + fromItemId: string + toItemId: string + toFrameworkId: string + associationType: string + /** Canonical URI of the origin item — preserved for correct serialization when external frameworks are involved */ + originUri: string + /** Canonical URI of the destination item — preserved for correct serialization when external frameworks are involved */ + destinationUri: string +} + +const ALIGNMENT_ASSOCIATION_TYPES: Array<{ value: string; label: string }> = [ + { value: 'exactMatchOf', label: 'Exact Match Of' }, + { value: 'isRelatedTo', label: 'Is Related To' }, + { value: 'isPeerOf', label: 'Is Peer Of' }, + { value: 'precedes', label: 'Precedes' }, + { value: 'isReplacedBy', label: 'Is Replaced By' }, +] + +type LineCoord = { + id: string + d: string + midX: number + midY: number + /** True when one or both endpoints are ancestors (item is collapsed). Clicking expands to reveal items. */ + isDashed: boolean +} + +type PopoverState = { + itemId: string + side: 'left' | 'right' + x: number + y: number +} + +type AlignmentPreload = { + docId: string + associations: Array<{ + id: string + fromItemId: string + toItemId: string + toFrameworkId: string + associationType: string + originUri: string + destinationUri: string + }> +} + +type TargetEntry = { + id: string + alignmentDocId: string + hasUnsavedChanges: boolean +} + +type Props = { + availableFrameworks?: HomeFramework[] + /** Server-side frameworks not yet loaded locally — shown in the target selector and auto-loaded when selected */ + serverFrameworks?: Array<{ id: string; title: string }> + /** Load a framework from the server into the local session (called when a server-only target is selected) */ + onLoadTargetFramework?: (id: string) => Promise + /** Whether the source (left-panel) framework has been saved to the server. Alignment authoring requires stable server-assigned URIs. */ + isSourcePublished?: boolean + /** Called with a serialized alignment CFPackage when the user saves pending associations. */ + onSaveAlignments?: (cfPackage: unknown) => Promise + /** Called when the user expands a target framework — returns any previously-saved alignment doc ID and associations for that pairing. */ + onLoadAlignmentsForTarget?: (targetId: string) => Promise + /** Called once on mount to discover all frameworks that already have saved alignments with the source. Used to pre-populate the target list. */ + onDiscoverAlignedTargets?: (sourceId: string) => Promise> +} + +// ── Helpers: build tree data from a domain Framework ────────────────────── + +function domainFrameworkToCfItems(framework: Framework): CFItem[] { + return [...framework.items.values()].map((item) => { + const md = (item.metadata ?? {}) as Record + return { + identifier: String(item.id), + uri: (md.caseUri as string) || `urn:case:item:${String(item.id)}`, + fullStatement: item.statement, + humanCodingScheme: md.humanCodingScheme as string | undefined, + abbreviatedStatement: md.abbreviatedStatement as string | undefined, + lastChangeDateTime: (md.lastChangeDateTime as string) || new Date().toISOString(), + } + }) +} + +function domainFrameworkToEdges(framework: Framework): FrameworkEdgeRecord[] { + const frameworkId = String(framework.id) + return [...framework.associations.values()] + .filter( + (a) => + (a.associationType === 'isChildOf' || a.associationType === 'isPartOf') && + String(a.toItemId) !== frameworkId, + ) + .map((a) => { + const md = (a.metadata ?? {}) as Record + return { + parentId: String(a.toItemId), + childId: String(a.fromItemId), + sequenceNumber: md.sequenceNumber as number | undefined, + } + }) +} + +function domainFrameworkToRootIds(framework: Framework): string[] { + const frameworkId = String(framework.id) + return [...framework.associations.values()] + .filter( + (a) => + a.associationType === 'isChildOf' && String(a.toItemId) === frameworkId, + ) + .sort((a, b) => { + const seqA = ((a.metadata ?? {}) as Record).sequenceNumber as number ?? Infinity + const seqB = ((b.metadata ?? {}) as Record).sequenceNumber as number ?? Infinity + return seqA - seqB + }) + .map((a) => String(a.fromItemId)) +} + +/** Returns all ancestor IDs of itemId, from immediate parent up to the root, in that order. */ +function ancestorsFromParentMap(itemId: string, parentMap: Map): string[] { + const ancestors: string[] = [] + let current = parentMap.get(itemId) + while (current !== undefined && current !== null) { + ancestors.push(current) + current = parentMap.get(current) + } + return ancestors +} + +/** Maps every item ID → its parent item ID (null for root items). */ +function buildParentMap(nodes: FrameworkTreeNode[], parentId: string | null = null, map = new Map()): Map { + for (const node of nodes) { + map.set(node.id, parentId) + buildParentMap(node.children, node.id, map) + } + return map +} + +/** + * Finds the nearest item that is currently rendered in `panel`. + * Returns the item itself (isExact: true) if visible, or the nearest visible + * ancestor (isExact: false) if the item is collapsed inside a parent. + */ +function findNearestVisible( + itemId: string, + parentMap: Map, + panel: HTMLElement, +): { id: string; isExact: boolean } | null { + if (panel.querySelector(`[data-item-id="${itemId}"]`)) return { id: itemId, isExact: true } + let current = parentMap.get(itemId) + while (current !== undefined && current !== null) { + if (panel.querySelector(`[data-item-id="${current}"]`)) return { id: current, isExact: false } + current = parentMap.get(current) + } + return null +} + +// ── Component ───────────────────────────────────────────────────────────── + +export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, isSourcePublished = false, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) { + const { + nodes, + cfItems, + frameworkEdges, + rootItemIds, + frameworkNodeId, + frameworkInfo, + cfDocument, + selectedNodeId, + onNodesChange, + addChild, + addDetachedItem, + } = useEditor() + + const activeFrameworkId = frameworkNodeId + + // ── Core state ── + const [targets, setTargets] = useState([]) + const [expandedTargetId, setExpandedTargetId] = useState(null) + const [pendingAssociations, setPendingAssociations] = useState([]) + const [dragOverItemId, setDragOverItemId] = useState(null) + const [loadingTargetIds, setLoadingTargetIds] = useState>(() => new Set()) + const [loadedTargetIds, setLoadedTargetIds] = useState>(() => new Set()) + const [savingTargetId, setSavingTargetId] = useState(null) + const [saveError, setSaveError] = useState(null) + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + + // ── Tree expansion state (lifted so line recalculation fires on every expand/collapse) ── + const [leftExpandedIds, setLeftExpandedIds] = useState>(() => new Set()) + const [rightExpandedIds, setRightExpandedIds] = useState>(() => new Set()) + + const handleLeftToggleExpand = useCallback((id: string) => { + setLeftExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + }, []) + + const handleRightToggleExpand = useCallback((id: string) => { + setRightExpandedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id); else next.add(id) + return next + }) + }, []) + // ── SVG overlay state ── + const [lineCoords, setLineCoords] = useState([]) + const [hoveredLineId, setHoveredLineId] = useState(null) + + // ── Badge popover state ── + const [popover, setPopover] = useState(null) + + // ── DOM refs ── + const containerRef = useRef(null) + const leftPanelRef = useRef(null) + const rightPanelRef = useRef(null) + + // Stable refs for values used inside recalculateLines (avoids stale closures) + const pendingRef = useRef(pendingAssociations) + pendingRef.current = pendingAssociations + const expandedTargetIdRef = useRef(expandedTargetId) + expandedTargetIdRef.current = expandedTargetId + + const leftParentMapRef = useRef>(new Map()) + const rightParentMapRef = useRef>(new Map()) + + // ── Tree data ── + + const leftRoots = useMemo( + () => buildFrameworkTree(cfItems, frameworkEdges, rootItemIds), + [cfItems, frameworkEdges, rootItemIds], + ) + + // Self-alignment target: when the expanded target IS the framework being edited, the right + // panel must mirror the live editor state (not the stale snapshot in availableFrameworks), + // otherwise it would show a copy missing any unsaved edits made in this session. + const isSelfTarget = expandedTargetId !== null && expandedTargetId === activeFrameworkId + + const expandedTargetFramework = useMemo( + () => (expandedTargetId ? availableFrameworks.find((f) => f.id === expandedTargetId) ?? null : null), + [expandedTargetId, availableFrameworks], + ) + + const targetCfItems = useMemo( + () => (isSelfTarget ? cfItems : expandedTargetFramework ? domainFrameworkToCfItems(expandedTargetFramework.framework) : []), + [isSelfTarget, cfItems, expandedTargetFramework], + ) + + const rightRoots = useMemo(() => { + if (isSelfTarget) return leftRoots + if (!expandedTargetFramework) return [] + const edges = domainFrameworkToEdges(expandedTargetFramework.framework) + const roots = domainFrameworkToRootIds(expandedTargetFramework.framework) + return buildFrameworkTree(targetCfItems, edges, roots) + }, [isSelfTarget, leftRoots, expandedTargetFramework, targetCfItems]) + + // Keep parent maps current so recalculateLines can walk the tree without stale closure issues + const leftParentMap = useMemo(() => buildParentMap(leftRoots), [leftRoots]) + const rightParentMap = useMemo(() => buildParentMap(rightRoots), [rightRoots]) + leftParentMapRef.current = leftParentMap + rightParentMapRef.current = rightParentMap + + // The active framework is included so users can author intra-framework (self) alignments. + const selectableFrameworks = useMemo( + () => availableFrameworks.filter((f) => f.cfDocument?.frameworkType !== 'Alignment'), + [availableFrameworks], + ) + + // Server-side frameworks not yet loaded locally — shown alongside loaded ones in the target selector + const selectableServerFrameworks = useMemo(() => { + const loadedIds = new Set(availableFrameworks.map((f) => f.id)) + return serverFrameworks.filter((f) => !loadedIds.has(f.id)) + }, [serverFrameworks, availableFrameworks]) + + // Associated Frameworks list — the active framework always pinned first, then alphabetical by title. + const sortedTargets = useMemo(() => { + const titleFor = (id: string) => availableFrameworks.find((f) => f.id === id)?.cfDocument.title ?? id + return targets.slice().sort((a, b) => { + if (a.id === activeFrameworkId) return -1 + if (b.id === activeFrameworkId) return 1 + return titleFor(a.id).localeCompare(titleFor(b.id)) + }) + }, [targets, availableFrameworks, activeFrameworkId]) + + // ── Modal: which frameworks can still be added ── + + const targetIds = useMemo(() => new Set(targets.map((t) => t.id)), [targets]) + + const addableLocalFrameworks = useMemo( + () => + selectableFrameworks + .filter((f) => !targetIds.has(f.id)) + .slice() + .sort((a, b) => (a.id === activeFrameworkId ? -1 : b.id === activeFrameworkId ? 1 : 0)), + [selectableFrameworks, targetIds, activeFrameworkId], + ) + + const addableServerFrameworks = useMemo( + () => selectableServerFrameworks.filter((f) => !targetIds.has(f.id)), + [selectableServerFrameworks, targetIds], + ) + + // ── Association counts for badges (scoped to expanded target) ── + + const leftAssociationCounts = useMemo(() => { + const counts = new Map() + for (const a of pendingAssociations) { + if (a.toFrameworkId === expandedTargetId) { + counts.set(a.fromItemId, (counts.get(a.fromItemId) ?? 0) + 1) + } + } + return counts + }, [pendingAssociations, expandedTargetId]) + + const rightAssociationCounts = useMemo(() => { + const counts = new Map() + for (const a of pendingAssociations) { + if (a.toFrameworkId === expandedTargetId) { + counts.set(a.toItemId, (counts.get(a.toItemId) ?? 0) + 1) + } + } + return counts + }, [pendingAssociations, expandedTargetId]) + + // ── SVG line calculation ── + + const recalculateLines = useCallback(() => { + const container = containerRef.current + const leftPanel = leftPanelRef.current + const rightPanel = rightPanelRef.current + if (!container || !leftPanel || !rightPanel) { + setLineCoords([]) + return + } + + const containerRect = container.getBoundingClientRect() + const leftScrollEl = leftPanel.querySelector('[data-scroll-container]') + const rightScrollEl = rightPanel.querySelector('[data-scroll-container]') + + const computed = pendingRef.current + .filter((assoc) => assoc.toFrameworkId === expandedTargetIdRef.current) + .flatMap((assoc) => { + // Use nearest visible ancestor if the exact item is collapsed inside a parent + const leftHit = findNearestVisible(assoc.fromItemId, leftParentMapRef.current, leftPanel) + const rightHit = findNearestVisible(assoc.toItemId, rightParentMapRef.current, rightPanel) + if (!leftHit || !rightHit) return [] + + const fromEl = leftPanel.querySelector(`[data-item-id="${leftHit.id}"]`) + const toEl = rightPanel.querySelector(`[data-item-id="${rightHit.id}"]`) + if (!fromEl || !toEl) return [] + + const fromRect = fromEl.getBoundingClientRect() + const toRect = toEl.getBoundingClientRect() + + // Skip if the rendered endpoint is scrolled outside its panel viewport + if (leftScrollEl) { + const r = leftScrollEl.getBoundingClientRect() + if (fromRect.bottom < r.top || fromRect.top > r.bottom) return [] + } + if (rightScrollEl) { + const r = rightScrollEl.getBoundingClientRect() + if (toRect.bottom < r.top || toRect.top > r.bottom) return [] + } + + const x1 = fromRect.right - containerRect.left + const y1 = fromRect.top + fromRect.height / 2 - containerRect.top + const x2 = toRect.left - containerRect.left + const y2 = toRect.top + toRect.height / 2 - containerRect.top + + const cpOffset = Math.min(100, Math.abs(x2 - x1) * 0.45) + const d = `M ${x1} ${y1} C ${x1 + cpOffset} ${y1}, ${x2 - cpOffset} ${y2}, ${x2} ${y2}` + + return [{ id: assoc.id, d, midX: (x1 + x2) / 2, midY: (y1 + y2) / 2, isDashed: !(leftHit.isExact && rightHit.isExact) }] + }) + + setLineCoords(computed) + }, []) + + // Recalculate whenever associations, target, or expansion state changes. + // Expansion changes alter which items are in the DOM, so lines must be redrawn + // (collapsed items fall back to their nearest visible ancestor). + // targetFramework?.id catches the null→resolved transition for server-only frameworks. + useEffect(() => { + const id = requestAnimationFrame(recalculateLines) + return () => cancelAnimationFrame(id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pendingAssociations, expandedTargetId, expandedTargetFramework?.id ?? null, leftExpandedIds, rightExpandedIds, recalculateLines]) + + // Recalculate on scroll and resize + useEffect(() => { + const leftScroll = leftPanelRef.current?.querySelector('[data-scroll-container]') + const rightScroll = rightPanelRef.current?.querySelector('[data-scroll-container]') + const container = containerRef.current + + const listener = () => requestAnimationFrame(recalculateLines) + + leftScroll?.addEventListener('scroll', listener, { passive: true }) + rightScroll?.addEventListener('scroll', listener, { passive: true }) + globalThis.addEventListener('resize', listener, { passive: true }) + + const obs = new ResizeObserver(listener) + if (container) obs.observe(container) + + return () => { + leftScroll?.removeEventListener('scroll', listener) + rightScroll?.removeEventListener('scroll', listener) + globalThis.removeEventListener('resize', listener) + obs.disconnect() + } + }, [recalculateLines, expandedTargetId]) + + // Close popover on outside click + useEffect(() => { + if (!popover) return + const onDown = () => setPopover(null) + document.addEventListener('mousedown', onDown) + return () => document.removeEventListener('mousedown', onDown) + }, [popover]) + + // Pre-populate the target list with frameworks that already have saved alignment docs, + // then eagerly load their associations so counts are visible without expanding. + useEffect(() => { + if (!activeFrameworkId || !onDiscoverAlignedTargets) return + void (async () => { + const discovered = await onDiscoverAlignedTargets(activeFrameworkId) + if (!discovered.length) return + + // Self-alignments (targetId === activeFrameworkId) are legitimate and kept. + const newEntries = discovered + + setTargets((prev) => { + const existingIds = new Set(prev.map((t) => t.id)) + const toAdd = newEntries + .filter((d) => !existingIds.has(d.targetId)) + .map((d) => ({ id: d.targetId, alignmentDocId: d.alignmentDocId, hasUnsavedChanges: false })) + return toAdd.length > 0 ? [...prev, ...toAdd] : prev + }) + + if (!onLoadAlignmentsForTarget) return + + const targetIds = newEntries.map((e) => e.targetId) + setLoadingTargetIds((prev) => new Set([...prev, ...targetIds])) + + await Promise.allSettled( + newEntries.map(async ({ targetId }) => { + try { + const { docId, associations } = await onLoadAlignmentsForTarget(targetId) + setTargets((prev) => + prev.map((t) => (t.id === targetId ? { ...t, alignmentDocId: docId } : t)), + ) + setPendingAssociations((prev) => [ + ...prev.filter((a) => a.toFrameworkId !== targetId), + ...(associations as PendingAssociation[]), + ]) + setLoadedTargetIds((prev) => new Set([...prev, targetId])) + } finally { + setLoadingTargetIds((prev) => { + const next = new Set(prev) + next.delete(targetId) + return next + }) + } + }), + ) + })() + }, [activeFrameworkId, onDiscoverAlignedTargets, onLoadAlignmentsForTarget]) + + // When expanding a pre-populated target whose framework data isn't loaded yet, fetch it + useEffect(() => { + if (!expandedTargetId || !onLoadTargetFramework) return + const isLoaded = availableFrameworks.some((f) => f.id === expandedTargetId) + if (isLoaded) return + const id = expandedTargetId + setLoadingTargetIds((prev) => new Set([...prev, id])) + void onLoadTargetFramework(id).finally(() => { + setLoadingTargetIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [expandedTargetId, onLoadTargetFramework]) + // availableFrameworks intentionally omitted — only needs to run when the expanded target changes + + // Load associations when a target is expanded for the first time + useEffect(() => { + if (!expandedTargetId || loadedTargetIds.has(expandedTargetId) || !onLoadAlignmentsForTarget) return + const id = expandedTargetId + setLoadingTargetIds((prev) => new Set([...prev, id])) + void onLoadAlignmentsForTarget(id) + .then(({ docId, associations }) => { + setTargets((prev) => prev.map((t) => (t.id === id ? { ...t, alignmentDocId: docId } : t))) + setPendingAssociations((prev) => [ + ...prev.filter((a) => a.toFrameworkId !== id), + ...(associations as PendingAssociation[]), + ]) + setLoadedTargetIds((prev) => new Set([...prev, id])) + }) + .catch(() => { /* keep existing state */ }) + .finally(() => { + setLoadingTargetIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + }) + }, [expandedTargetId, loadedTargetIds, onLoadAlignmentsForTarget]) + + // ── Mutation helpers ── + + const removePendingAssociation = useCallback((id: string) => { + setPendingAssociations((prev) => { + const assoc = prev.find((a) => a.id === id) + if (assoc) { + setTargets((ts) => ts.map((t) => (t.id === assoc.toFrameworkId ? { ...t, hasUnsavedChanges: true } : t))) + } + return prev.filter((a) => a.id !== id) + }) + }, []) + + // ── Drag handlers ── + + const handleSelect = (id: string) => { + const deselects = nodes + .filter((n) => n.selected && n.id !== id) + .map((n) => ({ type: 'select' as const, id: n.id, selected: false })) + onNodesChange([...deselects, { type: 'select' as const, id, selected: true }]) + } + + const handleRightDragOver = (id: string) => setDragOverItemId(id) + const handleRightDragLeave = () => setDragOverItemId(null) + + const handleRightDrop = (toItemId: string, e: React.DragEvent) => { + const fromItemId = e.dataTransfer.getData('text/plain') + if (!fromItemId || !expandedTargetId) return + if (fromItemId === toItemId) { + // An item can't be aligned to itself. + setDragOverItemId(null) + return + } + + // Resolve canonical URIs from the loaded CFItem data so they survive round-trips, + // including the case where the destination framework is on a different server. + const fromCfItem = cfItems.find((i) => i.identifier === fromItemId) + const toCfItem = targetCfItems.find((i) => i.identifier === toItemId) + const originUri = fromCfItem?.uri ?? `urn:case:item:${fromItemId}` + const destinationUri = toCfItem?.uri ?? `urn:case:item:${toItemId}` + + setPendingAssociations((prev) => [ + ...prev, + { + id: globalThis.crypto?.randomUUID?.() ?? `assoc-${Date.now()}`, + fromItemId, + toItemId, + toFrameworkId: expandedTargetId, + associationType: 'isRelatedTo', + originUri, + destinationUri, + }, + ]) + setTargets((prev) => prev.map((t) => (t.id === expandedTargetId ? { ...t, hasUnsavedChanges: true } : t))) + setDragOverItemId(null) + } + + const handleAssociationTypeChange = useCallback((id: string, newType: string) => { + setPendingAssociations((prev) => { + const assoc = prev.find((a) => a.id === id) + if (assoc) { + setTargets((ts) => ts.map((t) => (t.id === assoc.toFrameworkId ? { ...t, hasUnsavedChanges: true } : t))) + } + return prev.map((a) => (a.id === id ? { ...a, associationType: newType } : a)) + }) + }, []) + + // Clicking a solid line collapses both panels by one level (inverse of dashed-line click). + const handleSolidLineClick = useCallback((assocId: string) => { + const assoc = pendingRef.current.find((a) => a.id === assocId) + if (!assoc) return + const leftParentId = leftParentMapRef.current.get(assoc.fromItemId) + const rightParentId = rightParentMapRef.current.get(assoc.toItemId) + if (leftParentId !== null && leftParentId !== undefined) { + setLeftExpandedIds((prev) => { const next = new Set(prev); next.delete(leftParentId); return next }) + } + if (rightParentId !== null && rightParentId !== undefined) { + setRightExpandedIds((prev) => { const next = new Set(prev); next.delete(rightParentId); return next }) + } + }, []) + + // Clicking a dashed line expands both panels to reveal the actual aligned items. + const handleDashedLineClick = useCallback((assocId: string) => { + const assoc = pendingRef.current.find((a) => a.id === assocId) + if (!assoc) return + const leftAncestors = ancestorsFromParentMap(assoc.fromItemId, leftParentMapRef.current) + const rightAncestors = ancestorsFromParentMap(assoc.toItemId, rightParentMapRef.current) + if (leftAncestors.length > 0) { + setLeftExpandedIds((prev) => { + const next = new Set(prev) + leftAncestors.forEach((id) => next.add(id)) + return next + }) + } + if (rightAncestors.length > 0) { + setRightExpandedIds((prev) => { + const next = new Set(prev) + rightAncestors.forEach((id) => next.add(id)) + return next + }) + } + }, []) + + // ── Badge popover handlers ── + + const handleLeftBadgeClick = useCallback((itemId: string, e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + setPopover({ itemId, side: 'left', x: rect.right + 8, y: rect.top }) + }, []) + + const handleRightBadgeClick = useCallback((itemId: string, e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + setPopover({ itemId, side: 'right', x: rect.left - 8, y: rect.top }) + }, []) + + // Associations relevant to the open popover + const popoverAssociations = useMemo(() => { + if (!popover) return [] + return pendingAssociations.filter((a) => + popover.side === 'left' ? a.fromItemId === popover.itemId : a.toItemId === popover.itemId, + ) + }, [popover, pendingAssociations]) + + // ── Save alignments ── + + const handleSaveAlignments = useCallback(async () => { + const targetId = expandedTargetId + if (!targetId || !expandedTargetFramework || !onSaveAlignments) return + const target = targets.find((t) => t.id === targetId) + if (!target?.hasUnsavedChanges) return + + setSavingTargetId(targetId) + setSaveError(null) + try { + const now = new Date().toISOString() + const sourceDocId = activeFrameworkId ?? '' + const targetDocId = targetId + // Both frameworks are stored on this server — use their canonical server URIs so + // participants are referenced consistently regardless of import origin. + const sourceDocUri = `/ims/case/v1p1/CFDocuments/${sourceDocId}` + const targetDocUri = `/ims/case/v1p1/CFDocuments/${targetDocId}` + const sourceTitle = frameworkInfo.title + const targetTitle = isSelfTarget ? sourceTitle : (expandedTargetFramework.cfDocument.title ?? targetDocId) + + const targetAssociations = pendingAssociations.filter((a) => a.toFrameworkId === targetId) + + const cfPackage = { + CFDocument: { + identifier: target.alignmentDocId, + uri: `/ims/case/v1p1/CFDocuments/${target.alignmentDocId}`, + title: isSelfTarget ? `Internal Alignment: ${sourceTitle}` : `Alignment: ${sourceTitle} → ${targetTitle}`, + creator: cfDocument?.creator ?? 'OpenCASE', + frameworkType: 'Alignment', + lastChangeDateTime: now, + extensions: { + 'ext:opencase': { + alignmentParticipants: [ + { identifier: sourceDocId, uri: sourceDocUri }, + { identifier: targetDocId, uri: targetDocUri }, + ], + }, + }, + }, + CFItems: [], + CFAssociations: targetAssociations.map((a) => { + const fromItem = cfItems.find((i) => i.identifier === a.fromItemId) + const toItem = targetCfItems.find((i) => i.identifier === a.toItemId) + const fromTitle = (fromItem?.humanCodingScheme ?? fromItem?.abbreviatedStatement ?? fromItem?.fullStatement ?? a.fromItemId).slice(0, 200) + const toTitle = (toItem?.humanCodingScheme ?? toItem?.abbreviatedStatement ?? toItem?.fullStatement ?? a.toItemId).slice(0, 200) + return { + identifier: a.id, + uri: `/ims/case/v1p1/CFAssociations/${a.id}`, + associationType: a.associationType, + originNodeURI: { title: fromTitle, identifier: a.fromItemId, uri: a.originUri }, + destinationNodeURI: { title: toTitle, identifier: a.toItemId, uri: a.destinationUri }, + lastChangeDateTime: now, + } + }), + } + + await onSaveAlignments(cfPackage) + + // Reload from server to keep associations current after save. + if (onLoadAlignmentsForTarget) { + try { + const { docId, associations } = await onLoadAlignmentsForTarget(targetId) + setTargets((prev) => + prev.map((t) => (t.id === targetId ? { ...t, alignmentDocId: docId, hasUnsavedChanges: false } : t)), + ) + setPendingAssociations((prev) => [ + ...prev.filter((a) => a.toFrameworkId !== targetId), + ...(associations as PendingAssociation[]), + ]) + } catch { + setTargets((prev) => + prev.map((t) => (t.id === targetId ? { ...t, hasUnsavedChanges: false } : t)), + ) + } + } else { + setTargets((prev) => + prev.map((t) => (t.id === targetId ? { ...t, hasUnsavedChanges: false } : t)), + ) + } + } catch (err: unknown) { + setSaveError(err instanceof Error ? err.message : 'Save failed') + } finally { + setSavingTargetId(null) + } + }, [expandedTargetId, expandedTargetFramework, isSelfTarget, onSaveAlignments, targets, pendingAssociations, activeFrameworkId, cfDocument, frameworkInfo, cfItems, targetCfItems, onLoadAlignmentsForTarget]) + + // ── Target accordion management ── + + const handleExpandTarget = useCallback((id: string) => { + if (id === expandedTargetId) { + // Clicking the same accordion entry collapses it + setExpandedTargetId(null) + return + } + const currentTarget = targets.find((t) => t.id === expandedTargetId) + if (currentTarget?.hasUnsavedChanges) { + if (!window.confirm('You have unsaved alignment changes. Discard them and switch?')) return + setPendingAssociations((prev) => prev.filter((a) => a.toFrameworkId !== expandedTargetId)) + setTargets((prev) => + prev.map((t) => (t.id === expandedTargetId ? { ...t, hasUnsavedChanges: false } : t)), + ) + } + setExpandedTargetId(id) + setRightExpandedIds(new Set()) + setPopover(null) + setDragOverItemId(null) + }, [expandedTargetId, targets]) + + const handleAddTarget = useCallback(async (id: string) => { + const isLoaded = availableFrameworks.some((f) => f.id === id) + if (!isLoaded && onLoadTargetFramework) { + setLoadingTargetIds((prev) => new Set([...prev, id])) + try { + await onLoadTargetFramework(id) + } finally { + setLoadingTargetIds((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + } + } + setTargets((prev) => { + if (prev.some((t) => t.id === id)) return prev + const newDocId = globalThis.crypto?.randomUUID?.() ?? `align-${Date.now()}` + return [...prev, { id, alignmentDocId: newDocId, hasUnsavedChanges: false }] + }) + setIsAddModalOpen(false) + handleExpandTarget(id) + }, [availableFrameworks, onLoadTargetFramework, handleExpandTarget]) + + // ── Layout ── + + const panelHeight = 'h-[calc(100vh-128px)]' + const panelClass = `${panelHeight} flex w-full max-w-[720px] flex-col overflow-hidden rounded-2xl border border-black/10 bg-white shadow-sm` + + return ( +
setDragOverItemId(null)} + > + {/* Two-panel row — SVG overlay lives inside this relative container */} +
+ {/* SVG association lines overlay */} + {lineCoords.length > 0 && ( + + {lineCoords.map((line) => { + const isHovered = hoveredLineId === line.id + return ( + + {/* Transparent hit area — solid lines collapse one level, dashed lines expand to reveal items */} + setHoveredLineId(line.id)} + onMouseLeave={() => setHoveredLineId(null)} + onClick={() => line.isDashed + ? handleDashedLineClick(line.id) + : handleSolidLineClick(line.id) + } + /> + {/* Visible stroke: solid when both items are visible, dashed when proxied via ancestor */} + + + ) + })} + + )} + + {/* Left panel — source framework */} +
+
+
+ { /* cursor hint only */ }} + associationCounts={leftAssociationCounts} + onBadgeClick={handleLeftBadgeClick} + /> +
+ {/* Gate callout — shown only when a target is expanded but source hasn't been saved */} + {expandedTargetFramework && !isSourcePublished && ( +
+

+ Save this framework first to enable alignment authoring. + Items need stable server-assigned URIs before associations can be created. +

+
+ )} +
+ +
+
+
+ + {/* Right panel — accordion list of target frameworks */} +
+
+
+

Associated Frameworks

+
+ +
+ {targets.length === 0 ? ( +
+

No target frameworks

+

+ Add a framework below, then drag items from the left panel onto items here to + create crosswalk associations. +

+
+ ) : ( + sortedTargets.map((target) => { + const fw = availableFrameworks.find((f) => f.id === target.id) + const isExpanded = target.id === expandedTargetId + const targetAssocCount = pendingAssociations.filter((a) => a.toFrameworkId === target.id).length + const isLoading = loadingTargetIds.has(target.id) + const isSelf = target.id === activeFrameworkId + + return ( +
+ + + {isExpanded && fw && ( +
+
+ +
+ {isSourcePublished && onSaveAlignments && target.hasUnsavedChanges && ( +
+ + {targetAssocCount > 0 + ? `${targetAssocCount} association${targetAssocCount !== 1 ? 's' : ''} — unsaved` + : 'Unsaved changes'} + +
+ {saveError && ( + + {saveError} + + )} + +
+
+ )} +
+ )} +
+ ) + }) + )} +
+ +
+ +
+
+
+
+ + {/* Badge popover — fixed-position, rendered outside the scroll container */} + {popover && popoverAssociations.length > 0 && ( +
e.stopPropagation()} + > +

+ {popoverAssociations.length} association{popoverAssociations.length !== 1 ? 's' : ''} +

+ {popoverAssociations.map((assoc) => { + const connectedItem = + popover.side === 'left' + ? targetCfItems.find((i) => i.identifier === assoc.toItemId) + : cfItems.find((i) => i.identifier === assoc.fromItemId) + const label = + connectedItem?.humanCodingScheme ?? + connectedItem?.abbreviatedStatement ?? + connectedItem?.fullStatement ?? + (popover.side === 'left' ? assoc.toItemId : assoc.fromItemId) + return ( +
+ + + {label} + + +
+ ) + })} +
+ )} + + {/* Add framework modal */} + {isAddModalOpen && ( +
setIsAddModalOpen(false)} + > +
e.stopPropagation()} + > +
+

Add Target Framework

+

+ Select a framework to add to the crosswalk panel. +

+
+
+ {addableLocalFrameworks.length === 0 && addableServerFrameworks.length === 0 ? ( +

+ All available frameworks have already been added. +

+ ) : ( + <> + {addableLocalFrameworks.map((fw) => { + const isSelf = fw.id === activeFrameworkId + return ( + + ) + })} + {addableServerFrameworks.length > 0 && addableLocalFrameworks.length > 0 && ( +
+ )} + {addableServerFrameworks.map((fw) => ( + + ))} + + )} +
+
+ +
+
+
+ )} + +
+ ) +} diff --git a/apps/editor/src/ui/editor/treePanel/buildFrameworkTree.test.ts b/apps/editor/src/ui/editor/treePanel/buildFrameworkTree.test.ts new file mode 100644 index 0000000..25992f4 --- /dev/null +++ b/apps/editor/src/ui/editor/treePanel/buildFrameworkTree.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest' +import { buildFrameworkTree } from '@/domain/framework/treeDerivation' +import type { CFItem } from '@/domain/case/types' +import type { FrameworkEdgeRecord } from '@/domain/framework/treeDerivation' + +function makeItem(id: string, overrides: Partial = {}): CFItem { + return { + identifier: id, + uri: `urn:case:item:${id}`, + fullStatement: `Statement for ${id}`, + lastChangeDateTime: '2025-01-01T00:00:00Z', + ...overrides, + } +} + +function edge(parentId: string, childId: string, seq?: number): FrameworkEdgeRecord { + return { parentId, childId, sequenceNumber: seq } +} + +describe('buildFrameworkTree', () => { + it('returns empty array when cfItems is empty', () => { + expect(buildFrameworkTree([], [], [])).toEqual([]) + }) + + it('returns empty array when rootItemIds is empty', () => { + const items = [makeItem('a')] + expect(buildFrameworkTree(items, [], [])).toEqual([]) + }) + + it('returns root items for a flat list', () => { + const items = [makeItem('a'), makeItem('b'), makeItem('c')] + const tree = buildFrameworkTree(items, [], ['a', 'b', 'c']) + expect(tree.map((n) => n.id)).toEqual(['a', 'b', 'c']) + }) + + it('root nodes have depth 0', () => { + const items = [makeItem('a'), makeItem('b')] + const tree = buildFrameworkTree(items, [], ['a', 'b']) + for (const node of tree) expect(node.depth).toBe(0) + }) + + it('root nodes have no children when no edges', () => { + const items = [makeItem('a'), makeItem('b')] + const tree = buildFrameworkTree(items, [], ['a', 'b']) + for (const node of tree) expect(node.children).toHaveLength(0) + }) + + it('builds nested children', () => { + const items = [makeItem('a'), makeItem('b'), makeItem('c')] + const edges = [edge('a', 'b'), edge('b', 'c')] + const tree = buildFrameworkTree(items, edges, ['a']) + expect(tree).toHaveLength(1) + expect(tree[0].id).toBe('a') + expect(tree[0].children).toHaveLength(1) + expect(tree[0].children[0].id).toBe('b') + expect(tree[0].children[0].depth).toBe(1) + expect(tree[0].children[0].children[0].id).toBe('c') + expect(tree[0].children[0].children[0].depth).toBe(2) + }) + + it('includes cfItem data on each node', () => { + const items = [makeItem('a', { humanCodingScheme: 'A.1' })] + const tree = buildFrameworkTree(items, [], ['a']) + expect(tree[0].cfItem.humanCodingScheme).toBe('A.1') + expect(tree[0].cfItem.fullStatement).toBe('Statement for a') + }) + + it('respects the order of rootItemIds', () => { + const items = [makeItem('a'), makeItem('b'), makeItem('c')] + const tree = buildFrameworkTree(items, [], ['c', 'a', 'b']) + expect(tree.map((n) => n.id)).toEqual(['c', 'a', 'b']) + }) + + it('sorts children by sequence number', () => { + const items = [makeItem('parent'), makeItem('x'), makeItem('y'), makeItem('z')] + const edges = [edge('parent', 'z', 1), edge('parent', 'x', 2), edge('parent', 'y', 3)] + const tree = buildFrameworkTree(items, edges, ['parent']) + expect(tree[0].children.map((n) => n.id)).toEqual(['z', 'x', 'y']) + }) + + it('places children without sequence number after sequenced ones', () => { + const items = [makeItem('parent'), makeItem('a'), makeItem('b')] + const edges = [edge('parent', 'b'), edge('parent', 'a', 1)] + const tree = buildFrameworkTree(items, edges, ['parent']) + expect(tree[0].children.map((n) => n.id)).toEqual(['a', 'b']) + }) + + it('skips unknown item IDs silently', () => { + const items = [makeItem('a')] + const tree = buildFrameworkTree(items, [], ['a', 'unknown']) + expect(tree.map((n) => n.id)).toEqual(['a']) + }) +}) diff --git a/apps/editor/src/ui/home/HomeScreen.tsx b/apps/editor/src/ui/home/HomeScreen.tsx index c0bc7aa..2082f9b 100644 --- a/apps/editor/src/ui/home/HomeScreen.tsx +++ b/apps/editor/src/ui/home/HomeScreen.tsx @@ -399,7 +399,7 @@ export default function HomeScreen({ const filteredServerFrameworks = useMemo( () => serverFrameworks.filter((doc) => { - return matchesSearch(doc.title, doc.creator, doc.description) && matchesFilters(doc.adoptionStatus, doc.frameworkType) + return doc.frameworkType !== 'Alignment' && matchesSearch(doc.title, doc.creator, doc.description) && matchesFilters(doc.adoptionStatus, doc.frameworkType) }), // eslint-disable-next-line react-hooks/exhaustive-deps [serverFrameworks, searchQuery, statusFilter, typeFilter], @@ -409,7 +409,7 @@ export default function HomeScreen({ const allFrameworkTypes = useMemo(() => { const types = new Set() visibleDrafts.forEach((d) => { if (d.cfDocument.frameworkType) types.add(d.cfDocument.frameworkType) }) - serverFrameworks.forEach((d) => { if (d.frameworkType) types.add(d.frameworkType) }) + serverFrameworks.forEach((d) => { if (d.frameworkType && d.frameworkType !== 'Alignment') types.add(d.frameworkType) }) return Array.from(types).sort((a, b) => a.localeCompare(b)) }, [visibleDrafts, serverFrameworks]) diff --git a/apps/editor/src/ui/shared/components/ui/badge.tsx b/apps/editor/src/ui/shared/components/ui/badge.tsx new file mode 100644 index 0000000..c3b9bec --- /dev/null +++ b/apps/editor/src/ui/shared/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-1.5 py-0.5 text-xs font-medium transition-colors', + { + variants: { + variant: { + default: 'border-transparent bg-[#662F90] text-white', + secondary: 'border-transparent bg-slate-100 text-slate-700', + destructive: 'border-transparent bg-red-100 text-red-700', + outline: 'border-slate-200 bg-transparent text-slate-600', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +type BadgeProps = React.HTMLAttributes & VariantProps + +function Badge({ className, variant, ...props }: BadgeProps) { + return +} + +export { Badge, badgeVariants } diff --git a/apps/editor/src/ui/shared/components/ui/combobox-input.tsx b/apps/editor/src/ui/shared/components/ui/combobox-input.tsx index 64128e9..bcf81f0 100644 --- a/apps/editor/src/ui/shared/components/ui/combobox-input.tsx +++ b/apps/editor/src/ui/shared/components/ui/combobox-input.tsx @@ -123,7 +123,7 @@ export function ComboboxInput({ value, onChange, onCommit, options, placeholder, } return ( -
+
{filtered.map((opt, idx) => { const selected = opt.value === value diff --git a/apps/editor/src/ui/shared/components/ui/tag-combobox-input.tsx b/apps/editor/src/ui/shared/components/ui/tag-combobox-input.tsx index 86a23db..9a2a1b3 100644 --- a/apps/editor/src/ui/shared/components/ui/tag-combobox-input.tsx +++ b/apps/editor/src/ui/shared/components/ui/tag-combobox-input.tsx @@ -155,7 +155,7 @@ export function TagComboboxInput({ } return ( -
+
{/* Tags + input area */}
{filtered.map((opt, idx) => { const highlighted = idx === highlightIdx diff --git a/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts b/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts index c5ca5ac..d799308 100644 --- a/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts +++ b/apps/opencase/src/application/case/endpoints/GetCFItemAssociations.ts @@ -35,21 +35,45 @@ export class GetCFItemAssociations { const item = pkg.items.find(i => i.sourcedId === query.sourcedId) if (!item) return null - // Find all associations where this item is the origin or destination - const associations = pkg.associations.filter(a => { - const assocJSON = a.toJSON() - const originURI = assocJSON.originNodeURI - const destURI = assocJSON.destinationNodeURI - const originId = typeof originURI === 'string' ? originURI : originURI?.identifier - const destId = typeof destURI === 'string' ? destURI : destURI?.identifier + const matchesItem = (a: { toJSON: (v?: any) => any }) => { + const j = a.toJSON() + const originId = typeof j.originNodeURI === 'string' ? j.originNodeURI : j.originNodeURI?.identifier + const destId = typeof j.destinationNodeURI === 'string' ? j.destinationNodeURI : j.destinationNodeURI?.identifier return originId === query.sourcedId || destId === query.sourcedId - }) + } + + // Intra-framework associations (within the item's own package) + const ownAssociations = pkg.associations.filter(matchesItem) + + // Cross-framework associations from alignment packages that reference this framework + const alignmentDocs = this.store.getAllDocuments(query.tenantId, storageVersion).filter( + meta => + meta.frameworkType === 'Alignment' && + !meta.archived && + meta.alignmentParticipants?.some(p => p.identifier === pkg.document.sourcedId) + ) + + const crossAssociations = ( + await Promise.all( + alignmentDocs.map(async meta => { + try { + const alignStorageKey = this.store.resolveStorageKey(query.tenantId, storageVersion, meta.sourcedId) + if (!alignStorageKey) return [] + const alignPkg = await this.pkgRepo.load(query.tenantId, storageVersion, alignStorageKey) + return alignPkg?.associations.filter(matchesItem) ?? [] + } catch (err) { + logger.warn({ err, alignmentDocId: meta.sourcedId }, 'Failed to load alignment package for cross-framework associations') + return [] + } + }) + ) + ).flat() // Pass caseVersion to toJSON for correct field stripping when downconverting const serializeAs = query.loadVersion ? query.caseVersion : undefined return { CFItem: item.toJSON(serializeAs), - CFAssociations: associations.map(a => a.toJSON(serializeAs)) + CFAssociations: [...ownAssociations, ...crossAssociations].map(a => a.toJSON(serializeAs)) } } } diff --git a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts index 5122a66..8191e76 100644 --- a/apps/opencase/src/application/case/endpoints/ListFrameworks.ts +++ b/apps/opencase/src/application/case/endpoints/ListFrameworks.ts @@ -6,6 +6,10 @@ export interface ListFrameworksQuery { tenantId: TenantId caseVersion?: CaseVersion includeArchived?: boolean + /** When set, only frameworks with this frameworkType are returned */ + frameworkType?: string + /** When set, only alignment frameworks listing this docId as a participant are returned */ + participantId?: string } export class ListFrameworks { @@ -24,18 +28,19 @@ export class ListFrameworks { subject?: string version?: string lastChangeDateTime: string + alignmentParticipants?: Array<{ identifier?: string; uri: string }> }> = [] for (const version of versions) { const documents = this.store.getAllDocuments(query.tenantId, version) for (const doc of documents) { - // Filter server-level archived documents unless includeArchived is true - if (!query.includeArchived) { - if (doc.archived === true) { - continue // Skip archived documents - } + if (!query.includeArchived && doc.archived === true) continue + if (query.frameworkType && doc.frameworkType !== query.frameworkType) continue + if (query.participantId) { + const participates = doc.alignmentParticipants?.some(p => p.identifier === query.participantId) + if (!participates) continue } - + frameworks.push({ sourcedId: doc.sourcedId, title: doc.title, @@ -44,7 +49,8 @@ export class ListFrameworks { frameworkType: doc.frameworkType, subject: doc.subject, version: doc.version, - lastChangeDateTime: doc.lastChangeDateTime.toISOString() + lastChangeDateTime: doc.lastChangeDateTime.toISOString(), + alignmentParticipants: doc.alignmentParticipants, }) } } diff --git a/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts b/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts index a855316..51dc18f 100644 --- a/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts +++ b/apps/opencase/src/application/case/endpoints/__tests__/GetCFItemAssociations.test.ts @@ -19,7 +19,8 @@ describe('GetCFItemAssociations', () => { mockStore = { getStorageKeyForItem: jest.fn(), - getAllDocuments: jest.fn() + getAllDocuments: jest.fn().mockReturnValue([]), + resolveStorageKey: jest.fn() } as any getCFItemAssociations = new GetCFItemAssociations(mockRepository, mockStore) @@ -135,6 +136,130 @@ describe('GetCFItemAssociations', () => { ]) }) }) + + it('should include cross-framework associations from alignment packages', async () => { + const document = CFDocument.create({ + tenantId, + caseVersion, + sourcedId: docId, + uri: `/ims/case/v1p1/CFDocuments/${docId}`, + creator: 'Test Creator', + title: 'Source Framework', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z') + }) + + const item = CFItem.create({ + tenantId, + caseVersion, + sourcedId: itemId, + uri: `/ims/case/v1p1/CFItems/${itemId}`, + fullStatement: 'Test Statement', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + CFDocumentURI: { title: 'Source Framework', identifier: docId, uri: document.toJSON().uri } + }) + + const pkg = new CFPackage({ document, items: [item], associations: [], rubrics: [] }) + + // Alignment package linking this framework to a target framework + const alignDocId = 'align-doc-1' + const targetDocId = 'target-doc-1' + const alignDocument = CFDocument.create({ + tenantId, + caseVersion, + sourcedId: alignDocId, + uri: `/ims/case/v1p1/CFDocuments/${alignDocId}`, + creator: 'OpenCASE', + title: 'Alignment: Source → Target', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z') + }) + + const crossAssociation = CFAssociation.create({ + tenantId, + caseVersion, + sourcedId: 'cross-assoc-1', + uri: '/ims/case/v1p1/CFAssociations/cross-assoc-1', + associationType: 'exactMatchOf', + originNodeURI: { title: 'Origin', identifier: itemId, uri: `/ims/case/v1p1/CFItems/${itemId}` }, + destinationNodeURI: { title: 'Target Item', identifier: 'target-item-1', uri: '/ims/case/v1p1/CFItems/target-item-1' }, + lastChangeDateTime: new Date('2024-01-01T00:00:00Z') + }) + + const alignPkg = new CFPackage({ document: alignDocument, items: [], associations: [crossAssociation], rubrics: [] }) + + mockStore.getStorageKeyForItem.mockReturnValue(docId) + mockStore.getAllDocuments.mockReturnValue([ + { + sourcedId: alignDocId, + title: 'Alignment: Source → Target', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + currentFile: 'frameworks/align-doc-1/align-doc-1_v0001.json', + frameworkType: 'Alignment', + archived: false, + alignmentParticipants: [ + { identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }, + { identifier: targetDocId, uri: `/ims/case/v1p1/CFDocuments/${targetDocId}` } + ] + } + ]) + mockStore.resolveStorageKey.mockReturnValue(alignDocId) + mockRepository.load + .mockResolvedValueOnce(pkg) // item's own package + .mockResolvedValueOnce(alignPkg) // alignment package + + const result = await getCFItemAssociations.execute({ tenantId, caseVersion, sourcedId: itemId }) + + expect(result).toEqual({ + CFItem: expect.objectContaining({ identifier: itemId }), + CFAssociations: expect.arrayContaining([ + expect.objectContaining({ identifier: 'cross-assoc-1' }) + ]) + }) + expect(mockRepository.load).toHaveBeenCalledTimes(2) + expect(mockRepository.load).toHaveBeenNthCalledWith(2, tenantId, caseVersion, alignDocId) + }) + + it('should not include alignment associations from archived alignment docs', async () => { + const document = CFDocument.create({ + tenantId, + caseVersion, + sourcedId: docId, + uri: `/ims/case/v1p1/CFDocuments/${docId}`, + creator: 'Test Creator', + title: 'Source Framework', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z') + }) + + const item = CFItem.create({ + tenantId, + caseVersion, + sourcedId: itemId, + uri: `/ims/case/v1p1/CFItems/${itemId}`, + fullStatement: 'Test Statement', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + CFDocumentURI: { title: 'Source Framework', identifier: docId, uri: document.toJSON().uri } + }) + + const pkg = new CFPackage({ document, items: [item], associations: [], rubrics: [] }) + + mockStore.getStorageKeyForItem.mockReturnValue(docId) + mockStore.getAllDocuments.mockReturnValue([ + { + sourcedId: 'align-doc-archived', + title: 'Alignment: Archived', + lastChangeDateTime: new Date('2024-01-01T00:00:00Z'), + currentFile: 'frameworks/align-doc-archived/align-doc-archived_v0001.json', + frameworkType: 'Alignment', + archived: true, + alignmentParticipants: [{ identifier: docId, uri: `/ims/case/v1p1/CFDocuments/${docId}` }] + } + ]) + mockRepository.load.mockResolvedValue(pkg) + + const result = await getCFItemAssociations.execute({ tenantId, caseVersion, sourcedId: itemId }) + + expect(result?.CFAssociations).toHaveLength(0) + expect(mockRepository.load).toHaveBeenCalledTimes(1) // only own package, not archived alignment + }) }) }) diff --git a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts index ea42fb9..be8087a 100644 --- a/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts +++ b/apps/opencase/src/infrastructure/persistence/file/FileFrameworkStore.ts @@ -28,6 +28,8 @@ export interface DocumentMetadata { isModifiedFromSource?: boolean /** Server-level archive flag — independent of CASE adoptionStatus */ archived?: boolean + /** Participant frameworks in an alignment document (extracted from ext:opencase.alignmentParticipants) */ + alignmentParticipants?: Array<{ identifier?: string; uri: string }> } export interface DocumentVersionInfo { @@ -175,6 +177,7 @@ export class FileFrameworkStore { sourcePackageURI: d.sourcePackageURI, isModifiedFromSource: d.isModifiedFromSource, archived: d.archived, + alignmentParticipants: d.alignmentParticipants, }) } } catch { @@ -439,6 +442,11 @@ export class FileFrameworkStore { } } + let alignmentParticipants: Array<{ identifier?: string; uri: string }> | undefined + if (extOpencase && typeof extOpencase === 'object' && Array.isArray((extOpencase as any).alignmentParticipants)) { + alignmentParticipants = (extOpencase as any).alignmentParticipants + } + versionMap.set(storageKey, { sourcedId: currentIdentifier, title: doc.title as string, @@ -454,6 +462,7 @@ export class FileFrameworkStore { licenseIdentifier, sourcePackageURI, isModifiedFromSource, + alignmentParticipants, }) } @@ -658,6 +667,7 @@ export class FileFrameworkStore { sourcePackageURI: meta.sourcePackageURI, isModifiedFromSource: meta.isModifiedFromSource, archived: meta.archived, + alignmentParticipants: meta.alignmentParticipants, })) await fs.writeFile( diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts index e90e886..758e961 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts @@ -28,10 +28,11 @@ export class CFPackagesManagementController { return res.status(403).json({ error: 'Tenant mismatch - authenticated tenant does not match URL parameter' }) } - // Extract includeArchived query parameter (default: false) const includeArchived = req.query.includeArchived === 'true' + const frameworkType = typeof req.query.frameworkType === 'string' ? req.query.frameworkType : undefined + const participantId = typeof req.query.participantId === 'string' ? req.query.participantId : undefined - const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived }) + const result = await this.listFrameworks.execute({ tenantId, caseVersion, includeArchived, frameworkType, participantId }) return res.status(200).json(result) } catch (error: any) { return res.status(400).json({ error: error.message || 'List failed' })