diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index b9cc7ab..7148d73 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -5,6 +5,7 @@ import HomeScreen from '@/ui/home/HomeScreen' import { createNewFrameworkDraft, createHomeFrameworkFromDomain, loadFrameworks, saveFrameworks, type HomeFramework } from '@/ui/home/frameworkStore' import type { CreateFrameworkDraft } from '@/ui/home/CreateFrameworkDialog' import type { Framework } from '@/domain/framework/model/types' +import type { ItemId } from '@/domain/shared/types' import { AuthProvider, useAuth } from '@/app/providers/AuthProvider' import { getAppConfig } from '@/app/config' import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient' @@ -657,6 +658,38 @@ function AppInner() { [api, tenantId], ) + // Frameworks already in the session, read through a ref so the resolver below keeps a stable identity. + const frameworksRef = useRef(frameworks) + frameworksRef.current = frameworks + + // Which framework holds each item: frameworks already in the session first, then the server + // (an item's CFDocumentURI). Used to place inline associations to other frameworks in the tree view. + const handleResolveItemFrameworks = useCallback( + async (itemIds: string[]): Promise> => { + const result: Record = {} + const queue: string[] = [] + for (const id of itemIds) { + const local = frameworksRef.current.find((f) => f.framework.items.has(id as ItemId)) + if (local) result[id] = local.id + else queue.push(id) + } + + const worker = async () => { + for (let id = queue.shift(); id !== undefined; id = queue.shift()) { + try { + const docId = await api.getCfItemDocumentId({ itemId: id, caseVersion: caseApiVersion }) + if (docId) result[id] = docId + } catch (err) { + console.warn('[App] Failed to resolve the framework of item', id, err) + } + } + } + await Promise.all(Array.from({ length: Math.min(6, queue.length) }, worker)) + return result + }, + [api, caseApiVersion], + ) + const handleLoadAlignmentsForTarget = useCallback( async (targetId: string): Promise<{ docId: string @@ -773,6 +806,7 @@ function AppInner() { onSaveAlignments={tenantId ? handleSaveAlignments : undefined} onLoadAlignmentsForTarget={tenantId ? handleLoadAlignmentsForTarget : undefined} onDiscoverAlignedTargets={tenantId ? handleDiscoverAlignedTargets : undefined} + onResolveItemFrameworks={handleResolveItemFrameworks} mirrorStatus={activeFramework.mirrorStatus} cfDefinitionsExtensions={activeCfDefinitions?.extensions} loadedFramework={activeFramework.framework} diff --git a/apps/editor/src/domain/framework/inlineAlignments.test.ts b/apps/editor/src/domain/framework/inlineAlignments.test.ts new file mode 100644 index 0000000..b32282b --- /dev/null +++ b/apps/editor/src/domain/framework/inlineAlignments.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { groupInlineAlignmentsByTarget, inlineAlignmentsOf } from './inlineAlignments' +import type { Association, Framework, Item } from '@/domain/framework/model/types' +import type { AssociationId, FrameworkId, ItemId } from '@/domain/shared/types' + +const item = (id: string): Item => ({ id: id as ItemId, statement: id }) +const assoc = (id: string, from: string, to: string, associationType = 'isRelatedTo', metadata?: Association['metadata']): Association => ({ + id: id as AssociationId, + fromItemId: from as ItemId, + toItemId: to as ItemId, + associationType, + metadata, +}) +const framework = (associations: Association[]): Framework => ({ + id: 'doc-1' as FrameworkId, + metadata: { title: 'Doc' }, + items: new Map([item('item-1'), item('item-2')].map((i) => [i.id, i])), + associations: new Map(associations.map((a) => [a.id, a])), + status: 'Published', +}) + +describe('inlineAlignmentsOf', () => { + it('keeps non-hierarchical associations from an item to a CASE item, here or elsewhere', () => { + const fw = framework([ + assoc('a-1', 'item-1', 'item-2', 'ext:related'), + assoc('a-2', 'item-1', 'other-item', 'precedes', { destinationUri: 'https://example.org/ims/case/v1p1/CFItems/other-item' }), + ]) + const result = inlineAlignmentsOf(fw) + expect(result.map((a) => a.id)).toEqual(['a-1', 'a-2']) + expect(result[1]).toMatchObject({ + fromItemId: 'item-1', + toItemId: 'other-item', + associationType: 'precedes', + destinationUri: 'https://example.org/ims/case/v1p1/CFItems/other-item', + }) + }) + + it('skips hierarchy, links to the document, non-CASE targets and incoming associations', () => { + const fw = framework([ + assoc('child', 'item-2', 'item-1', 'isChildOf'), + assoc('part', 'item-2', 'item-1', 'isPartOf'), + assoc('root', 'item-1', 'doc-1', 'exactMatchOf'), + assoc('non-case', 'item-1', 'KO|3.1.2', 'isRelatedTo', { destinationTargetType: 'ext:outcomes' }), + assoc('incoming', 'other-item', 'item-1'), + ]) + expect(inlineAlignmentsOf(fw)).toEqual([]) + }) + + it('treats an explicit CASE targetType as a CASE item', () => { + const fw = framework([assoc('a-1', 'item-1', 'other-item', 'isRelatedTo', { destinationTargetType: 'CASE' })]) + expect(inlineAlignmentsOf(fw)).toHaveLength(1) + }) +}) + +describe('groupInlineAlignmentsByTarget', () => { + it('groups local destinations under the framework itself and others by their framework', () => { + const fw = framework([ + assoc('local', 'item-1', 'item-2'), + assoc('remote', 'item-1', 'other-item'), + assoc('unknown', 'item-2', 'missing-item'), + ]) + const { byTarget, unresolvedItemIds } = groupInlineAlignmentsByTarget( + fw, + inlineAlignmentsOf(fw), + (id) => (id === 'other-item' ? 'doc-2' : undefined), + ) + expect(byTarget.get('doc-1')?.map((a) => a.id)).toEqual(['local']) + expect(byTarget.get('doc-2')?.map((a) => a.id)).toEqual(['remote']) + expect(unresolvedItemIds).toEqual(['missing-item']) + }) +}) diff --git a/apps/editor/src/domain/framework/inlineAlignments.ts b/apps/editor/src/domain/framework/inlineAlignments.ts new file mode 100644 index 0000000..7db5f5e --- /dev/null +++ b/apps/editor/src/domain/framework/inlineAlignments.ts @@ -0,0 +1,78 @@ +import type { Framework } from '@/domain/framework/model/types' +import type { ItemId } from '@/domain/shared/types' + +/** A non-hierarchical association stored in the framework itself, seen as an alignment from one of its items. */ +export type InlineAlignment = { + id: string + fromItemId: string + toItemId: string + associationType: string + originUri: string + destinationUri: string +} + +const HIERARCHICAL_TYPES = new Set(['isChildOf', 'isPartOf']) + +/** + * Associations that a framework stores in its own CFPackage and that read as + * alignments: non-hierarchical, from one of its items to a CASE item (in this + * framework or another one). Links to the framework's own CFDocument and to + * non-CASE targets (`targetType` other than 'CASE') are not alignments. + * + * CASE 1.1 keeps an association in the document of its origin, so these are + * the same relationships an alignment document would hold — just not in a + * separate package. + */ +export function inlineAlignmentsOf(framework: Framework): InlineAlignment[] { + const frameworkId = String(framework.id) + const result: InlineAlignment[] = [] + for (const a of framework.associations.values()) { + if (HIERARCHICAL_TYPES.has(a.associationType)) continue + const fromItemId = String(a.fromItemId) + const toItemId = String(a.toItemId) + if (!framework.items.has(a.fromItemId)) continue + if (toItemId === frameworkId || toItemId === fromItemId) continue + const targetType = a.metadata?.destinationTargetType + if (targetType && targetType !== 'CASE') continue + + const md = a.metadata ?? {} + const itemUri = (framework.items.get(a.fromItemId)?.metadata as Record | undefined)?.caseUri + result.push({ + id: String(a.id), + fromItemId, + toItemId, + associationType: a.associationType, + originUri: md.originUri ?? (typeof itemUri === 'string' ? itemUri : `urn:case:item:${fromItemId}`), + destinationUri: md.destinationUri ?? `urn:case:item:${toItemId}`, + }) + } + return result +} + +/** + * Groups inline alignments by the framework that holds their destination. + * Destinations in the framework itself group under its own id (a self + * alignment); others are looked up with `frameworkOfItem`. Destinations it + * cannot place are returned in `unresolvedItemIds`. + */ +export function groupInlineAlignmentsByTarget( + framework: Framework, + alignments: InlineAlignment[], + frameworkOfItem: (itemId: string) => string | undefined, +): { byTarget: Map; unresolvedItemIds: string[] } { + const frameworkId = String(framework.id) + const byTarget = new Map() + const unresolved = new Set() + for (const alignment of alignments) { + const isLocal = framework.items.has(alignment.toItemId as ItemId) + const targetId = isLocal ? frameworkId : frameworkOfItem(alignment.toItemId) + if (!targetId) { + unresolved.add(alignment.toItemId) + continue + } + const list = byTarget.get(targetId) + if (list) list.push(alignment) + else byTarget.set(targetId, [alignment]) + } + return { byTarget, unresolvedItemIds: [...unresolved] } +} diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index 0c8111b..e3d27a7 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -1,5 +1,5 @@ import type { CFDocument, CFLicense, CFPackage, CFDefinition } from '@/domain/case/types' -import type { HttpClient } from './http' +import { HttpError, type HttpClient } from './http' import type { CgeImportResult, CachedFrameworkItemSummary } from './cgeTypes' export type { CgeImportResult, CachedFrameworkItemSummary, CgeFrameworkSummary, CgeSubscriptionSummary } from './cgeTypes' export { normalizeCgeFrameworkList, normalizeCgeSubscriptionList } from './cgeTypes' @@ -134,6 +134,24 @@ export class CaseApiClient { return [] } + /** + * Identifier of the CFDocument that holds an item, read from the item's CFDocumentURI. + * + * Uses the public endpoint: GET /ims/case/{version}/CFItems/{itemId} + * Returns undefined when the item is not on this server (404). + */ + async getCfItemDocumentId(params: { itemId: string; caseVersion?: 'v1p0' | 'v1p1' }): Promise { + const v = params.caseVersion ?? 'v1p1' + try { + const res = (await this._http.get(`/ims/case/${v}/CFItems/${encodeURIComponent(params.itemId)}`)) as + { CFDocumentURI?: { identifier?: string } } | null + return res?.CFDocumentURI?.identifier || undefined + } catch (err) { + if (err instanceof HttpError && err.status === 404) return undefined + throw err + } + } + async getCfPackage(params: { docId: string; caseVersion?: 'v1p0' | 'v1p1'; includeExtensions?: boolean }): Promise { const v = params.caseVersion ?? 'v1p1' const res = (await this._http.get( diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index 31c25d3..da60e81 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -219,6 +219,8 @@ type EditorCanvasProps = { cfDefinitionsExtensions?: CaseExtensions /** The framework as loaded — its associations to nodes outside this framework have no canvas edge, so they're carried through on save */ loadedFramework?: Framework + /** Resolve which framework holds each item (item id → framework id); used by the tree view for inline associations */ + onResolveItemFrameworks?: (itemIds: string[]) => Promise> } function RemoteLinkInternalsSync({ @@ -250,7 +252,7 @@ function RemoteLinkInternalsSync({ return null } -export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions, loadedFramework }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions, loadedFramework, onResolveItemFrameworks }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -1787,6 +1789,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor onSaveAlignments={onSaveAlignments} onLoadAlignmentsForTarget={onLoadAlignmentsForTarget} onDiscoverAlignedTargets={onDiscoverAlignedTargets} + loadedFramework={loadedFramework} + onResolveItemFrameworks={onResolveItemFrameworks} /> ) : null} diff --git a/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx b/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx index 645df67..f943e94 100644 --- a/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx +++ b/apps/editor/src/ui/editor/treePanel/TreePanelView.tsx @@ -5,6 +5,8 @@ import { buildFrameworkTree, type FrameworkEdgeRecord, type FrameworkTreeNode } import type { CFItem } from '@/domain/case/types' import type { HomeFramework } from '@/ui/home/frameworkStore' import type { Framework } from '@/domain/framework/model/types' +import type { ItemId } from '@/domain/shared/types' +import { groupInlineAlignmentsByTarget, inlineAlignmentsOf } from '@/domain/framework/inlineAlignments' import FrameworkTreeList from './FrameworkTreeList' // ── Types ───────────────────────────────────────────────────────────────── @@ -19,6 +21,11 @@ type PendingAssociation = { originUri: string /** Canonical URI of the destination item — preserved for correct serialization when external frameworks are involved */ destinationUri: string + /** + * True for an association stored in the framework's own CFPackage (not in an alignment document). + * Shown read-only and never written to an alignment document, so it isn't duplicated there. + */ + inline?: boolean } // Module-scope stable reference (never recreated) — passed as a no-op @@ -40,6 +47,8 @@ type LineCoord = { midY: number /** True when one or both endpoints are ancestors (item is collapsed). Clicking expands to reveal items. */ isDashed: boolean + /** True for an association stored in the framework itself (see PendingAssociation.inline) */ + isInline: boolean } type PopoverState = { @@ -80,6 +89,10 @@ type Props = { 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> + /** The framework as loaded — its own non-hierarchical associations are shown as (read-only) alignments. */ + loadedFramework?: Framework + /** Resolves which framework holds each item (item id → framework id), to place inline associations to other frameworks. */ + onResolveItemFrameworks?: (itemIds: string[]) => Promise> } // ── Helpers: build tree data from a domain Framework ────────────────────── @@ -172,7 +185,7 @@ function findNearestVisible( // ── Component ───────────────────────────────────────────────────────────── -export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) { +export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, loadedFramework, onResolveItemFrameworks }: Props) { const { nodes, cfItems, @@ -192,6 +205,13 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const [targets, setTargets] = useState([]) const [expandedTargetId, setExpandedTargetId] = useState(null) const [pendingAssociations, setPendingAssociations] = useState([]) + // Associations stored in the framework itself (CASE 1.1 keeps an association in the document of its origin). + const [inlineAssociations, setInlineAssociations] = useState([]) + // Everything drawn in the panel: alignment-document associations (editable) and inline ones (read-only). + const shownAssociations = useMemo( + () => (inlineAssociations.length > 0 ? [...pendingAssociations, ...inlineAssociations] : pendingAssociations), + [pendingAssociations, inlineAssociations], + ) const [dragOverItemId, setDragOverItemId] = useState(null) const [loadingTargetIds, setLoadingTargetIds] = useState>(() => new Set()) const [loadedTargetIds, setLoadedTargetIds] = useState>(() => new Set()) @@ -241,8 +261,8 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const [leftHeaderHeight, setLeftHeaderHeight] = useState(undefined) // Stable refs for values used inside recalculateLines (avoids stale closures) - const pendingRef = useRef(pendingAssociations) - pendingRef.current = pendingAssociations + const shownRef = useRef(shownAssociations) + shownRef.current = shownAssociations const expandedTargetIdRef = useRef(expandedTargetId) expandedTargetIdRef.current = expandedTargetId @@ -342,23 +362,23 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const leftAssociationCounts = useMemo(() => { const counts = new Map() - for (const a of pendingAssociations) { + for (const a of shownAssociations) { if (a.toFrameworkId === expandedTargetId) { counts.set(a.fromItemId, (counts.get(a.fromItemId) ?? 0) + 1) } } return counts - }, [pendingAssociations, expandedTargetId]) + }, [shownAssociations, expandedTargetId]) const rightAssociationCounts = useMemo(() => { const counts = new Map() - for (const a of pendingAssociations) { + for (const a of shownAssociations) { if (a.toFrameworkId === expandedTargetId) { counts.set(a.toItemId, (counts.get(a.toItemId) ?? 0) + 1) } } return counts - }, [pendingAssociations, expandedTargetId]) + }, [shownAssociations, expandedTargetId]) // ── SVG line calculation ── @@ -375,7 +395,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const leftScrollEl = leftPanel.querySelector('[data-scroll-container]') const rightScrollEl = rightPanel.querySelector('[data-scroll-container]') - const computed = pendingRef.current + const computed = shownRef.current .filter((assoc) => assoc.toFrameworkId === expandedTargetIdRef.current) .flatMap((assoc) => { // Use nearest visible ancestor if the exact item is collapsed inside a parent @@ -408,7 +428,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor 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) }] + return [{ id: assoc.id, d, midX: (x1 + x2) / 2, midY: (y1 + y2) / 2, isDashed: !(leftHit.isExact && rightHit.isExact), isInline: assoc.inline === true }] }) setLineCoords(computed) @@ -422,7 +442,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor const id = requestAnimationFrame(recalculateLines) return () => cancelAnimationFrame(id) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pendingAssociations, expandedTargetId, expandedTargetFramework?.id ?? null, leftExpandedIds, rightExpandedIds, recalculateLines]) + }, [shownAssociations, expandedTargetId, expandedTargetFramework?.id ?? null, leftExpandedIds, rightExpandedIds, recalculateLines]) // Recalculate on scroll and resize useEffect(() => { @@ -516,6 +536,43 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor })() }, [activeFrameworkId, onDiscoverAlignedTargets, onLoadAlignmentsForTarget]) + // The framework's own non-hierarchical associations, grouped by the framework that holds their + // destination: local destinations form the self target, others are resolved via the server. + // Each such framework is added to the target list; its associations are shown read-only. + useEffect(() => { + const alignments = loadedFramework ? inlineAlignmentsOf(loadedFramework) : [] + if (!loadedFramework || alignments.length === 0) { + setInlineAssociations([]) + return + } + + let cancelled = false + const show = (frameworkOfItem: Record) => { + if (cancelled) return + const { byTarget } = groupInlineAlignmentsByTarget(loadedFramework, alignments, (id) => frameworkOfItem[id]) + setInlineAssociations([...byTarget].flatMap(([targetId, list]) => + list.map((a) => ({ ...a, toFrameworkId: targetId, inline: true })))) + setTargets((prev) => { + const existingIds = new Set(prev.map((t) => t.id)) + const toAdd = [...byTarget.keys()] + .filter((id) => !existingIds.has(id)) + .map((id) => ({ id, alignmentDocId: globalThis.crypto?.randomUUID?.() ?? `align-${Date.now()}-${id}`, hasUnsavedChanges: false })) + return toAdd.length > 0 ? [...prev, ...toAdd] : prev + }) + } + + show({}) + const externalItemIds = [...new Set(alignments + .map((a) => a.toItemId) + .filter((id) => !loadedFramework.items.has(id as ItemId)))] + if (externalItemIds.length > 0 && onResolveItemFrameworks) { + void onResolveItemFrameworks(externalItemIds) + .then(show) + .catch((err: unknown) => console.warn('[TreePanelView] Failed to resolve frameworks of inline associations:', err)) + } + return () => { cancelled = true } + }, [loadedFramework, onResolveItemFrameworks]) + // When expanding a pre-populated target whose framework data isn't loaded yet, fetch it useEffect(() => { if (!expandedTargetId || !onLoadTargetFramework) return @@ -629,7 +686,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // 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) + const assoc = shownRef.current.find((a) => a.id === assocId) if (!assoc) return const leftParentId = leftParentMapRef.current.get(assoc.fromItemId) const rightParentId = rightParentMapRef.current.get(assoc.toItemId) @@ -643,7 +700,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // 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) + const assoc = shownRef.current.find((a) => a.id === assocId) if (!assoc) return const leftAncestors = ancestorsFromParentMap(assoc.fromItemId, leftParentMapRef.current) const rightAncestors = ancestorsFromParentMap(assoc.toItemId, rightParentMapRef.current) @@ -678,10 +735,11 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor // 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, + return shownAssociations.filter((a) => + a.toFrameworkId === expandedTargetId && + (popover.side === 'left' ? a.fromItemId === popover.itemId : a.toItemId === popover.itemId), ) - }, [popover, pendingAssociations]) + }, [popover, shownAssociations, expandedTargetId]) // ── Save alignments ── @@ -859,7 +917,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor {/* Visible stroke: solid when both items are visible, dashed when proxied via ancestor */} { 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 targetAssocCount = shownAssociations.filter((a) => a.toFrameworkId === target.id).length + const pendingAssocCount = pendingAssociations.filter((a) => a.toFrameworkId === target.id).length const isLoading = loadingTargetIds.has(target.id) const isSelf = target.id === activeFrameworkId @@ -996,8 +1055,8 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor {onSaveAlignments && target.hasUnsavedChanges && (
- {targetAssocCount > 0 - ? `${targetAssocCount} association${targetAssocCount !== 1 ? 's' : ''} — unsaved` + {pendingAssocCount > 0 + ? `${pendingAssocCount} association${pendingAssocCount !== 1 ? 's' : ''} — unsaved` : 'Unsaved changes'}
@@ -1060,6 +1119,14 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor key={assoc.id} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-slate-50" > + {assoc.inline ? ( + + {ALIGNMENT_ASSOCIATION_TYPES.find((t) => t.value === assoc.associationType)?.label ?? assoc.associationType} + + ) : ( + )} {label} - + {!assoc.inline && ( + + )}
) })}