diff --git a/apps/editor/package-lock.json b/apps/editor/package-lock.json index a19fbef..1d02620 100644 --- a/apps/editor/package-lock.json +++ b/apps/editor/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "case-editor", "version": "0.0.0", + "license": "Apache-2.0", "dependencies": { "@heroicons/react": "^2.2.0", "@radix-ui/react-checkbox": "^1.3.3", @@ -2503,27 +2504,6 @@ "vite": "^5.2.0 || ^6 || ^7" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -2579,14 +2559,6 @@ } } }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2737,7 +2709,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -2747,7 +2719,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -3245,17 +3217,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3602,7 +3563,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/d3-color": { @@ -3782,14 +3743,6 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/electron-to-chromium": { "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", @@ -4871,17 +4824,6 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5153,36 +5095,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5214,14 +5126,6 @@ "react": "^19.2.4" } }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index f043f3c..512a71d 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -493,6 +493,9 @@ function AppInner() { onSaveToServer={tenantId ? handleSaveToServer : undefined} isPublishedToOpenCase={activeFrameworkId ? publishedFrameworkIds.has(activeFrameworkId) : false} onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined} + onImportFromRegistry={tenantId ? async (registryUrl) => { + return api.previewRegistryFramework({ tenantId, registryUrl }) + } : undefined} /> ) diff --git a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts index 4c9cc37..4c70255 100644 --- a/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts +++ b/apps/editor/src/application/framework/mappers/case/CasePackageSnapshot.ts @@ -36,6 +36,8 @@ export type CaseDocumentSnapshot = { lastChangeDateTime?: string /** Link to the CFLicense governing this framework */ licenseURI?: { title?: string; identifier?: string; uri: string } + /** Namespaced extension data (e.g. ext:opencase — CTID, import provenance, layout) */ + extensions?: Record } /** diff --git a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts index 7c1fb24..79fb2e5 100644 --- a/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts +++ b/apps/editor/src/application/framework/mappers/case/caseToDomainFramework.ts @@ -56,6 +56,7 @@ export function mapCaseSnapshotToDomainFramework(snapshot: CasePackageSnapshot): statusEndDate: doc.statusEndDate, lastChangeDateTime: doc.lastChangeDateTime, licenseURI: doc.licenseURI, + extensions: doc.extensions as Record | undefined, } const items: Framework['items'] = new Map() diff --git a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts index ab72bb0..9f4077f 100644 --- a/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/normalizeCasePackage.ts @@ -340,6 +340,7 @@ export function normalizeCasePackageResponse(res: unknown): CasePackageSnapshot statusEndDate: asString(doc.statusEndDate), lastChangeDateTime: asString(doc.lastChangeDateTime), licenseURI, + extensions: asRecord(doc.extensions) ?? undefined, }, items, associations, diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index d81d7f4..3f6e6d9 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -62,6 +62,21 @@ function ensureUuid(id: string): string { return generateDeterministicUuid(id) } +/** Matches a Credential Engine CTID (`ce-`) anywhere in a string (bare or in a URL). */ +const CTID_PATTERN = /\bce-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i + +/** + * Extract the bare UUID from a CTID or a registry resource URL that contains one, + * e.g. "ce-64e0…" or "https://…/resources/ce-64e0…" → "64e0…". Returns undefined + * when no CTID is present. Used so alignments to registry resources carry the + * resource's real identifier rather than a hash of the "ce-" string. + */ +function extractCtidUuid(value: string | undefined): string | undefined { + if (!value) return undefined + const match = CTID_PATTERN.exec(value) + return match ? match[1].toLowerCase() : undefined +} + /** * URI format specification for CASE entities. * Uses the official CASE v1p1 REST endpoint format. @@ -106,6 +121,31 @@ function incrementVersion(currentVersion?: string): string { return `${parts[0]}.${parts[1]}.${build + 1}` } +type PersistedRegistryNode = { + id: string + ctdlUri: string + ctdlCtid: string + fullStatement: string + codedNotation?: string + frameworkTitle?: string + x: number + y: number + w?: number + h?: number +} + +type PersistedExternalNode = { + id: string + title: string + uri?: string + description?: string + source?: string + x: number + y: number + w?: number + h?: number +} + type OpencaseExtension = { layout?: NodeLayout notes?: string @@ -117,6 +157,40 @@ type OpencaseExtension = { edgeType?: string /** Visual color band hex color for item nodes */ colorBand?: string + /** CTDL URI of the destination when the target is a Registry reference node */ + ctdlDestinationUri?: string + /** URI of the destination when the target is an external-framework reference node */ + externalDestinationUri?: string + /** Registry competency nodes placed on canvas for alignment */ + registryNodes?: PersistedRegistryNode[] + /** External-framework reference nodes placed on canvas for alignment */ + externalNodes?: PersistedExternalNode[] + /** Registry provenance for CFDocuments/CFItems imported from a CTDL registry. */ + source?: { + /** Resolvable @id URI of the source resource — authoritative and registry-qualified. */ + uri: string + /** Credential Engine CTID convenience (derivable from uri; omitted when absent). */ + ctid?: string + /** Source registry base/origin — disambiguates multi-registry imports. */ + registry?: string + /** Vocabulary/format of the source, e.g. "ctdl-asn". */ + format?: string + } +} + +/** + * Recover a nested extensions object from item/association metadata, which may + * hold `ext:opencase` either nested (`metadata.extensions['ext:opencase']`, from + * fromEditorGraph) or flattened (`metadata['ext:opencase']`, from + * caseToDomainFramework). Returns a nested-shape object so downstream export + * logic — and the merge helper — see a single consistent form. This is what + * keeps imported Registry provenance (the `source` block) alive across saves. + */ +function reconcileExtensions(md: Record): CaseExtensions | undefined { + const nested = md.extensions as CaseExtensions | undefined + if (nested) return nested + const flattened = md[OPENCASE_EXT_KEY] as OpencaseExtension | undefined + return flattened ? { [OPENCASE_EXT_KEY]: flattened } : undefined } /** @@ -130,7 +204,7 @@ function mergeOpencaseExtension( const extensions = { ...base } // Only add if there's data to store - if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand) { + if (opencaseData.layout || opencaseData.notes || opencaseData.originHandle || opencaseData.destinationHandle || opencaseData.edgeType || opencaseData.colorBand || opencaseData.ctdlDestinationUri || opencaseData.externalDestinationUri || opencaseData.registryNodes?.length || opencaseData.externalNodes?.length) { const existing = (extensions[OPENCASE_EXT_KEY] as OpencaseExtension | undefined) ?? {} extensions[OPENCASE_EXT_KEY] = { ...existing, @@ -148,7 +222,7 @@ function frameworkToCfDocument( framework: Framework, caseVersion: CaseVersion, layout?: NodeLayout, - options?: { incrementVersion?: boolean; edgeType?: string } + options?: { incrementVersion?: boolean; edgeType?: string; registryNodes?: PersistedRegistryNode[]; externalNodes?: PersistedExternalNode[] } ): CFDocument { const meta = framework.metadata const fwId = String(framework.id) @@ -188,9 +262,9 @@ function frameworkToCfDocument( title: docTitle, identifier: fwId, }, - extensions: (layout || options?.edgeType) - ? mergeOpencaseExtension(undefined, { layout, edgeType: options?.edgeType }) - : undefined, + extensions: (layout || options?.edgeType || options?.registryNodes?.length || options?.externalNodes?.length) + ? mergeOpencaseExtension(meta.extensions as CaseExtensions | undefined, { layout, edgeType: options?.edgeType, registryNodes: options?.registryNodes?.length ? options.registryNodes : undefined, externalNodes: options?.externalNodes?.length ? options.externalNodes : undefined }) + : (meta.extensions as CaseExtensions | undefined), } return document @@ -224,7 +298,11 @@ function itemToCfItem( return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : undefined } - const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined + // Extensions may be nested (metadata.extensions) or flattened into metadata + // (metadata['ext:opencase']) depending on the load path (fromEditorGraph vs + // caseToDomainFramework). Reconcile both so imported data — e.g. the Registry + // `source` block — survives the round-trip. Mirrors toReactFlow. + const existingExtensions = reconcileExtensions(md) const cfItem: CFItem & { sourcedId: string } = { identifier: itemId, @@ -284,24 +362,39 @@ function associationToCfAssociation( const fromItem = framework.items.get(assoc.fromItemId) const toItem = framework.items.get(assoc.toItemId) const fromTitle = fromItem?.statement ?? `Item ${fromId}` - const toTitle = toItem?.statement ?? `Item ${toId}` - + + // toItem may be undefined when the destination is a Registry reference node const s = (k: string): string | undefined => { const v = md[k] return typeof v === 'string' ? v : undefined } + const ctdlUri = s('ctdlUri') + const externalUri = s('externalUri') + const isRegistryDestination = !toItem && !!ctdlUri + const isExternalDestination = !toItem && !ctdlUri && !!externalUri + const toTitle = toItem?.statement + ?? s('ctdlStatement') + ?? (isExternalDestination ? s('externalTitle') : undefined) + ?? `Item ${toId}` + const destinationUri = isRegistryDestination + ? ctdlUri! + : isExternalDestination + ? externalUri! + : (s('destinationUri') ?? `urn:case:item:${toId}`) const n = (k: string): number | undefined => { const v = md[k] return typeof v === 'number' ? v : undefined } - const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined + const existingExtensions = reconcileExtensions(md) - // Persist user-defined edge handle positions in ext:opencase + // Persist user-defined edge handle positions and external destination URIs in ext:opencase const originHandle = s('originHandle') const destinationHandle = s('destinationHandle') - const extensions = (originHandle || destinationHandle) - ? mergeOpencaseExtension(existingExtensions, { originHandle, destinationHandle }) + const ctdlDestinationUri = isRegistryDestination ? ctdlUri! : undefined + const externalDestinationUri = isExternalDestination ? externalUri! : undefined + const extensions = (originHandle || destinationHandle || ctdlDestinationUri || externalDestinationUri) + ? mergeOpencaseExtension(existingExtensions, { originHandle, destinationHandle, ctdlDestinationUri, externalDestinationUri }) : existingExtensions const cfAssociation: CFAssociation & { sourcedId: string } = { @@ -316,7 +409,7 @@ function associationToCfAssociation( }, destinationNodeURI: { identifier: toId, - uri: s('destinationUri') ?? `urn:case:item:${toId}`, + uri: destinationUri, title: toTitle, }, sequenceNumber: n('sequenceNumber'), @@ -368,13 +461,17 @@ export function frameworkToCfPackage(params: { cfAssociationGroupings?: CFAssociationGrouping[] /** CFLicense definitions to include in CFDefinitions (from editor state) */ cfLicenses?: CFLicense[] + /** Registry reference nodes placed on canvas — persisted in CFDocument extensions */ + registryNodes?: PersistedRegistryNode[] + /** External-framework reference nodes placed on canvas — persisted in CFDocument extensions */ + externalNodes?: PersistedExternalNode[] }): CFPackage { - const { framework, caseVersion, layout, incrementVersion, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses } = params + const { framework, caseVersion, layout, incrementVersion, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses, registryNodes, externalNodes } = params const fwId = String(framework.id) // Build CFDocument const documentLayout = layout?.byNodeId?.[fwId] - const document = frameworkToCfDocument(framework, caseVersion, documentLayout, { incrementVersion, edgeType }) + const document = frameworkToCfDocument(framework, caseVersion, documentLayout, { incrementVersion, edgeType, registryNodes, externalNodes }) // Build CFItems const itemIds = Array.from(framework.items.keys()).map(String) @@ -618,7 +715,8 @@ export type OpenCaseCFPackage = CaseV1p1Package */ export function toOpenCaseFormat(cfPackage: CFPackage): CaseV1p1Package { const doc = cfPackage.CFDocument as CFDocument & { sourcedId?: string } - const docId = ensureUuid(doc.sourcedId ?? doc.identifier) + const docInternalId = doc.sourcedId ?? doc.identifier + const docId = ensureUuid(docInternalId) const docTitle = doc.title // Build a mapping from internal item IDs to normalized UUIDs @@ -710,6 +808,44 @@ export function toOpenCaseFormat(cfPackage: CFPackage): CaseV1p1Package { const originId = itemIdMap.get(originInternalId) ?? ensureUuid(originInternalId) const destId = itemIdMap.get(destInternalId) ?? ensureUuid(destInternalId) + // Preserve external destination URIs for alignment associations + const assocExt = (a.extensions as Record | undefined)?.[OPENCASE_EXT_KEY] as Record | undefined + const ctdlDestUri = typeof assocExt?.ctdlDestinationUri === 'string' ? assocExt.ctdlDestinationUri : undefined + const externalDestUri = typeof assocExt?.externalDestinationUri === 'string' ? assocExt.externalDestinationUri : undefined + + // Resolve the destination LinkURI for the possible targets: + // 1. Registry alignment — identifier is the CTID's UUID, uri is the registry resource URL. + // 2. External-framework alignment — uri is the external resource URI (identifier from its + // CTID if it happens to be a registry-style URL, else a deterministic UUID). + // 3. Framework membership (a top-level item's isChildOf → the CFDocument) — CFDocument URI + title. + // 4. Ordinary item-to-item — CFItem URI. + let destinationNodeURI: CaseLinkURI + if (ctdlDestUri) { + destinationNodeURI = { + title: a.destinationNodeURI.title ?? 'Destination', + identifier: extractCtidUuid(destInternalId) ?? extractCtidUuid(ctdlDestUri) ?? destId, + uri: ctdlDestUri, + } + } else if (externalDestUri) { + destinationNodeURI = { + title: a.destinationNodeURI.title ?? 'Destination', + identifier: extractCtidUuid(externalDestUri) ?? destId, + uri: externalDestUri, + } + } else if (destInternalId === docInternalId) { + destinationNodeURI = { + title: docTitle, + identifier: docId, + uri: makeDocumentUri(docId), + } + } else { + destinationNodeURI = { + title: a.destinationNodeURI.title ?? 'Destination', + identifier: destId, + uri: makeItemUri(destId), + } + } + return { identifier: assocId, uri: makeAssociationUri(assocId), @@ -719,11 +855,7 @@ export function toOpenCaseFormat(cfPackage: CFPackage): CaseV1p1Package { identifier: originId, uri: makeItemUri(originId), }, - destinationNodeURI: { - title: a.destinationNodeURI.title ?? 'Destination', - identifier: destId, - uri: makeItemUri(destId), - }, + destinationNodeURI, lastChangeDateTime: a.lastChangeDateTime, sequenceNumber: a.sequenceNumber, CFAssociationGroupingURI: a.CFAssociationGroupingURI ? { diff --git a/apps/editor/src/application/framework/mappers/case/toOpenCaseFormat.test.ts b/apps/editor/src/application/framework/mappers/case/toOpenCaseFormat.test.ts new file mode 100644 index 0000000..545eb29 --- /dev/null +++ b/apps/editor/src/application/framework/mappers/case/toOpenCaseFormat.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { toOpenCaseFormat } from './toCasePackage' +import type { CFPackage } from '@/domain/case/types' + +const DOC_ID = 'a03f4ef2-eed6-4b33-b11a-83d665b72740' +const ITEM_A = '1496da12-1a12-45ee-982e-00005a5e8240' +const ITEM_B = '2496da12-1a12-45ee-982e-00005a5e8241' +const CTID = 'ce-64e007f5-3a53-46a2-9abe-92c8717e62dd' +const REGISTRY_URL = `https://credentialengineregistry.org/resources/${CTID}` +const NOW = '2026-08-06T14:42:54.537Z' + +// Internal CFPackage shaped like associationToCfAssociation output, for the three +// destination kinds. toOpenCaseFormat is the authoritative "official CASE v1p1" export. +function buildPackage(): CFPackage { + return { + CFDocument: { + sourcedId: DOC_ID, identifier: DOC_ID, uri: `urn:case:document:${DOC_ID}`, + title: 'Test Framework', creator: 'C', lastChangeDateTime: NOW, + }, + CFItems: [ + { sourcedId: ITEM_A, identifier: ITEM_A, uri: `urn:case:item:${ITEM_A}`, fullStatement: 'Item A', lastChangeDateTime: NOW }, + { sourcedId: ITEM_B, identifier: ITEM_B, uri: `urn:case:item:${ITEM_B}`, fullStatement: 'Item B', lastChangeDateTime: NOW }, + ], + CFAssociations: [ + { // framework membership: top-level item isChildOf the document (placeholder LinkURI on input) + sourcedId: 'assoc-root', identifier: 'assoc-root', associationType: 'isChildOf', + originNodeURI: { identifier: ITEM_A, title: 'Item A', uri: `urn:case:item:${ITEM_A}` }, + destinationNodeURI: { identifier: DOC_ID, title: `Item ${DOC_ID}`, uri: `urn:case:item:${DOC_ID}` }, + lastChangeDateTime: NOW, + }, + { // registry alignment: destination is a CTDL resource + sourcedId: 'assoc-reg', identifier: 'assoc-reg', associationType: 'isRelatedTo', + originNodeURI: { identifier: ITEM_A, title: 'Item A', uri: `urn:case:item:${ITEM_A}` }, + destinationNodeURI: { identifier: CTID, title: 'The ability to demonstrate ethical…', uri: REGISTRY_URL }, + lastChangeDateTime: NOW, + extensions: { 'ext:opencase': { ctdlDestinationUri: REGISTRY_URL } }, + }, + { // ordinary item-to-item + sourcedId: 'assoc-item', identifier: 'assoc-item', associationType: 'isRelatedTo', + originNodeURI: { identifier: ITEM_A, title: 'Item A', uri: `urn:case:item:${ITEM_A}` }, + destinationNodeURI: { identifier: ITEM_B, title: 'Item B', uri: `urn:case:item:${ITEM_B}` }, + lastChangeDateTime: NOW, + }, + ], + } as unknown as CFPackage +} + +describe('toOpenCaseFormat — association destination URIs', () => { + const out = toOpenCaseFormat(buildPackage()) + const [root, reg, itemToItem] = out.CFAssociations! + + it('top-level membership points at the CFDocument (not a /CFItems/ URI)', () => { + expect(root.associationType).toBe('isChildOf') + expect(root.destinationNodeURI.uri).toBe(`/ims/case/v1p1/CFDocuments/${DOC_ID}`) + expect(root.destinationNodeURI.identifier).toBe(DOC_ID) + expect(root.destinationNodeURI.title).toBe('Test Framework') // not "Item a03f4ef2…" + }) + + it('registry alignment keeps the resource URL and uses the CTID’s UUID as identifier', () => { + expect(reg.destinationNodeURI.uri).toBe(REGISTRY_URL) + expect(reg.destinationNodeURI.identifier).toBe('64e007f5-3a53-46a2-9abe-92c8717e62dd') // ce- stripped, no hash + expect(reg.destinationNodeURI.title).toBe('The ability to demonstrate ethical…') + }) + + it('ordinary item-to-item still resolves to a /CFItems/ URI', () => { + expect(itemToItem.destinationNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${ITEM_B}`) + expect(itemToItem.destinationNodeURI.identifier).toBe(ITEM_B) + }) +}) diff --git a/apps/editor/src/domain/framework/model/types.ts b/apps/editor/src/domain/framework/model/types.ts index 0f308ca..30a88ae 100644 --- a/apps/editor/src/domain/framework/model/types.ts +++ b/apps/editor/src/domain/framework/model/types.ts @@ -33,6 +33,8 @@ export type FrameworkMetadata = { lastChangeDateTime?: string /** CASE licenseURI — link to the CFLicense governing this framework */ licenseURI?: { title?: string; identifier?: string; uri: string } + /** Arbitrary CASE extensions — preserved across load/save round-trips */ + extensions?: Record } export type ItemMetadata = Record diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index e7bdb59..3b60fdd 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -186,6 +186,71 @@ export class CaseApiClient { throw new Error('Unexpected import response shape') } + /** + * Import a CFPackage from the Credential Engine Registry via the OpenCASE backend. + * + * Accepts a full registry resource URL or bare CTID. The backend fetches the + * CTDL-ASN graph, translates it to CASE, and stores it in the tenant's framework store. + */ + async importCfPackageFromRegistry(params: { + tenantId: string + registryUrl: string + caseVersion?: 'v1p0' | 'v1p1' + }): Promise<{ status: string; id: string; version: number; itemCount: number; associationCount: number }> { + const v = params.caseVersion ?? 'v1p1' + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/ims/case/${v}/CFPackages/import-from-registry` + + const res = (await this._http.post(url, { registryUrl: params.registryUrl })) as unknown + + if (res && typeof res === 'object') { + const obj = res as { status?: string; id?: string; version?: number; itemCount?: number; associationCount?: number } + return { + status: obj.status ?? 'imported', + id: obj.id ?? '', + version: obj.version ?? 1, + itemCount: obj.itemCount ?? 0, + associationCount: obj.associationCount ?? 0, + } + } + + throw new Error('Unexpected import response shape') + } + + /** + * Preview a framework from the Credential Engine Registry without saving it. + * Returns competency items for placement as read-only reference nodes on the canvas. + */ + async previewRegistryFramework(params: { + tenantId: string + registryUrl: string + caseVersion?: 'v1p0' | 'v1p1' + }): Promise<{ frameworkTitle: string; items: Array<{ id: string; fullStatement: string; codedNotation?: string; ctdlUri: string; ctdlCtid: string }> }> { + const v = params.caseVersion ?? 'v1p1' + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/ims/case/${v}/CFPackages/preview-registry` + + const res = (await this._http.post(url, { registryUrl: params.registryUrl })) as unknown + + if (res && typeof res === 'object') { + const obj = res as { frameworkTitle?: string; items?: unknown[] } + return { + frameworkTitle: obj.frameworkTitle ?? 'Registry Framework', + items: Array.isArray(obj.items) + ? (obj.items as Array<{ id?: string; fullStatement?: string; codedNotation?: string; ctdlUri?: string; ctdlCtid?: string }>) + .filter((item) => item.id && item.fullStatement && item.ctdlUri && item.ctdlCtid) + .map((item) => ({ + id: item.id!, + fullStatement: item.fullStatement!, + codedNotation: item.codedNotation, + ctdlUri: item.ctdlUri!, + ctdlCtid: item.ctdlCtid!, + })) + : [], + } + } + + throw new Error('Unexpected preview response shape') + } + /** * List all CFDocuments from the CASE API. * diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index ca6773f..433e364 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -15,6 +15,7 @@ import ConfirmLeaveDialog from '@/ui/editor/components/ConfirmLeaveDialog' import SettingsModal from '@/ui/editor/components/SettingsModal' import FloatingAddButton from '@/ui/editor/components/FloatingAddButton' import AddExternalFrameworkDialog from '@/ui/editor/components/AddExternalFrameworkDialog' +import ImportFromRegistryDialog from '@/ui/home/ImportFromRegistryDialog' import ViewCFPackageDialog from '@/ui/editor/components/ViewCFPackageDialog' import { useEditor } from '@/ui/editor/state/EditorContext' import type { CaseEditorNodeType, CaseEditorEdge } from '@/ui/editor/reactflow/types' @@ -30,9 +31,11 @@ type EditorCanvasProps = { isPublishedToOpenCase?: boolean /** Archive the current framework on the server and navigate home */ onArchiveFramework?: () => Promise + /** Fetch Registry competencies for canvas alignment (no library save) */ + onImportFromRegistry?: (registryUrl: string) => Promise<{ frameworkTitle: string; items: Array<{ id: string; fullStatement: string; codedNotation?: string; ctdlUri: string; ctdlCtid: string }> }> } -export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onImportFromRegistry }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -75,8 +78,12 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen updateSettings, addDetachedItem, addExternalFramework, + addRegistryFramework, applyHierarchyLayout, applyStarLayout, + isImported, + isLocked, + enableEditing, } = useEditor() const reactFlowWrapRef = useRef(null) @@ -85,8 +92,10 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const didInitialViewportRef = useRef(false) const [leaveOpen, setLeaveOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) + const [forkConfirmOpen, setForkConfirmOpen] = useState(false) const [externalFwDialogOpen, setExternalFwDialogOpen] = useState(false) const [externalFwViewportCenter, setExternalFwViewportCenter] = useState<{ x: number; y: number } | undefined>(undefined) + const [registryImportOpen, setRegistryImportOpen] = useState(false) const [cfPackageDialogOpen, setCfPackageDialogOpen] = useState(false) const [generatedCfPackage, setGeneratedCfPackage] = useState(null) const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') @@ -143,9 +152,9 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const handleViewCFPackage = useCallback(() => { const { nodes: n, edges: e } = graphRef.current const ctx = saveCtxRef.current - const { framework, layout } = fromEditorGraph({ graph: { nodes: n, edges: e } }) + const { framework, layout, registryNodes, externalNodes } = fromEditorGraph({ graph: { nodes: n, edges: e } }) const cfPackage = frameworkToCfPackage({ - framework, layout, incrementVersion: false, + framework, layout, incrementVersion: false, registryNodes, externalNodes, caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, @@ -158,9 +167,9 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const handleSave = useCallback(async () => { const { nodes: n, edges: e } = graphRef.current const ctx = saveCtxRef.current - const { framework, layout } = fromEditorGraph({ graph: { nodes: n, edges: e } }) + const { framework, layout, registryNodes, externalNodes } = fromEditorGraph({ graph: { nodes: n, edges: e } }) const cfPackage = frameworkToCfPackage({ - framework, layout, incrementVersion: true, + framework, layout, incrementVersion: true, registryNodes, externalNodes, caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, @@ -1138,6 +1147,9 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen cfAssociationGroupings={inUseGroupings} activeGroupingFilter={activeGroupingFilter} onSetGroupingFilter={setActiveGroupingFilter} + isImported={isImported} + isLocked={isLocked} + onEnableEditing={() => setForkConfirmOpen(true)} />
@@ -1211,6 +1223,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen ensureCfSubject={ensureCfSubject} cfConcepts={cfConcepts} ensureCfConcept={ensureCfConcept} + readOnly={isLocked} /> - { - const viewportCenter = getViewportCenter() - addDetachedItem(viewportCenter) - }} - onAddExternalFramework={() => { - const viewportCenter = getViewportCenter() - setExternalFwViewportCenter(viewportCenter) - setExternalFwDialogOpen(true) + {!isLocked ? ( + { + const viewportCenter = getViewportCenter() + addDetachedItem(viewportCenter) + }} + onAddExternalFramework={() => { + const viewportCenter = getViewportCenter() + setExternalFwViewportCenter(viewportCenter) + setExternalFwDialogOpen(true) + }} + onImportFromRegistry={onImportFromRegistry ? () => setRegistryImportOpen(true) : undefined} + sidePanelOpen={Boolean(selectedNode || selectedEdge || (selectedNodeIds.length + selectedEdgeIds.length > 1))} + /> + ) : null} + + setForkConfirmOpen(false)} + onConfirm={() => { + setForkConfirmOpen(false) + enableEditing() }} - sidePanelOpen={Boolean(selectedNode || selectedEdge || (selectedNodeIds.length + selectedEdgeIds.length > 1))} /> + {onImportFromRegistry && ( + setRegistryImportOpen(false)} + onImport={async (registryUrl) => { + const result = await onImportFromRegistry(registryUrl) + // Place competencies as read-only registry reference nodes on canvas + const viewportCenter = getViewportCenter() + addRegistryFramework( + result.items.map((item) => ({ + ctdlUri: item.ctdlUri, + ctdlCtid: item.ctdlCtid, + fullStatement: item.fullStatement, + codedNotation: item.codedNotation, + frameworkTitle: result.frameworkTitle, + })), + viewportCenter, + ) + setRegistryImportOpen(false) + return { status: 'placed', version: 1, itemCount: result.items.length, associationCount: 0, id: '' } + }} + /> + )} + { diff --git a/apps/editor/src/ui/editor/components/CanvasHeader.tsx b/apps/editor/src/ui/editor/components/CanvasHeader.tsx index 71ad80c..b6513a1 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, LockClosedIcon } from '@heroicons/react/24/solid' import { Button } from '@/ui/shared/components/ui/button' import type { CFAssociationGrouping } from '@/domain/case/types' @@ -203,6 +203,9 @@ export default function CanvasHeader({ cfAssociationGroupings, activeGroupingFilter, onSetGroupingFilter, + isImported, + isLocked, + onEnableEditing, }: { frameworkTitle: string frameworkSubtitle?: string @@ -234,6 +237,12 @@ export default function CanvasHeader({ activeGroupingFilter?: string | null /** Set the active grouping filter */ onSetGroupingFilter?: (_id: string | null) => void + /** True when this framework was imported from a registry (forked or not). */ + isImported?: boolean + /** True when the framework is an unforked registry import — content is read-only. */ + isLocked?: boolean + /** Called when the user chooses to fork an imported framework for editing. */ + onEnableEditing?: () => void }) { // Build user menu items const userMenuItems: (MenuItem | 'divider')[] = [] @@ -292,10 +301,32 @@ export default function CanvasHeader({
{frameworkTitle} + {isImported && !isLocked ? ( + + Derived + + ) : null}
{frameworkSubtitle ?
{frameworkSubtitle}
: null}
+ {isImported && isLocked ? ( + <> + + + Read-only + + {onEnableEditing ? ( + + ) : null} + + ) : null} + {/* Save button / status indicator */} {onSave ? (
diff --git a/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx index 0bf7f8c..8c0e22e 100644 --- a/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/EdgePropertiesPanel.tsx @@ -45,9 +45,11 @@ type Props = { onFlipEdge?: (_edgeId: string) => void cfAssociationGroupings?: CFAssociationGrouping[] ensureCfAssociationGrouping?: (_title: string) => CFAssociationGrouping | null + /** When true, association fields are shown read-only (imported, unforked framework). */ + readOnly?: boolean } -export default memo(function EdgePropertiesPanel({ edge, nodes, onClose, onChangeEdge, onFlipEdge, cfAssociationGroupings = [], ensureCfAssociationGrouping }: Readonly) { +export default memo(function EdgePropertiesPanel({ edge, nodes, onClose, onChangeEdge, onFlipEdge, cfAssociationGroupings = [], ensureCfAssociationGrouping, readOnly = false }: Readonly) { const [copied, setCopied] = useState(null) const [customType, setCustomType] = useState('') const [groupingInput, setGroupingInput] = useState('') @@ -168,7 +170,7 @@ export default memo(function EdgePropertiesPanel({ edge, nodes, onClose, onChang
{edge ? ( -
+
{/* ── Connection ── */} @@ -379,7 +381,7 @@ export default memo(function EdgePropertiesPanel({ edge, nodes, onClose, onChang
-
+ ) : null} ) diff --git a/apps/editor/src/ui/editor/components/FloatingAddButton.tsx b/apps/editor/src/ui/editor/components/FloatingAddButton.tsx index ab4d8a1..d4bb70a 100644 --- a/apps/editor/src/ui/editor/components/FloatingAddButton.tsx +++ b/apps/editor/src/ui/editor/components/FloatingAddButton.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' -import { PlusIcon, DocumentPlusIcon, LinkIcon } from '@heroicons/react/24/solid' +import { PlusIcon, DocumentPlusIcon, LinkIcon, CloudArrowDownIcon } from '@heroicons/react/24/solid' type AddOption = { id: string @@ -27,16 +27,24 @@ const getAddOptions = (): AddOption[] => [ icon: LinkIcon, shortcut: isMac ? '⌘F' : 'Ctrl+F', }, + { + id: 'registry', + label: 'Import from Registry', + description: 'Import a framework from the Credential Engine Registry', + icon: CloudArrowDownIcon, + shortcut: isMac ? '⌘R' : 'Ctrl+R', + }, ] type Props = { onAddItem: () => void onAddExternalFramework: () => void + onImportFromRegistry?: () => void /** Whether the side panel is open - shifts the button left */ sidePanelOpen?: boolean } -export default function FloatingAddButton({ onAddItem, onAddExternalFramework, sidePanelOpen }: Readonly) { +export default function FloatingAddButton({ onAddItem, onAddExternalFramework, onImportFromRegistry, sidePanelOpen }: Readonly) { const [open, setOpen] = useState(false) const rootRef = useRef(null) const addOptions = useMemo(() => getAddOptions(), []) @@ -68,6 +76,8 @@ export default function FloatingAddButton({ onAddItem, onAddExternalFramework, s onAddItem() } else if (optionId === 'external') { onAddExternalFramework() + } else if (optionId === 'registry') { + onImportFromRegistry?.() } } diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index 763ee53..f083dde 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -11,6 +11,8 @@ import type { CaseItemNodeType, ExternalFrameworkNodeType, ExternalFrameworkNodeData, + RegistryItemNodeType, + RegistryItemNodeData, } from '../reactflow/types' import type { CFConcept, CFDocument, CFItem, CFItemType, CFLicense, CFSubject } from '@/domain/case/types' import type { ComboboxOption } from '@/ui/shared/components/ui/combobox-input' @@ -25,6 +27,23 @@ const INPUT_CLS = 'w-full rounded-xl border border-black/15 bg-white px-3 py-2.5 const LABEL_CLS = 'mb-1.5 block text-sm font-medium text-slate-700' const HINT_CLS = 'mt-1.5 text-sm text-slate-500' +/** + * Extract Registry provenance from the `source` block of an `ext:opencase` extension. + * Returns undefined when the node has no Registry provenance. + */ +function readRegistryProvenance( + ext: Record | undefined, +): { uri?: string; ctid?: string; registry?: string } | undefined { + const source = ext?.source as Record | undefined + if (!source) return undefined + const str = (v: unknown) => (typeof v === 'string' ? v : undefined) + const uri = str(source.uri) + const ctid = str(source.ctid) + const registry = str(source.registry) + if (!uri && !ctid) return undefined + return { uri, ctid, registry } +} + type Props = { node: CaseEditorNodeType | null onClose?: () => void @@ -38,11 +57,14 @@ type Props = { ensureCfSubject?: (_title: string) => CFSubject | null cfConcepts?: CFConcept[] ensureCfConcept?: (_title: string) => CFConcept | null + /** When true, the item/framework fields are shown read-only (imported, unforked). */ + readOnly?: boolean } export default memo(function NodePropertiesPanel({ node, onClose, onChangeNode, onViewCFPackage, isPublishedToOpenCase, availableLicenses, cfItemTypes = [], ensureCfItemType, cfSubjects = [], ensureCfSubject, cfConcepts = [], ensureCfConcept, + readOnly = false, }: Readonly) { const [copied, setCopied] = useState(null) const [conceptInput, setConceptInput] = useState('') @@ -57,13 +79,25 @@ export default memo(function NodePropertiesPanel({ const isItemNode = (n: CaseEditorNodeType): n is CaseItemNodeType => n.type === 'caseItemNode' const isFrameworkNode = (n: CaseEditorNodeType): n is CaseFrameworkNodeType => n.type === 'caseFrameworkNode' const isExternalFrameworkNode = (n: CaseEditorNodeType): n is ExternalFrameworkNodeType => n.type === 'externalFrameworkNode' + const isRegistryNode = (n: CaseEditorNodeType): n is RegistryItemNodeType => n.type === 'registryItemNode' const isFramework = Boolean(node && isFrameworkNode(node)) const isExternalFramework = Boolean(node && isExternalFrameworkNode(node)) + const isRegistry = Boolean(node && isRegistryNode(node)) const cfItem: CFItem | undefined = node && isItemNode(node) ? node.data.cfItem : undefined const cfDocument: CFDocument | undefined = node && isFrameworkNode(node) ? node.data.cfDocument : undefined const externalData: ExternalFrameworkNodeData | undefined = node && isExternalFrameworkNode(node) ? node.data : undefined + const registryData: RegistryItemNodeData | undefined = node && isRegistryNode(node) ? node.data : undefined + + // Registry provenance from the ext:opencase extension (present on frameworks/items + // imported from a CTDL registry). Surfaced read-only in Technical details. + const itemOpencaseExt = (cfItem?.extensions as Record | undefined)?.['ext:opencase'] as Record | undefined + const docOpencaseExt = (cfDocument?.extensions as Record | undefined)?.['ext:opencase'] as Record | undefined + const registryProvenance = readRegistryProvenance(itemOpencaseExt) ?? readRegistryProvenance(docOpencaseExt) + const registryCtid = registryProvenance?.ctid + const registryCtdlUri = registryProvenance?.uri + const registrySource = registryProvenance?.registry useEffect(() => { setConceptInput(cfItem?.conceptKeywordsURI?.title ?? '') @@ -139,15 +173,19 @@ export default memo(function NodePropertiesPanel({ /* ── Title for the header ── */ const headerTitle = isExternalFramework ? (externalData?.title || 'External framework') - : isFramework - ? (cfDocument?.title ?? 'Untitled framework') - : (cfItem?.humanCodingScheme ?? cfItem?.alternativeLabel ?? cfItem?.CFItemType ?? 'Untitled item') + : isRegistry + ? (registryData?.codedNotation || registryData?.frameworkTitle || 'Registry competency') + : isFramework + ? (cfDocument?.title ?? 'Untitled framework') + : (cfItem?.humanCodingScheme ?? cfItem?.alternativeLabel ?? cfItem?.CFItemType ?? 'Untitled item') const headerSubtitle = isExternalFramework ? 'External reference' - : isFramework - ? 'Framework' - : (cfItem?.CFItemType ?? 'Item') + : isRegistry + ? 'Registry competency' + : isFramework + ? 'Framework' + : (cfItem?.CFItemType ?? 'Item') return (
- ) : node ? ( + ) : node && isRegistry ? (
+ {/* ── Statement (read-only) ── */} + +