Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<Record<string, string>> => {
const result: Record<string, string> = {}
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
Expand Down Expand Up @@ -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}
Expand Down
71 changes: 71 additions & 0 deletions apps/editor/src/domain/framework/inlineAlignments.test.ts
Original file line number Diff line number Diff line change
@@ -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'])
})
})
78 changes: 78 additions & 0 deletions apps/editor/src/domain/framework/inlineAlignments.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<string, InlineAlignment[]>; unresolvedItemIds: string[] } {
const frameworkId = String(framework.id)
const byTarget = new Map<string, InlineAlignment[]>()
const unresolved = new Set<string>()
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] }
}
20 changes: 19 additions & 1 deletion apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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<string | undefined> {
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<CFPackage> {
const v = params.caseVersion ?? 'v1p1'
const res = (await this._http.get(
Expand Down
6 changes: 5 additions & 1 deletion apps/editor/src/ui/editor/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>
}

function RemoteLinkInternalsSync({
Expand Down Expand Up @@ -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<EditorCanvasProps>) {
export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus, cfDefinitionsExtensions, loadedFramework, onResolveItemFrameworks }: Readonly<EditorCanvasProps>) {
const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth()
const {
nodes,
Expand Down Expand Up @@ -1787,6 +1789,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramewor
onSaveAlignments={onSaveAlignments}
onLoadAlignmentsForTarget={onLoadAlignmentsForTarget}
onDiscoverAlignedTargets={onDiscoverAlignedTargets}
loadedFramework={loadedFramework}
onResolveItemFrameworks={onResolveItemFrameworks}
/>
</div>
) : null}
Expand Down
Loading
Loading