From fce44d29c2e1e2fe31d8a54d414e598e3c33018c Mon Sep 17 00:00:00 2001 From: Jeff Grann Date: Thu, 6 Aug 2026 12:02:55 -0500 Subject: [PATCH 1/4] feat(opencase): import CTDL-ASN frameworks from the Credential Registry Adds server-side import of a competency framework from a CTDL-ASN registry resource into a CASE CFPackage, plus the supporting configuration. - CredentialRegistryClient fetches the CTDL-ASN @graph. The default registry origin is configurable via CREDENTIAL_REGISTRY_BASE_URL; a full resource URL passed to import overrides it per request (enables prod/sandbox/self-hosted registries without reconfiguration). - CtdlAsnToCaseMapper maps ceasn:CompetencyFramework / ceasn:Competency graphs to a CASE CFPackage, recording registry provenance under a unified ext:opencase.source block (uri / ctid / registry / format) on the CFDocument and every CFItem, and setting officialSourceURL + sourcePackageURI. - ImportFrameworkFromRegistry and PreviewRegistryFramework endpoints, wired through the management controller/routes and DI container. - The document index lifts isModifiedFromSource / sourcePackageURI from ext:opencase; CreateFramework no longer force-marks registry-managed frameworks modified on a layout-only save (it trusts the editor's explicit fork flag; legacy CASE-package imports keep the previous behaviour). - docker-compose + docs/env.example expose CREDENTIAL_REGISTRY_BASE_URL. Co-Authored-By: Claude Opus 4.8 --- .../case/endpoints/CreateFramework.ts | 26 ++- .../endpoints/ImportFrameworkFromRegistry.ts | 64 ++++++ .../endpoints/PreviewRegistryFramework.ts | 54 +++++ .../src/infrastructure/config/Config.ts | 9 + .../ctdlasn/CtdlAsnToCaseMapper.ts | 207 ++++++++++++++++++ .../http/CredentialRegistryClient.ts | 84 +++++++ .../CFPackagesManagementController.ts | 49 ++++- .../CFPackagesManagementController.test.ts | 4 + .../interfaces/http/http-management/routes.ts | 16 ++ apps/opencase/src/wiring/container.ts | 11 +- docker-compose.yml | 3 + docs/env.example | 9 + 12 files changed, 526 insertions(+), 10 deletions(-) create mode 100644 apps/opencase/src/application/case/endpoints/ImportFrameworkFromRegistry.ts create mode 100644 apps/opencase/src/application/case/endpoints/PreviewRegistryFramework.ts create mode 100644 apps/opencase/src/infrastructure/ctdlasn/CtdlAsnToCaseMapper.ts create mode 100644 apps/opencase/src/infrastructure/http/CredentialRegistryClient.ts diff --git a/apps/opencase/src/application/case/endpoints/CreateFramework.ts b/apps/opencase/src/application/case/endpoints/CreateFramework.ts index f9b53c3..0e3a142 100644 --- a/apps/opencase/src/application/case/endpoints/CreateFramework.ts +++ b/apps/opencase/src/application/case/endpoints/CreateFramework.ts @@ -149,14 +149,24 @@ export class CreateFramework { const existingOpencase = (existingExt['ext:opencase'] && typeof existingExt['ext:opencase'] === 'object') ? existingExt['ext:opencase'] : {} - cfDocPayload = { - ...cfDocPayload, - extensions: { - ...existingExt, - 'ext:opencase': { - ...existingOpencase, - sourcePackageURI: existingMeta.sourcePackageURI, - isModifiedFromSource: true, + // Registry (CTDL) imports manage the modified/derived flag explicitly in the + // editor (read-only until the user forks, which rewrites `source` → `derivedFrom` + // and sets isModifiedFromSource). For those, trust the incoming flag so a + // layout-only save of a still-locked framework isn't wrongly marked "Forked". + const isRegistryManaged = Boolean( + (existingOpencase as any).source || (existingOpencase as any).derivedFrom, + ) + if (!isRegistryManaged) { + // Legacy CASE-package imports: any editor save marks the copy modified. + cfDocPayload = { + ...cfDocPayload, + extensions: { + ...existingExt, + 'ext:opencase': { + ...existingOpencase, + sourcePackageURI: existingMeta.sourcePackageURI, + isModifiedFromSource: true, + } } } } diff --git a/apps/opencase/src/application/case/endpoints/ImportFrameworkFromRegistry.ts b/apps/opencase/src/application/case/endpoints/ImportFrameworkFromRegistry.ts new file mode 100644 index 0000000..8a5dd19 --- /dev/null +++ b/apps/opencase/src/application/case/endpoints/ImportFrameworkFromRegistry.ts @@ -0,0 +1,64 @@ +import type { CFPackageRepository } from '../ports/CFPackageRepository' +import { CredentialRegistryClient } from '../../../infrastructure/http/CredentialRegistryClient' +import { mapCtdlGraphToCasePackage } from '../../../infrastructure/ctdlasn/CtdlAsnToCaseMapper' +import { CaseVersion, TenantId } from '../../../domain/case/value-objects/Identifiers' +import { CFDocument } from '../../../domain/case/entities/CFDocument' +import { CFItem } from '../../../domain/case/entities/CFItem' +import { CFAssociation } from '../../../domain/case/entities/CFAssociation' +import { CFPackage } from '../../../domain/case/entities/CFPackage' +import { logger } from '../../../infrastructure/logging/Logger' + +export interface ImportFrameworkFromRegistryCommand { + tenantId: TenantId + caseVersion: CaseVersion + registryUrl: string +} + +export interface ImportFrameworkFromRegistryResult { + docId: string + version: number + itemCount: number + associationCount: number +} + +export class ImportFrameworkFromRegistry { + constructor ( + private readonly pkgRepo: CFPackageRepository, + private readonly registryClient: CredentialRegistryClient + ) {} + + async execute (cmd: ImportFrameworkFromRegistryCommand): Promise { + const { tenantId, caseVersion, registryUrl } = cmd + + logger.info({ tenantId, caseVersion, registryUrl }, 'Importing framework from Credential Registry') + + const graph = await this.registryClient.fetchGraph(registryUrl) + const raw = mapCtdlGraphToCasePackage(graph) + + const document = CFDocument.fromRaw(tenantId, caseVersion, raw.CFDocument) + const docId = document.sourcedId + const docURI = document.toJSON().uri + + const items = raw.CFItems.map(i => + CFItem.fromRaw(tenantId, caseVersion, i, docId, docURI) + ) + const associations = raw.CFAssociations.map(a => + CFAssociation.fromRaw(tenantId, caseVersion, a) + ) + + const pkg = new CFPackage({ document, items, associations, rubrics: [], definitions: null }) + await this.pkgRepo.saveNewVersion(tenantId, caseVersion, pkg) + + logger.info( + { tenantId, caseVersion, docId, itemCount: items.length, associationCount: associations.length }, + 'Successfully imported framework from Credential Registry' + ) + + return { + docId, + version: 1, + itemCount: items.length, + associationCount: associations.length + } + } +} diff --git a/apps/opencase/src/application/case/endpoints/PreviewRegistryFramework.ts b/apps/opencase/src/application/case/endpoints/PreviewRegistryFramework.ts new file mode 100644 index 0000000..b187f2f --- /dev/null +++ b/apps/opencase/src/application/case/endpoints/PreviewRegistryFramework.ts @@ -0,0 +1,54 @@ +import type { CredentialRegistryClient } from '../../../infrastructure/http/CredentialRegistryClient' +import { mapCtdlGraphToCasePackage } from '../../../infrastructure/ctdlasn/CtdlAsnToCaseMapper' +import { logger } from '../../../infrastructure/logging/Logger' + +export interface RegistryPreviewItem { + id: string + fullStatement: string + codedNotation?: string + ctdlUri: string + ctdlCtid: string +} + +export interface PreviewRegistryFrameworkResult { + frameworkTitle: string + items: RegistryPreviewItem[] +} + +export class PreviewRegistryFramework { + constructor (private readonly registryClient: CredentialRegistryClient) {} + + async execute (cmd: { registryUrl: string }): Promise { + logger.info({ registryUrl: cmd.registryUrl }, 'Previewing framework from Credential Registry') + + const graph = await this.registryClient.fetchGraph(cmd.registryUrl) + const raw = mapCtdlGraphToCasePackage(graph) + + const frameworkTitle = typeof raw.CFDocument.title === 'string' ? raw.CFDocument.title : 'Unknown Framework' + + const items: RegistryPreviewItem[] = raw.CFItems + .map((item: Record): RegistryPreviewItem | null => { + const id: string = typeof item.sourcedId === 'string' ? item.sourcedId : typeof item.identifier === 'string' ? item.identifier : '' + const fullStatement: string = typeof item.fullStatement === 'string' ? item.fullStatement : '' + if (!id || !fullStatement) return null + + const ext = (item.extensions?.['ext:opencase'] ?? {}) as Record + const src = (ext.source ?? {}) as Record + const ctdlUri = typeof src.uri === 'string' ? src.uri : '' + const ctdlCtid = typeof src.ctid === 'string' ? src.ctid : '' + if (!ctdlUri) return null + + return { + id, + fullStatement, + codedNotation: typeof item.humanCodingScheme === 'string' ? item.humanCodingScheme : undefined, + ctdlUri, + ctdlCtid, + } + }) + .filter((item): item is RegistryPreviewItem => item !== null) + + logger.info({ frameworkTitle, itemCount: items.length }, 'Registry preview complete') + return { frameworkTitle, items } + } +} diff --git a/apps/opencase/src/infrastructure/config/Config.ts b/apps/opencase/src/infrastructure/config/Config.ts index d705041..0edb036 100644 --- a/apps/opencase/src/infrastructure/config/Config.ts +++ b/apps/opencase/src/infrastructure/config/Config.ts @@ -39,6 +39,13 @@ export interface AppConfig { smtpHost?: string; smtpPort?: string; smtpFrom?: string; + + /** + * Default CTDL registry origin used to resolve bare CTIDs on import + * (e.g. https://credentialengineregistry.org, or a sandbox/self-hosted registry). + * A full resource URL passed to import overrides this per request. + */ + credentialRegistryBaseUrl: string; } export function loadConfig(): AppConfig { @@ -78,6 +85,8 @@ export function loadConfig(): AppConfig { smtpHost: process.env.SMTP_HOST ?? (isProduction ? undefined : 'mailpit'), smtpPort: process.env.SMTP_PORT ?? '1025', smtpFrom: process.env.SMTP_FROM ?? 'noreply@opencase.local', + + credentialRegistryBaseUrl: process.env.CREDENTIAL_REGISTRY_BASE_URL ?? 'https://credentialengineregistry.org', }; } diff --git a/apps/opencase/src/infrastructure/ctdlasn/CtdlAsnToCaseMapper.ts b/apps/opencase/src/infrastructure/ctdlasn/CtdlAsnToCaseMapper.ts new file mode 100644 index 0000000..c75d7eb --- /dev/null +++ b/apps/opencase/src/infrastructure/ctdlasn/CtdlAsnToCaseMapper.ts @@ -0,0 +1,207 @@ +import { randomUUID } from 'node:crypto' +import type { CtdlGraph } from '../http/CredentialRegistryClient' + +export interface CtdlCasePackage { + CFDocument: Record + CFItems: Record[] + CFAssociations: Record[] +} + +// Extract a display string from a CTDL language-tagged value or plain string +function getLangValue (val: any, preferredLang = 'en-us'): string | undefined { + if (!val) return undefined + if (typeof val === 'string') return val + if (typeof val === 'object' && !Array.isArray(val)) { + if (val[preferredLang]) return String(val[preferredLang]) + if (val['en-US']) return String(val['en-US']) + if (val['en']) return String(val['en']) + const first = Object.values(val)[0] + return typeof first === 'string' ? first : undefined + } + return undefined +} + +// Normalize a CTDL value to an array of string URIs +function toUriArray (val: any): string[] { + if (!val) return [] + if (typeof val === 'string') return [val] + if (typeof val === 'object' && val['@id']) return [String(val['@id'])] + if (Array.isArray(val)) { + return val + .map((v: any) => (typeof v === 'string' ? v : v?.['@id'])) + .filter((v): v is string => typeof v === 'string') + } + return [] +} + +function toStringArray (val: any): string[] { + if (!val) return [] + if (typeof val === 'string') return [val] + if (Array.isArray(val)) return val.filter((v): v is string => typeof v === 'string') + return [] +} + +// Origin of a resource @id (e.g. "https://credentialengineregistry.org"), used to +// record which registry a node was imported from — this is what makes provenance +// unambiguous when frameworks are pulled from more than one CTDL registry. +function deriveRegistryBase (uri: string | undefined): string | undefined { + if (!uri) return undefined + try { return new URL(uri).origin } catch { return undefined } +} + +// Unified Registry provenance block, written identically on CFDocument and CFItem. +// The @id URI is authoritative (resolvable + registry-qualified); ctid is a derivable +// convenience and omitted when the source doesn't provide one (non-CE registries). +function buildSource (uri: string, ctid: string | undefined, registry: string | undefined): Record { + return { + uri, + ...(ctid ? { ctid } : {}), + ...(registry ? { registry } : {}), + format: 'ctdl-asn', + } +} + +export function mapCtdlGraphToCasePackage (graph: CtdlGraph): CtdlCasePackage { + const nodes = graph['@graph'] + + const framework = nodes.find(n => n['@type'] === 'ceasn:CompetencyFramework') + if (!framework) { + throw new Error('No ceasn:CompetencyFramework node found in Credential Registry graph') + } + + const competencies = nodes.filter(n => n['@type'] === 'ceasn:Competency') + const frameworkUri: string = framework['@id'] + const now = new Date().toISOString() + const ctid: string = framework['ceterms:ctid'] ?? '' + const registryBase = deriveRegistryBase(frameworkUri) + + // Assign a stable UUID to every node up front so associations can reference them + const uuidByCtdlUri = new Map() + const docUuid = randomUUID() + uuidByCtdlUri.set(frameworkUri, docUuid) + for (const comp of competencies) { + uuidByCtdlUri.set(comp['@id'], randomUUID()) + } + + // Publisher / creator display name — prefer publisherName (language-tagged string) + const publisherName = + getLangValue(framework['ceasn:publisherName']) ?? + toUriArray(framework['ceasn:publisher'])[0] ?? + 'Unknown' + + const language = toStringArray(framework['ceasn:inLanguage'])[0] ?? 'en' + + const title = getLangValue(framework['ceasn:name']) ?? 'Untitled Framework' + + // --- CFDocument --- + const CFDocument: Record = { + sourcedId: docUuid, + title, + description: getLangValue(framework['ceasn:description']), + creator: publisherName, + publisher: publisherName, + language, + officialSourceURL: frameworkUri, + lastChangeDateTime: framework['ceasn:dateModified'] ?? now, + extensions: { + 'ext:opencase': { + source: buildSource(frameworkUri, ctid || undefined, registryBase), + // Drives the home-screen "Imported"/"Forked" badge (indexed from ext:opencase). + sourcePackageURI: frameworkUri, + importedFrom: 'credential-registry', + importedAt: now, + isModifiedFromSource: false, + ...(toUriArray(framework['ceasn:source'])[0] + ? { derivedFromSource: toUriArray(framework['ceasn:source'])[0] } + : {}) + } + } + } + + // --- CFItems --- + const CFItems: Record[] = [] + + for (const comp of competencies) { + const uuid = uuidByCtdlUri.get(comp['@id']) + if (!uuid) continue + + const fullStatement = getLangValue(comp['ceasn:competencyText']) + if (!fullStatement) continue // skip items with no text + + const itemLanguages = toStringArray(comp['ceasn:inLanguage']) + const educationLevelUris = toUriArray(comp['ceasn:educationLevelType']) + const codedNotation = getLangValue(comp['ceasn:codedNotation']) + const competencyLabel = getLangValue(comp['ceasn:competencyLabel']) + + CFItems.push({ + sourcedId: uuid, + fullStatement, + abbreviatedStatement: competencyLabel, + humanCodingScheme: codedNotation, + language: itemLanguages[0] ?? language, + educationLevel: educationLevelUris.length > 0 ? educationLevelUris : undefined, + lastChangeDateTime: now, + CFDocumentURI: { + identifier: docUuid, + title, + uri: undefined // generated by CFItem.fromRaw() + }, + extensions: { + 'ext:opencase': { + source: buildSource(comp['@id'], comp['ceterms:ctid'] ?? undefined, registryBase) + } + } + }) + } + + // Build a title lookup (truncated statement) for use in association LinkData + const titleByUuid = new Map([[docUuid, title]]) + for (const item of CFItems) { + const label = typeof item.fullStatement === 'string' + ? item.fullStatement.substring(0, 120) + : String(item.sourcedId) + titleByUuid.set(item.sourcedId as string, label) + } + + // --- CFAssociations from CTDL hierarchy --- + const CFAssociations: Record[] = [] + + for (const comp of competencies) { + const childUuid = uuidByCtdlUri.get(comp['@id']) + if (!childUuid) continue + + const childTitle = titleByUuid.get(childUuid) ?? childUuid + const isChildOfUris = toUriArray(comp['ceasn:isChildOf']) + const isTopChildOfUris = toUriArray(comp['ceasn:isTopChildOf']) + + if (isChildOfUris.length > 0) { + // Item has an explicit parent — create isChildOf to the parent node + for (const parentUri of isChildOfUris) { + const parentUuid = uuidByCtdlUri.get(parentUri) + if (!parentUuid) continue // parent not in this graph + + CFAssociations.push({ + sourcedId: randomUUID(), + associationType: 'isChildOf', + originNodeURI: { identifier: childUuid, title: childTitle }, + destinationNodeURI: { + identifier: parentUuid, + title: titleByUuid.get(parentUuid) ?? parentUuid + }, + lastChangeDateTime: now + }) + } + } else if (isTopChildOfUris.includes(frameworkUri)) { + // Top-level item — parent is the CFDocument itself + CFAssociations.push({ + sourcedId: randomUUID(), + associationType: 'isChildOf', + originNodeURI: { identifier: childUuid, title: childTitle }, + destinationNodeURI: { identifier: docUuid, title }, + lastChangeDateTime: now + }) + } + } + + return { CFDocument, CFItems, CFAssociations } +} diff --git a/apps/opencase/src/infrastructure/http/CredentialRegistryClient.ts b/apps/opencase/src/infrastructure/http/CredentialRegistryClient.ts new file mode 100644 index 0000000..568d45c --- /dev/null +++ b/apps/opencase/src/infrastructure/http/CredentialRegistryClient.ts @@ -0,0 +1,84 @@ +import { logger } from '../logging/Logger' + +export interface CtdlGraph { + '@context': string | Record + '@graph': Record[] +} + +export class CredentialRegistryClient { + private static readonly DEFAULT_BASE_URL = 'https://credentialengineregistry.org' + private readonly baseUrl: string + private readonly timeout: number + + constructor (config: { baseUrl?: string; timeout?: number } = {}) { + // Normalize away any trailing slash so `${base}/graph/...` is well-formed. + this.baseUrl = (config.baseUrl ?? CredentialRegistryClient.DEFAULT_BASE_URL).replace(/\/+$/, '') + this.timeout = config.timeout ?? 30000 + } + + async fetchGraph (ctidOrUrl: string): Promise { + const ctid = CredentialRegistryClient.extractCtid(ctidOrUrl) + // A full resource URL identifies its own registry (origin); a bare CTID uses the + // configured default. This is what lets a single deployment import from prod, + // sandbox, or a self-hosted CTDL registry without reconfiguration. + const base = CredentialRegistryClient.resolveBase(ctidOrUrl) ?? this.baseUrl + const url = `${base}/graph/${ctid}` + + const controller = new AbortController() + const timeoutId = setTimeout(() => { controller.abort() }, this.timeout) + + try { + logger.info({ url }, 'Fetching Credential Registry graph') + + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + const text = await response.text().catch(() => 'Unknown error') + throw new Error( + `Credential Registry fetch failed: ${response.status} ${response.statusText}. ${text}` + ) + } + + const data = await response.json() as CtdlGraph + + if (!Array.isArray(data['@graph'])) { + throw new Error('Invalid Credential Registry response: missing @graph array') + } + + logger.info({ url, nodeCount: data['@graph'].length }, 'Successfully fetched Credential Registry graph') + return data + } catch (error: any) { + clearTimeout(timeoutId) + if (error.name === 'AbortError') { + throw new Error(`Request timeout after ${this.timeout}ms`) + } + logger.error({ url, error: error.message }, 'Failed to fetch Credential Registry graph') + throw error + } + } + + /** Returns the origin (scheme://host[:port]) when the input is a full http(s) URL, else undefined. */ + static resolveBase (ctidOrUrl: string): string | undefined { + try { + const u = new URL(ctidOrUrl) + if (u.protocol === 'http:' || u.protocol === 'https:') return u.origin + } catch { /* not a URL — fall through */ } + return undefined + } + + static extractCtid (ctidOrUrl: string): string { + // Match bare CTIDs (ce-{uuid}) or extract from registry URLs + const match = ctidOrUrl.match(/\bce-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i) + if (match) return match[0] + throw new Error( + `Could not extract a valid CTID from: "${ctidOrUrl}". ` + + 'Expected a Credential Engine CTID (e.g. ce-abf4a1d5-...) or a registry URL containing one.' + ) + } +} 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 a017f70..004a276 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/CFPackagesManagementController.ts @@ -2,6 +2,8 @@ import { type Request, type Response, type RequestHandler } from 'express' import { type CreateFramework } from '../../../../application/case/endpoints/CreateFramework' import { type ImportFrameworkFromEndpoint } from '../../../../application/case/endpoints/ImportFrameworkFromEndpoint' +import { type ImportFrameworkFromRegistry } from '../../../../application/case/endpoints/ImportFrameworkFromRegistry' +import { type PreviewRegistryFramework } from '../../../../application/case/endpoints/PreviewRegistryFramework' import { type DeleteCFDocument } from '../../../../application/case/endpoints/DeleteCFDocument' import { type RestoreFramework } from '../../../../application/case/endpoints/RestoreFramework' import { type ListFrameworks } from '../../../../application/case/endpoints/ListFrameworks' @@ -12,9 +14,11 @@ export class CFPackagesManagementController { constructor ( private readonly createFramework: CreateFramework, private readonly importFramework: ImportFrameworkFromEndpoint, + private readonly importFromRegistryUseCase: ImportFrameworkFromRegistry, private readonly listFrameworks: ListFrameworks, private readonly deleteCFDocument: DeleteCFDocument, - private readonly restoreFrameworkUseCase: RestoreFramework + private readonly restoreFrameworkUseCase: RestoreFramework, + private readonly previewRegistryUseCase?: PreviewRegistryFramework ) {} list: RequestHandler<{ tenantId: string }> = async (req: Request, res: Response) => { @@ -111,6 +115,49 @@ export class CFPackagesManagementController { } } + importFromRegistry: RequestHandler<{ tenantId: string }> = async (req: Request, res: Response) => { + const tenantId = getParam(req, 'tenantId') + const caseVersion = getCaseVersion(req, { default: '1.1' })! + const { registryUrl } = req.body + + if (!registryUrl) { + return res.status(400).json({ error: 'registryUrl is required' }) + } + + try { + if (!tenantId) return res.status(400).json({ error: 'Missing tenantId' }) + const result = await this.importFromRegistryUseCase.execute({ tenantId, caseVersion, registryUrl }) + return res.status(201).json({ + status: 'imported', + id: result.docId, + version: result.version, + itemCount: result.itemCount, + associationCount: result.associationCount + }) + } catch (error: any) { + return res.status(400).json({ error: 'import_failed', message: error.message }) + } + } + + previewFromRegistry: RequestHandler<{ tenantId: string }> = async (req: Request, res: Response) => { + const { registryUrl } = req.body + + if (!registryUrl) { + return res.status(400).json({ error: 'registryUrl is required' }) + } + + if (!this.previewRegistryUseCase) { + return res.status(503).json({ error: 'Preview not available' }) + } + + try { + const result = await this.previewRegistryUseCase.execute({ registryUrl }) + return res.status(200).json(result) + } catch (error: any) { + return res.status(400).json({ error: 'preview_failed', message: error.message }) + } + } + delete: RequestHandler<{ tenantId: string, id: string }> = async (req: Request, res: Response) => { try { const tenantId = (req as any).tenantId ?? req.params.tenantId diff --git a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts index 33c8dd7..9bf6e70 100644 --- a/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts +++ b/apps/opencase/src/interfaces/http/http-management/controllers/__tests__/CFPackagesManagementController.test.ts @@ -5,11 +5,13 @@ import { RestoreFramework } from '../../../../../application/case/endpoints/Rest import { ListFrameworks } from '../../../../../application/case/endpoints/ListFrameworks' import { CreateFramework } from '../../../../../application/case/endpoints/CreateFramework' import { ImportFrameworkFromEndpoint } from '../../../../../application/case/endpoints/ImportFrameworkFromEndpoint' +import { ImportFrameworkFromRegistry } from '../../../../../application/case/endpoints/ImportFrameworkFromRegistry' describe('CFPackagesManagementController', () => { let controller: CFPackagesManagementController let mockCreateFramework: jest.Mocked let mockImportFramework: jest.Mocked + let mockImportFromRegistry: jest.Mocked let mockListFrameworks: jest.Mocked let mockDeleteCFDocument: jest.Mocked let mockRestoreFramework: jest.Mocked @@ -22,6 +24,7 @@ describe('CFPackagesManagementController', () => { beforeEach(() => { mockCreateFramework = { execute: jest.fn() } as any mockImportFramework = { execute: jest.fn() } as any + mockImportFromRegistry = { execute: jest.fn() } as any mockListFrameworks = { execute: jest.fn() } as any mockDeleteCFDocument = { execute: jest.fn() } as any mockRestoreFramework = { execute: jest.fn() } as any @@ -29,6 +32,7 @@ describe('CFPackagesManagementController', () => { controller = new CFPackagesManagementController( mockCreateFramework, mockImportFramework, + mockImportFromRegistry, mockListFrameworks, mockDeleteCFDocument, mockRestoreFramework diff --git a/apps/opencase/src/interfaces/http/http-management/routes.ts b/apps/opencase/src/interfaces/http/http-management/routes.ts index 4d17bb5..e39d6b6 100644 --- a/apps/opencase/src/interfaces/http/http-management/routes.ts +++ b/apps/opencase/src/interfaces/http/http-management/routes.ts @@ -275,6 +275,22 @@ export function registerManagementRoutes (app: Express, deps: ManagementDeps): v '/management/tenants/:tenantId/ims/case/v1p1/CFPackages/import', withCaseVersion('1.1', deps.cfPackagesController.importFromEndpoint as unknown as RequestHandler) ) + app.post( + '/management/tenants/:tenantId/ims/case/v1p0/CFPackages/import-from-registry', + withCaseVersion('1.0', deps.cfPackagesController.importFromRegistry as unknown as RequestHandler) + ) + app.post( + '/management/tenants/:tenantId/ims/case/v1p1/CFPackages/import-from-registry', + withCaseVersion('1.1', deps.cfPackagesController.importFromRegistry as unknown as RequestHandler) + ) + app.post( + '/management/tenants/:tenantId/ims/case/v1p0/CFPackages/preview-registry', + withCaseVersion('1.0', deps.cfPackagesController.previewFromRegistry as unknown as RequestHandler) + ) + app.post( + '/management/tenants/:tenantId/ims/case/v1p1/CFPackages/preview-registry', + withCaseVersion('1.1', deps.cfPackagesController.previewFromRegistry as unknown as RequestHandler) + ) // CASE entity management endpoints (explicit version in the path) app.put( '/management/tenants/:tenantId/ims/case/v1p0/CFDocuments/:id', diff --git a/apps/opencase/src/wiring/container.ts b/apps/opencase/src/wiring/container.ts index 4d8c3e5..423c5f5 100644 --- a/apps/opencase/src/wiring/container.ts +++ b/apps/opencase/src/wiring/container.ts @@ -7,6 +7,8 @@ import { FileFrameworkStore } from '../infrastructure/persistence/file/FileFrame import { FileCFPackageRepository } from '../infrastructure/persistence/file/FileCFPackageRepository' import { CreateFramework } from '../application/case/endpoints/CreateFramework' import { ImportFrameworkFromEndpoint } from '../application/case/endpoints/ImportFrameworkFromEndpoint' +import { ImportFrameworkFromRegistry } from '../application/case/endpoints/ImportFrameworkFromRegistry' +import { PreviewRegistryFramework } from '../application/case/endpoints/PreviewRegistryFramework' import { GetCFPackage } from '../application/case/endpoints/GetCFPackage' import { GetCFDocument } from '../application/case/endpoints/GetCFDocument' import { GetAllCFDocuments } from '../application/case/endpoints/GetAllCFDocuments' @@ -62,6 +64,7 @@ import { ListFrameworks } from '../application/case/endpoints/ListFrameworks' import { ListTenants } from '../application/case/endpoints/ListTenants' import { CreateTenant } from '../application/case/endpoints/CreateTenant' import { CaseApiClient } from '../infrastructure/http/CaseApiClient' +import { CredentialRegistryClient } from '../infrastructure/http/CredentialRegistryClient' import { JsonSchemaValidator } from '../infrastructure/validation/JsonSchemaValidator' import { KeycloakAdminClient } from '../infrastructure/keycloak/KeycloakAdminClient' import { KeycloakTenantProvisioner } from '../infrastructure/keycloak/KeycloakTenantProvisioner' @@ -252,8 +255,12 @@ export async function buildContainer(): Promise { // But log which schemas were successfully registered (if any) } + const credentialRegistryClient = new CredentialRegistryClient({ baseUrl: config.credentialRegistryBaseUrl, timeout: 30000 }) + const createFramework = new CreateFramework(pkgRepo, jsonSchemaValidator, store) const importFramework = new ImportFrameworkFromEndpoint(pkgRepo, caseApiClient, jsonSchemaValidator) + const importFromRegistry = new ImportFrameworkFromRegistry(pkgRepo, credentialRegistryClient) + const previewFromRegistry = new PreviewRegistryFramework(credentialRegistryClient) // Initialize CASE endpoints const getCFPackage = new GetCFPackage(pkgRepo, store) @@ -369,9 +376,11 @@ export async function buildContainer(): Promise { const cfPackagesManagementController = new CFPackagesManagementController( createFramework, importFramework, + importFromRegistry, listFrameworks, deleteCFDocument, - restoreFramework + restoreFramework, + previewFromRegistry ) const tenantsManagementController = new TenantsManagementController( listTenants, diff --git a/docker-compose.yml b/docker-compose.yml index 41ae0be..7c3ed30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -124,6 +124,9 @@ services: - SMTP_HOST=${SMTP_HOST:-mailpit} - SMTP_PORT=${SMTP_PORT:-1025} - SMTP_FROM=noreply@${OPENCASE_HOSTNAME:-opencase.local} + # Default CTDL registry for framework import (bare CTIDs resolve here; a full + # resource URL overrides per request). Point at a sandbox/self-hosted registry as needed. + - CREDENTIAL_REGISTRY_BASE_URL=${CREDENTIAL_REGISTRY_BASE_URL:-https://credentialengineregistry.org} restart: on-failure volumes: - ./apps/opencase/data:/app/data diff --git a/docs/env.example b/docs/env.example index 9f5af2e..0388562 100644 --- a/docs/env.example +++ b/docs/env.example @@ -71,6 +71,15 @@ SMTP_HOST=mailpit SMTP_PORT=1025 # SMTP_FROM is auto-derived as noreply@OPENCASE_HOSTNAME +# ============================================================================= +# CTDL Registry Import +# ============================================================================= + +# Default registry origin used to resolve bare CTIDs when importing a framework. +# Importing with a full resource URL (e.g. https://sandbox.credentialengineregistry.org/resources/ce-…) +# overrides this per request. Point at a sandbox or self-hosted CTDL registry as needed. +CREDENTIAL_REGISTRY_BASE_URL=https://credentialengineregistry.org + # ============================================================================= # Single-Tenant Mode (optional) # ============================================================================= From 0c0a1d9e8f16dd72d22f48210699f1aacc3c72cf Mon Sep 17 00:00:00 2001 From: Jeff Grann Date: Thu, 6 Aug 2026 12:04:36 -0500 Subject: [PATCH 2/4] feat(editor): CASE mapping for registry provenance and registry/external alignment Data/mapping layer for importing registry frameworks and aligning CASE items to external competencies, with round-trip integrity. - Preserve registry provenance across the editor round-trip: carry ext:opencase on the CFDocument snapshot (normalize + snapshot type) and reconcile the nested vs flattened extension shapes on export so provenance survives saves. - Unified provenance: ext:opencase.source on documents and items; forking an imported framework rewrites source -> derivedFrom (provenance.ts) and the reducer's framework/enableEditing action applies it across all nodes. - Alignment associations to BOTH registry and external-framework reference nodes are emitted on export (previously external edges were dropped); the reference nodes are persisted in the CFDocument extension and reconstructed on load, and alignment edges are re-linked to the reconstructed nodes by destination URI (fixes edges/associations being lost across a reload). - Official-format (toOpenCaseFormat) URI fixes: a top-level item's isChildOf now targets the CFDocument (not a bogus /CFItems/ URI), and a registry alignment's destination identifier is the CTID's UUID rather than a hash of the "ce-" string. - Tests: toOpenCaseFormat (destination URIs), externalAlignment (association emitted + node persisted), alignmentRelink (edges re-link on reload). Co-Authored-By: Claude Opus 4.8 --- .../mappers/case/CasePackageSnapshot.ts | 2 + .../mappers/case/caseToDomainFramework.ts | 1 + .../mappers/case/normalizeCasePackage.ts | 1 + .../framework/mappers/case/toCasePackage.ts | 174 +++++++++++++++--- .../mappers/case/toOpenCaseFormat.test.ts | 69 +++++++ .../src/domain/framework/model/types.ts | 2 + .../reactflow/mapping/alignmentRelink.test.ts | 73 ++++++++ .../mapping/externalAlignment.test.ts | 53 ++++++ .../reactflow/mapping/fromEditorGraph.ts | 125 ++++++++++++- .../editor/reactflow/mapping/toReactFlow.ts | 121 +++++++++++- .../reactflow/nodeTypes/RegistryItemNode.tsx | 128 +++++++++++++ .../ui/editor/reactflow/nodeTypes/index.ts | 2 + apps/editor/src/ui/editor/reactflow/types.ts | 24 ++- .../src/ui/editor/state/editorReducer.ts | 69 ++++++- apps/editor/src/ui/editor/state/provenance.ts | 57 ++++++ 15 files changed, 868 insertions(+), 33 deletions(-) create mode 100644 apps/editor/src/application/framework/mappers/case/toOpenCaseFormat.test.ts create mode 100644 apps/editor/src/ui/editor/reactflow/mapping/alignmentRelink.test.ts create mode 100644 apps/editor/src/ui/editor/reactflow/mapping/externalAlignment.test.ts create mode 100644 apps/editor/src/ui/editor/reactflow/nodeTypes/RegistryItemNode.tsx create mode 100644 apps/editor/src/ui/editor/state/provenance.ts 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/ui/editor/reactflow/mapping/alignmentRelink.test.ts b/apps/editor/src/ui/editor/reactflow/mapping/alignmentRelink.test.ts new file mode 100644 index 0000000..3e97764 --- /dev/null +++ b/apps/editor/src/ui/editor/reactflow/mapping/alignmentRelink.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from 'vitest' +import { normalizeCasePackageResponse } from '@/application/framework/mappers/case/normalizeCasePackage' +import { mapCaseSnapshotToDomainFramework } from '@/application/framework/mappers/case/caseToDomainFramework' +import { toReactFlowGraph } from './toReactFlow' + +const DOC = 'd0000000-0000-4000-a000-000000000001' +const ITEM = 'a0000000-0000-4000-a000-000000000002' +const REG_NODE_ID = 'ce-64e007f5-3a53-46a2-9abe-92c8717e62dd' +const REG_URL = `https://credentialengineregistry.org/resources/${REG_NODE_ID}` +const REG_DEST_IDENTIFIER = '64e007f5-3a53-46a2-9abe-92c8717e62dd' // CTID's UUID — deliberately != node id +const EXT_NODE_ID = 'ext_ccss' +const EXT_URL = 'https://example.org/frameworks/ccss' +const NOW = '2026-08-06T00:00:00Z' + +// Simulate a CFPackage as served on reload: reference nodes live in the document +// extension, and alignment associations point at them by URI (their identifier does +// NOT match the reference node's canvas id). The editor must re-link the edges. +function served() { + return { + CFDocument: { + identifier: DOC, uri: `/ims/case/v1p1/CFDocuments/${DOC}`, title: 'FW', caseVersion: '1.1', lastChangeDateTime: NOW, + extensions: { + 'ext:opencase': { + registryNodes: [{ id: REG_NODE_ID, ctdlUri: REG_URL, ctdlCtid: REG_NODE_ID, fullStatement: 'Reg competency' }], + externalNodes: [{ id: EXT_NODE_ID, uri: EXT_URL, title: 'CCSS' }], + }, + }, + }, + CFItems: [ + { identifier: ITEM, uri: `/ims/case/v1p1/CFItems/${ITEM}`, fullStatement: 'My item', CFItemType: 'Competency', lastChangeDateTime: NOW, + CFDocumentURI: { identifier: DOC, uri: `/ims/case/v1p1/CFDocuments/${DOC}` } }, + ], + CFAssociations: [ + { identifier: 'assoc-reg', uri: '/ims/case/v1p1/CFAssociations/assoc-reg', associationType: 'isRelatedTo', + originNodeURI: { identifier: ITEM, uri: `/ims/case/v1p1/CFItems/${ITEM}` }, + destinationNodeURI: { identifier: REG_DEST_IDENTIFIER, uri: REG_URL, title: 'Reg competency' }, + lastChangeDateTime: NOW, + extensions: { 'ext:opencase': { ctdlDestinationUri: REG_URL } } }, + { identifier: 'assoc-ext', uri: '/ims/case/v1p1/CFAssociations/assoc-ext', associationType: 'isRelatedTo', + originNodeURI: { identifier: ITEM, uri: `/ims/case/v1p1/CFItems/${ITEM}` }, + destinationNodeURI: { identifier: 'whatever-hash', uri: EXT_URL, title: 'CCSS' }, + lastChangeDateTime: NOW, + extensions: { 'ext:opencase': { externalDestinationUri: EXT_URL } } }, + ], + } +} + +describe('alignment edge re-link on reload', () => { + const snap = normalizeCasePackageResponse(served())! + const framework = mapCaseSnapshotToDomainFramework(snap) + const { nodes, edges } = toReactFlowGraph({ framework }) + + it('reconstructs both reference nodes', () => { + expect(nodes.some((n) => n.id === REG_NODE_ID && n.type === 'registryItemNode')).toBe(true) + expect(nodes.some((n) => n.id === EXT_NODE_ID && n.type === 'externalFrameworkNode')).toBe(true) + }) + + it('re-links the registry alignment edge to the reconstructed registry node', () => { + expect(edges.some((e) => e.source === ITEM && e.target === REG_NODE_ID)).toBe(true) + }) + + it('re-links the external alignment edge to the reconstructed external node', () => { + expect(edges.some((e) => e.source === ITEM && e.target === EXT_NODE_ID)).toBe(true) + }) + + it('leaves no alignment edge dangling to the raw destination identifier', () => { + const nodeIds = new Set(nodes.map((n) => n.id)) + for (const e of edges) { + expect(nodeIds.has(e.source)).toBe(true) + expect(nodeIds.has(e.target)).toBe(true) + } + }) +}) diff --git a/apps/editor/src/ui/editor/reactflow/mapping/externalAlignment.test.ts b/apps/editor/src/ui/editor/reactflow/mapping/externalAlignment.test.ts new file mode 100644 index 0000000..6af9c0c --- /dev/null +++ b/apps/editor/src/ui/editor/reactflow/mapping/externalAlignment.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest' +import { fromEditorGraph } from './fromEditorGraph' +import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' +import type { EditorGraph } from '@/ui/editor/state/editorFactories' + +const FW = 'f0000000-0000-4000-a000-000000000001' +const ITEM = 'a0000000-0000-4000-a000-000000000002' +const EXT_ID = 'ext_ccss' +const EXT_URI = 'https://example.org/frameworks/ccss-math' + +// An item aligned to an external-framework reference node must export as a real +// CFAssociation whose destination is the external URI (previously dropped entirely). +function graph(): EditorGraph { + return { + nodes: [ + { id: FW, type: 'caseFrameworkNode', position: { x: 0, y: 0 }, data: { cfDocument: { identifier: FW, sourcedId: FW, title: 'My Framework', lastChangeDateTime: '2026-08-06T00:00:00Z' } } }, + { id: ITEM, type: 'caseItemNode', position: { x: 0, y: 200 }, data: { cfItem: { identifier: ITEM, sourcedId: ITEM, fullStatement: 'My item', lastChangeDateTime: '2026-08-06T00:00:00Z' } } }, + { id: EXT_ID, type: 'externalFrameworkNode', position: { x: 400, y: 200 }, style: { width: 280, height: 120 }, data: { title: 'CCSS Math', uri: EXT_URI, source: 'State Standards' } }, + ], + edges: [ + { id: 'e1', source: ITEM, target: EXT_ID, data: { associationType: 'isRelatedTo' } }, + ], + } as unknown as EditorGraph +} + +describe('external-framework alignment export', () => { + const { framework, externalNodes } = fromEditorGraph({ graph: graph() }) + const pkg = frameworkToCfPackage({ framework, caseVersion: '1.1', externalNodes }) + const official = toOpenCaseFormat(pkg) + + it('does not drop the alignment — an association to the external node is emitted', () => { + // fromEditorGraph must keep the edge (regression: it used to be skipped) + expect(framework.associations.size).toBe(1) + const align = official.CFAssociations!.find((a) => a.destinationNodeURI.uri === EXT_URI) + expect(align).toBeDefined() + expect(align!.associationType).toBe('isRelatedTo') + expect(align!.destinationNodeURI.title).toBe('CCSS Math') + expect(align!.originNodeURI.uri).toBe(`/ims/case/v1p1/CFItems/${ITEM}`) + }) + + it('records the external destination URI in the association extension', () => { + const align = official.CFAssociations!.find((a) => a.destinationNodeURI.uri === EXT_URI)! + const ext = (align.extensions as any)?.['ext:opencase'] + expect(ext?.externalDestinationUri).toBe(EXT_URI) + }) + + it('persists the external reference node in the document extensions', () => { + expect(externalNodes).toHaveLength(1) + const docExt = (official.CFDocument as any).extensions?.['ext:opencase'] + expect(docExt.externalNodes).toHaveLength(1) + expect(docExt.externalNodes[0]).toMatchObject({ id: EXT_ID, title: 'CCSS Math', uri: EXT_URI, source: 'State Standards' }) + }) +}) diff --git a/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts b/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts index f88d9a5..580395d 100644 --- a/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts +++ b/apps/editor/src/ui/editor/reactflow/mapping/fromEditorGraph.ts @@ -3,9 +3,28 @@ import type { Framework, FrameworkMetadata, Item, Association, AssociationType, import type { AssociationId, FrameworkId, ItemId } from '@/domain/shared/types' import type { LayoutState } from './types' import type { CFDocument, CFItem } from '@/domain/case/types' +import type { RegistryItemNodeData, ExternalFrameworkNodeData } from '@/ui/editor/reactflow/types' const isFrameworkNode = (n: EditorGraph['nodes'][number]) => n.type === 'caseFrameworkNode' const isItemNode = (n: EditorGraph['nodes'][number]) => n.type === 'caseItemNode' +const isRegistryNode = (n: EditorGraph['nodes'][number]) => n.type === 'registryItemNode' +const isExternalNode = (n: EditorGraph['nodes'][number]) => n.type === 'externalFrameworkNode' + +export type PersistedRegistryNode = RegistryItemNodeData & { + id: string + x: number + y: number + w?: number + h?: number +} + +export type PersistedExternalNode = ExternalFrameworkNodeData & { + id: string + x: number + y: number + w?: number + h?: number +} function mapItemType(rawType?: string): ItemType { const raw = (rawType ?? '').toLowerCase() @@ -39,7 +58,7 @@ function readCfItem(node: EditorGraph['nodes'][number]): CFItem | null { return any?.cfItem ?? null } -export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Framework; layout: LayoutState } { +export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Framework; layout: LayoutState; registryNodes: PersistedRegistryNode[]; externalNodes: PersistedExternalNode[] } { const { graph } = params const fwNode = graph.nodes.find(isFrameworkNode) const fwId = (fwNode?.id ?? 'fw') as unknown as FrameworkId @@ -64,6 +83,19 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr statusEndDate: doc?.statusEndDate, lastChangeDateTime: doc?.lastChangeDateTime, licenseURI: doc?.licenseURI, + extensions: doc?.extensions as Record | undefined, + } + + // Build registry node data map for association metadata + const registryDataByNodeId = new Map() + for (const n of graph.nodes.filter(isRegistryNode)) { + registryDataByNodeId.set(n.id, n.data as unknown as RegistryItemNodeData) + } + + // Build external-framework node data map for association metadata + const externalDataByNodeId = new Map() + for (const n of graph.nodes.filter(isExternalNode)) { + externalDataByNodeId.set(n.id, n.data as unknown as ExternalFrameworkNodeData) } const items: Framework['items'] = new Map() @@ -146,8 +178,56 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr continue } + const targetIsRegistry = registryDataByNodeId.has(target) + const targetIsExternal = externalDataByNodeId.has(target) + if (!items.has(source as unknown as ItemId)) continue - if (!items.has(target as unknown as ItemId)) continue + if (!items.has(target as unknown as ItemId) && !targetIsRegistry && !targetIsExternal) continue + + // Handle alignment edges to registry items specially + if (targetIsRegistry) { + const regData = registryDataByNodeId.get(target)! + const assocId = (edgeData?.cfAssociation?.identifier ?? e.id) as unknown as AssociationId + const assoc: Association = { + id: assocId, + fromItemId: source as unknown as ItemId, + toItemId: target as unknown as ItemId, + associationType: (edgeToAssociationType(e.id, edgeData) as AssociationType), + metadata: { + caseUri: edgeData?.cfAssociation?.uri, + ctdlUri: regData.ctdlUri, + ctdlCtid: regData.ctdlCtid, + ctdlStatement: regData.fullStatement, + notes: edgeData?.cfAssociation?.notes, + lastChangeDateTime: edgeData?.cfAssociation?.lastChangeDateTime, + }, + } + associations.set(assoc.id, assoc) + continue + } + + // Handle alignment edges to external-framework reference nodes. + // Mirrors the registry branch: the destination is an external resource identified + // by its URI, carried through export as the association's destinationNodeURI. + if (targetIsExternal) { + const extData = externalDataByNodeId.get(target)! + const assocId = (edgeData?.cfAssociation?.identifier ?? e.id) as unknown as AssociationId + const assoc: Association = { + id: assocId, + fromItemId: source as unknown as ItemId, + toItemId: target as unknown as ItemId, + associationType: (edgeToAssociationType(e.id, edgeData) as AssociationType), + metadata: { + caseUri: edgeData?.cfAssociation?.uri, + externalUri: extData.uri, + externalTitle: extData.title, + notes: edgeData?.cfAssociation?.notes, + lastChangeDateTime: edgeData?.cfAssociation?.lastChangeDateTime, + }, + } + associations.set(assoc.id, assoc) + continue + } const associationType = edgeToAssociationType(e.id, edgeData) @@ -204,6 +284,45 @@ export function fromEditorGraph(params: { graph: EditorGraph }): { framework: Fr } } - return { framework, layout: { byNodeId } } + // Extract registry nodes for persistence in CFPackage extensions + const registryNodes: PersistedRegistryNode[] = graph.nodes + .filter(isRegistryNode) + .map((n) => { + const data = n.data as unknown as RegistryItemNodeData + const styleAny = n.style as unknown as { width?: number; height?: number } | undefined + return { + id: n.id, + ctdlUri: data.ctdlUri, + ctdlCtid: data.ctdlCtid, + fullStatement: data.fullStatement, + codedNotation: data.codedNotation, + frameworkTitle: data.frameworkTitle, + x: n.position.x, + y: n.position.y, + w: typeof styleAny?.width === 'number' ? styleAny.width : undefined, + h: typeof styleAny?.height === 'number' ? styleAny.height : undefined, + } + }) + + // Extract external-framework reference nodes for persistence in CFPackage extensions + const externalNodes: PersistedExternalNode[] = graph.nodes + .filter(isExternalNode) + .map((n) => { + const data = n.data as unknown as ExternalFrameworkNodeData + const styleAny = n.style as unknown as { width?: number; height?: number } | undefined + return { + id: n.id, + title: data.title, + uri: data.uri, + description: data.description, + source: data.source, + x: n.position.x, + y: n.position.y, + w: typeof styleAny?.width === 'number' ? styleAny.width : undefined, + h: typeof styleAny?.height === 'number' ? styleAny.height : undefined, + } + }) + + return { framework, layout: { byNodeId }, registryNodes, externalNodes } } diff --git a/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts b/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts index 7ef4852..d02d9aa 100644 --- a/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts +++ b/apps/editor/src/ui/editor/reactflow/mapping/toReactFlow.ts @@ -2,7 +2,7 @@ import type { Association, Framework } from '@/domain/framework/model/types' import type { ItemId } from '@/domain/shared/types' import type { EditorGraph } from '@/ui/editor/state/editorFactories' import { getEdgeMarkers, getEdgeStyle, makeEdgeLabel } from '@/ui/editor/state/editorFactories' -import type { CaseEditorEdge, CaseEditorNodeType, CaseFrameworkNodeType, CaseItemNodeType } from '@/ui/editor/reactflow/types' +import type { CaseEditorEdge, CaseEditorNodeType, CaseFrameworkNodeType, CaseItemNodeType, RegistryItemNodeType, ExternalFrameworkNodeType } from '@/ui/editor/reactflow/types' import { FRAMEWORK_ROOT_ASSOCIATION_TYPE } from '@/ui/editor/reactflow/types' import type { CFAssociation, CFDocument, CFItem } from '@/domain/case/types' import type { LayoutState } from './types' @@ -103,6 +103,7 @@ function mapDomainFrameworkToCfDocument(framework: Framework): CFDocument { lastChangeDateTime: meta.lastChangeDateTime ?? nowIso(), CFPackageURI: { uri: `urn:case:package:${id}` }, licenseURI: meta.licenseURI, + extensions: (meta as { extensions?: unknown }).extensions as CFDocument['extensions'], } } @@ -118,6 +119,11 @@ function mapDomainItemToCfItem(framework: Framework, itemId: string): CFItem { const rawExtensions = md.extensions as Record | undefined const opencaseExt = (rawExtensions?.[OPENCASE_EXT_KEY] ?? md[OPENCASE_EXT_KEY]) as Record | undefined const colorBand = typeof opencaseExt?.colorBand === 'string' ? opencaseExt.colorBand : s('colorBand') + // Reconcile extensions shape: caseToDomainFramework flattens ext:opencase into + // metadata (metadata['ext:opencase']), while fromEditorGraph nests it under + // metadata.extensions. Rebuild a nested object so imported Registry provenance + // (the `source` block) rides through to export. Mirrors the association mapping below. + const reconciledExtensions = rawExtensions ?? (opencaseExt ? { [OPENCASE_EXT_KEY]: opencaseExt } : undefined) return { identifier: itemId, @@ -141,7 +147,7 @@ function mapDomainItemToCfItem(framework: Framework, itemId: string): CFItem { statusEndDate: s('statusEndDate'), colorBand: colorBand || undefined, lastChangeDateTime: s('lastChangeDateTime') ?? nowIso(), - extensions: rawExtensions ?? undefined, + extensions: reconciledExtensions, CFDocumentURI: { uri: `urn:case:document:${framework.id as unknown as string}` }, } } @@ -226,14 +232,41 @@ export function toReactFlowGraph(params: { framework: Framework; layout?: Layout const nodes: CaseEditorNodeType[] = [fwNode] const edges: CaseEditorEdge[] = [] + // Build a map from persisted reference-node URIs → their canvas node id, so alignment + // associations reloaded from a CFPackage can be re-linked to the reconstructed + // registry/external nodes. On reload an alignment's destinationNodeURI.identifier is + // the CTID's UUID (or a derived id), which does NOT equal the reference node's canvas + // id — without this remap the edge would dangle and be dropped, losing the alignment + // on the next save. Applies to both registry and external reference nodes. + const docExtForRefs = (cfDocument as unknown as { extensions?: Record }).extensions?.[OPENCASE_EXT_KEY] as Record | undefined + const refNodeIdByUri = new Map() + for (const rn of (Array.isArray(docExtForRefs?.registryNodes) ? docExtForRefs!.registryNodes as Array> : [])) { + if (typeof rn.ctdlUri === 'string' && typeof rn.id === 'string') refNodeIdByUri.set(rn.ctdlUri, rn.id) + } + for (const en of (Array.isArray(docExtForRefs?.externalNodes) ? docExtForRefs!.externalNodes as Array> : [])) { + if (typeof en.uri === 'string' && typeof en.id === 'string') refNodeIdByUri.set(en.uri, en.id) + } + + // Resolve an association's destination to a reconstructed reference node id when its + // recorded destination URI matches one; otherwise return the raw destination id. + const resolveDestId = (a: Association): string => { + const rawToId = a.toItemId as unknown as string + if (refNodeIdByUri.size === 0) return rawToId + const md = (a.metadata ?? {}) as Record + const ext = md[OPENCASE_EXT_KEY] as Record | undefined + const uri = [md.destinationUri, ext?.ctdlDestinationUri, ext?.externalDestinationUri] + .find((v): v is string => typeof v === 'string' && refNodeIdByUri.has(v)) + return uri ? refNodeIdByUri.get(uri)! : rawToId + } + // Map child -> parent and track associations by their origin/destination for edge data const parentByChild = new Map() const associationByEdgeKey = new Map() const associatedItemIds = new Set() - + for (const a of framework.associations.values()) { const fromId = a.fromItemId as unknown as string - const toId = a.toItemId as unknown as string + const toId = resolveDestId(a) if (!fromId || !toId) continue // Track whether each item participates in any association. @@ -356,11 +389,89 @@ export function toReactFlowGraph(params: { framework: Framework; layout?: Layout }) } + // Reconstruct Registry reference nodes from CFDocument extensions + const docAny = cfDocument as unknown as { extensions?: Record } + const opencaseDocExt = docAny.extensions?.[OPENCASE_EXT_KEY] as Record | undefined + const persistedRegistryNodes = Array.isArray(opencaseDocExt?.registryNodes) + ? (opencaseDocExt!.registryNodes as Array>) + : [] + + for (const rn of persistedRegistryNodes) { + const regId = typeof rn.id === 'string' ? rn.id : typeof rn.ctdlCtid === 'string' ? rn.ctdlCtid : null + if (!regId) continue + const regLayout = getLayout(layout, regId, { + x: typeof rn.x === 'number' ? rn.x : 800, + y: typeof rn.y === 'number' ? rn.y : HEADER_SAFE_Y, + w: typeof rn.w === 'number' ? rn.w : 260, + h: typeof rn.h === 'number' ? rn.h : 72, + }) + const regNode: RegistryItemNodeType = { + id: regId, + type: 'registryItemNode', + position: regLayout.position, + style: regLayout.style, + data: { + ctdlUri: typeof rn.ctdlUri === 'string' ? rn.ctdlUri : '', + ctdlCtid: typeof rn.ctdlCtid === 'string' ? rn.ctdlCtid : '', + fullStatement: typeof rn.fullStatement === 'string' ? rn.fullStatement : '', + codedNotation: typeof rn.codedNotation === 'string' ? rn.codedNotation : undefined, + frameworkTitle: typeof rn.frameworkTitle === 'string' ? rn.frameworkTitle : undefined, + }, + className: wrapperNodeClassName, + } + nodes.push(regNode) + // Add to nodePositions so edge handles can be calculated + const regStyleAny = regNode.style as { width?: number; height?: number } | undefined + nodePositions.set(regId, { + x: regNode.position.x, + y: regNode.position.y, + w: typeof regStyleAny?.width === 'number' ? regStyleAny.width : 260, + h: typeof regStyleAny?.height === 'number' ? regStyleAny.height : 72, + }) + } + + // Reconstruct external-framework reference nodes from CFDocument extensions + const persistedExternalNodes = Array.isArray(opencaseDocExt?.externalNodes) + ? (opencaseDocExt!.externalNodes as Array>) + : [] + + for (const en of persistedExternalNodes) { + const extId = typeof en.id === 'string' ? en.id : null + if (!extId) continue + const extLayout = getLayout(layout, extId, { + x: typeof en.x === 'number' ? en.x : 800, + y: typeof en.y === 'number' ? en.y : HEADER_SAFE_Y, + w: typeof en.w === 'number' ? en.w : 280, + h: typeof en.h === 'number' ? en.h : 120, + }) + const extNode: ExternalFrameworkNodeType = { + id: extId, + type: 'externalFrameworkNode', + position: extLayout.position, + style: extLayout.style, + data: { + title: typeof en.title === 'string' ? en.title : 'External framework', + uri: typeof en.uri === 'string' ? en.uri : undefined, + description: typeof en.description === 'string' ? en.description : undefined, + source: typeof en.source === 'string' ? en.source : undefined, + }, + className: wrapperNodeClassName, + } + nodes.push(extNode) + const extStyleAny = extNode.style as { width?: number; height?: number } | undefined + nodePositions.set(extId, { + x: extNode.position.x, + y: extNode.position.y, + w: typeof extStyleAny?.width === 'number' ? extStyleAny.width : 280, + h: typeof extStyleAny?.height === 'number' ? extStyleAny.height : 120, + }) + } + // Non-hierarchical associations as edges (source=from, target=to). for (const a of framework.associations.values()) { if (a.associationType === 'isChildOf' || a.associationType === 'isPartOf') continue const fromId = a.fromItemId as unknown as string - const toId = a.toItemId as unknown as string + const toId = resolveDestId(a) // re-link registry/external alignments to their reconstructed node if (!fromId || !toId) continue const cfAssociation = mapDomainAssociationToCfAssociation(framework, a) diff --git a/apps/editor/src/ui/editor/reactflow/nodeTypes/RegistryItemNode.tsx b/apps/editor/src/ui/editor/reactflow/nodeTypes/RegistryItemNode.tsx new file mode 100644 index 0000000..7b60554 --- /dev/null +++ b/apps/editor/src/ui/editor/reactflow/nodeTypes/RegistryItemNode.tsx @@ -0,0 +1,128 @@ +import { Handle, Position, type NodeProps, useReactFlow, useConnection } from '@xyflow/react' +import { XMarkIcon } from '@heroicons/react/24/solid' +import type { RegistryItemNodeType } from '../types' +import type { CaseEditorNodeType } from '@/ui/editor/reactflow/types' + +export default function RegistryItemNode({ id, data, selected }: NodeProps) { + const rf = useReactFlow() + const connection = useConnection() + const connectionInProgress = connection.inProgress + const connectionNodeId = connection.fromNode?.id ?? null + + const sourceNodeType = connection.fromNode?.type + const isSourceRegistry = sourceNodeType === 'registryItemNode' + + // Registry nodes cannot be connection sources, and two registry nodes cannot be linked + const isInvalidTarget = connectionInProgress && isSourceRegistry && connectionNodeId !== id + + const typedData = data as unknown as { + ctdlUri?: string + ctdlCtid?: string + fullStatement?: string + codedNotation?: string + frameworkTitle?: string + } + + const fullStatement = typedData?.fullStatement ?? '' + const codedNotation = typedData?.codedNotation + const frameworkTitle = typedData?.frameworkTitle + + return ( +
+ {/* Remove button */} +
+ +
+ + {/* Card */} +
+ {/* Registry badge */} +
+ + Registry + +
+ + {/* Content */} +
+ {codedNotation && ( +
{codedNotation}
+ )} +
+ {fullStatement} +
+ {frameworkTitle && ( +
{frameworkTitle}
+ )} +
+ + {/* Handles — target only (alignment edges point TO registry nodes) */} + + + + +
+
+ ) +} diff --git a/apps/editor/src/ui/editor/reactflow/nodeTypes/index.ts b/apps/editor/src/ui/editor/reactflow/nodeTypes/index.ts index 7832834..24c6f42 100644 --- a/apps/editor/src/ui/editor/reactflow/nodeTypes/index.ts +++ b/apps/editor/src/ui/editor/reactflow/nodeTypes/index.ts @@ -2,10 +2,12 @@ import type { NodeTypes } from '@xyflow/react' import CaseItemNode from './CaseItemNode' import CaseFrameworkNode from './CaseFrameworkNode' import ExternalFrameworkNode from './ExternalFrameworkNode' +import RegistryItemNode from './RegistryItemNode' export const nodeTypes: NodeTypes = { caseItemNode: CaseItemNode, caseFrameworkNode: CaseFrameworkNode, externalFrameworkNode: ExternalFrameworkNode, + registryItemNode: RegistryItemNode, } diff --git a/apps/editor/src/ui/editor/reactflow/types.ts b/apps/editor/src/ui/editor/reactflow/types.ts index 24b2597..f7f635a 100644 --- a/apps/editor/src/ui/editor/reactflow/types.ts +++ b/apps/editor/src/ui/editor/reactflow/types.ts @@ -32,8 +32,24 @@ export type ExternalFrameworkNodeData = { export type ExternalFrameworkNodeType = Node -export type CaseEditorNodeData = CaseItemNodeData | CaseFrameworkNodeData | ExternalFrameworkNodeData -export type CaseEditorNodeType = CaseItemNodeType | CaseFrameworkNodeType | ExternalFrameworkNodeType +/** Data for read-only Credential Engine Registry competency reference nodes */ +export type RegistryItemNodeData = { + /** CTDL @id URI from the Registry */ + ctdlUri: string + /** Credential Engine CTID (e.g. "ce-abc123") */ + ctdlCtid: string + /** Competency full statement text */ + fullStatement: string + /** Optional coded notation / human coding scheme */ + codedNotation?: string + /** Title of the Registry framework this competency belongs to */ + frameworkTitle?: string +} + +export type RegistryItemNodeType = Node + +export type CaseEditorNodeData = CaseItemNodeData | CaseFrameworkNodeData | ExternalFrameworkNodeData | RegistryItemNodeData +export type CaseEditorNodeType = CaseItemNodeType | CaseFrameworkNodeType | ExternalFrameworkNodeType | RegistryItemNodeType export type CaseItemNodeDataPatch = Partial> & { cfItem?: Partial @@ -45,7 +61,9 @@ export type CaseFrameworkNodeDataPatch = Partial -export type CaseEditorNodeDataPatch = CaseItemNodeDataPatch | CaseFrameworkNodeDataPatch | ExternalFrameworkNodeDataPatch +export type RegistryItemNodeDataPatch = Partial + +export type CaseEditorNodeDataPatch = CaseItemNodeDataPatch | CaseFrameworkNodeDataPatch | ExternalFrameworkNodeDataPatch | RegistryItemNodeDataPatch // ========== Edge Types ========== diff --git a/apps/editor/src/ui/editor/state/editorReducer.ts b/apps/editor/src/ui/editor/state/editorReducer.ts index f081394..e8e2ff9 100644 --- a/apps/editor/src/ui/editor/state/editorReducer.ts +++ b/apps/editor/src/ui/editor/state/editorReducer.ts @@ -14,6 +14,8 @@ import type { CaseItemNodeType, ExternalFrameworkNodeData, ExternalFrameworkNodeType, + RegistryItemNodeData, + RegistryItemNodeType, } from '@/ui/editor/reactflow/types' import { FRAMEWORK_ROOT_ASSOCIATION_TYPE } from '@/ui/editor/reactflow/types' import type { CFItem } from '@/domain/case/types' @@ -30,6 +32,7 @@ import { isFrameworkNode, isItemNode, } from '@/ui/editor/state/helpers/nodeGeometry' +import { forkExtensions } from '@/ui/editor/state/provenance' // ── State ────────────────────────────────────────────────────────────── @@ -63,10 +66,12 @@ export type Action = | { type: 'node/addChild'; parentId: string; childId: string; cfItem: CFItem } | { type: 'node/addDetachedItem'; nodeId: string; cfItem: CFItem; viewportCenter?: { x: number; y: number } } | { type: 'node/addExternalFramework'; nodeId: string; data: ExternalFrameworkNodeData; viewportCenter?: { x: number; y: number } } + | { type: 'node/addRegistryFramework'; items: RegistryItemNodeData[]; viewportCenter?: { x: number; y: number } } | { type: 'graph/delete'; nodeIds: string[]; edgeIds: string[]; reattachChildren: boolean } | { type: 'layout/apply'; positions: Record } | { type: 'layout/applyHierarchy'; positions: Record; edgeHandles: Record } | { type: 'graph/load'; graph: EditorGraph } + | { type: 'framework/enableEditing' } | { type: 'dirty/mark' } | { type: 'dirty/clear' } @@ -129,9 +134,13 @@ export function editorReducer(state: EditorState, action: Action): EditorState { const isTargetMainFramework = targetNode?.type === 'caseFrameworkNode' const isSourceExternalFramework = sourceNode?.type === 'externalFrameworkNode' const isTargetExternalFramework = targetNode?.type === 'externalFrameworkNode' + const isSourceRegistry = sourceNode?.type === 'registryItemNode' + const isTargetRegistry = targetNode?.type === 'registryItemNode' const isSourceAnyFramework = isSourceMainFramework || isSourceExternalFramework const isTargetAnyFramework = isTargetMainFramework || isTargetExternalFramework + // Registry nodes cannot be sources; two frameworks cannot link + if (isSourceRegistry) return state if (isSourceAnyFramework && isTargetAnyFramework) return state const involvesMainFramework = isSourceMainFramework || isTargetMainFramework @@ -142,6 +151,8 @@ export function editorReducer(state: EditorState, action: Action): EditorState { defaultAssocType = FRAMEWORK_ROOT_ASSOCIATION_TYPE } else if (involvesExternalFramework) { defaultAssocType = 'isPartOf' + } else if (isTargetRegistry) { + defaultAssocType = 'isRelatedTo' } else { defaultAssocType = 'isChildOf' } @@ -168,7 +179,7 @@ export function editorReducer(state: EditorState, action: Action): EditorState { labelStyle: { fill: '#94a3b8', fontSize: 11, fontWeight: 500 }, style: getEdgeStyle(defaultAssocType), data: { - isHierarchical: true, + isHierarchical: !isTargetRegistry && !involvesExternalFramework, associationType: defaultAssocType, isFrameworkRootConnection: involvesMainFramework, }, @@ -276,7 +287,11 @@ export function editorReducer(state: EditorState, action: Action): EditorState { const isTargetMainFramework = newTargetNode?.type === 'caseFrameworkNode' const isSourceExternalFramework = newSourceNode?.type === 'externalFrameworkNode' const isTargetExternalFramework = newTargetNode?.type === 'externalFrameworkNode' + const isSourceRegistry = newSourceNode?.type === 'registryItemNode' + const isTargetRegistry = newTargetNode?.type === 'registryItemNode' const isSourceAnyFramework = isSourceMainFramework || isSourceExternalFramework + + if (isSourceRegistry) return state const isTargetAnyFramework = isTargetMainFramework || isTargetExternalFramework if (isSourceAnyFramework && isTargetAnyFramework) return state @@ -307,6 +322,8 @@ export function editorReducer(state: EditorState, action: Action): EditorState { newAssocType = FRAMEWORK_ROOT_ASSOCIATION_TYPE } else if (involvesExternalFramework) { newAssocType = 'isPartOf' + } else if (isTargetRegistry) { + newAssocType = currentData.associationType ?? 'isRelatedTo' } else { newAssocType = currentData.associationType ?? 'isChildOf' } @@ -510,6 +527,39 @@ export function editorReducer(state: EditorState, action: Action): EditorState { const nextNodes = [...state.nodes.map((n) => ({ ...n, selected: false })), { ...newNode, selected: true }] return { ...state, nodes: nextNodes, selectedNodeId: action.nodeId, selectedEdgeId: null, selectedNodeIds: [action.nodeId], selectedEdgeIds: [], dirty: true } } + case 'node/addRegistryFramework': { + if (!action.items.length) return state + + const REG_NODE_WIDTH = 260 + const REG_NODE_HEIGHT = 72 + const REG_NODE_GAP = 6 + + const existingNodeIds = new Set(state.nodes.map((n) => n.id)) + + // Find a clear area: right of existing content, or offset from viewport center + const maxX = state.nodes.length ? Math.max(...state.nodes.map((n) => n.position.x + ((n.style as { width?: number } | undefined)?.width ?? 280))) : 0 + const clusterX = action.viewportCenter ? action.viewportCenter.x + 40 : maxX + 80 + const clusterStartY = action.viewportCenter + ? action.viewportCenter.y - (action.items.length * (REG_NODE_HEIGHT + REG_NODE_GAP)) / 2 + : HEADER_SAFE_Y + + // Use ctdlCtid as stable node ID; skip items already on canvas + const newRegistryNodes: RegistryItemNodeType[] = action.items + .filter((item) => !existingNodeIds.has(item.ctdlCtid)) + .map((item, i) => ({ + id: item.ctdlCtid, + type: 'registryItemNode' as const, + position: { x: clusterX, y: clusterStartY + i * (REG_NODE_HEIGHT + REG_NODE_GAP) }, + style: { width: REG_NODE_WIDTH, height: REG_NODE_HEIGHT }, + data: item, + className: WRAPPER_NODE_CLASS, + })) + + if (!newRegistryNodes.length) return state + + const nextNodes = [...state.nodes.map((n) => ({ ...n, selected: false })), ...newRegistryNodes] + return { ...state, nodes: nextNodes as CaseEditorNodeType[], dirty: true } + } case 'graph/delete': { const deleteNodeIds = new Set(action.nodeIds) const deleteEdgeIds = new Set(action.edgeIds) @@ -618,6 +668,23 @@ export function editorReducer(state: EditorState, action: Action): EditorState { dirty: false, } } + case 'framework/enableEditing': { + // Fork an imported framework into a derivative: rename each node's + // ext:opencase.source → derivedFrom and flag the document isModifiedFromSource. + const nodes = state.nodes.map((n) => { + if (isFrameworkNode(n)) { + const extensions = forkExtensions(n.data.cfDocument.extensions, { markModified: true }) as typeof n.data.cfDocument.extensions + return { ...n, data: { ...n.data, cfDocument: { ...n.data.cfDocument, extensions } } } + } + if (isItemNode(n)) { + const extensions = forkExtensions(n.data.cfItem.extensions) as typeof n.data.cfItem.extensions + if (extensions === n.data.cfItem.extensions) return n + return { ...n, data: { ...n.data, cfItem: { ...n.data.cfItem, extensions } } } + } + return n + }) + return { ...state, nodes, dirty: true } + } case 'dirty/mark': { return state.dirty ? state : { ...state, dirty: true } } diff --git a/apps/editor/src/ui/editor/state/provenance.ts b/apps/editor/src/ui/editor/state/provenance.ts new file mode 100644 index 0000000..4cd7a10 --- /dev/null +++ b/apps/editor/src/ui/editor/state/provenance.ts @@ -0,0 +1,57 @@ +/** + * Helpers for reasoning about a framework's registry provenance and for the + * "fork" transform that turns a faithfully-imported framework into a derivative. + * + * Imported registry frameworks are read-only by default. When the user chooses to + * edit one, it becomes *derived* from the source: each item's and the document's + * `ext:opencase.source` (an identity assertion — "this IS registry resource X") is + * converted to `derivedFrom` (a lineage assertion — "this was derived from X"), and + * the document is flagged `isModifiedFromSource`. + */ + +export const OPENCASE_EXT_KEY = 'ext:opencase' + +type ExtRecord = Record + +function opencaseExt(extensions: unknown): ExtRecord | undefined { + const ext = (extensions as ExtRecord | undefined)?.[OPENCASE_EXT_KEY] + return ext && typeof ext === 'object' ? (ext as ExtRecord) : undefined +} + +/** + * Classify a CFDocument's registry provenance: + * - `imported` — came from a CTDL registry (has a source/derivedFrom/import marker) + * - `modified` — has been forked (edited) away from that source + * The editor treats `imported && !modified` as read-only. + */ +export function readDocProvenance( + cfDocument: { extensions?: unknown } | undefined, +): { imported: boolean; modified: boolean } { + const ext = opencaseExt(cfDocument?.extensions) + const imported = Boolean(ext && (ext.source || ext.derivedFrom || ext.importedFrom)) + const modified = ext?.isModifiedFromSource === true + return { imported, modified } +} + +/** + * Return a new extensions object with the registry `source` block renamed to + * `derivedFrom` (identity → lineage). Optionally set the document-level + * `isModifiedFromSource` flag. Returns the input reference unchanged when there is + * nothing to transform, so callers can skip no-op node updates. + */ +export function forkExtensions( + extensions: unknown, + opts: { markModified?: boolean } = {}, +): Record | undefined { + const base = extensions as ExtRecord | undefined + const ext = opencaseExt(base) + const hasSource = Boolean(ext?.source) + if (!hasSource && !opts.markModified) return base + const nextExt: ExtRecord = { ...(ext ?? {}) } + if (hasSource) { + nextExt.derivedFrom = nextExt.source + delete nextExt.source + } + if (opts.markModified) nextExt.isModifiedFromSource = true + return { ...(base ?? {}), [OPENCASE_EXT_KEY]: nextExt } +} From 23b0eb4e3cb6451f495f38f6538b36bfe8b9ebad Mon Sep 17 00:00:00 2001 From: Jeff Grann Date: Thu, 6 Aug 2026 12:04:46 -0500 Subject: [PATCH 3/4] feat(editor): registry import UI and read-only/fork workflow - Import-from-registry dialog and a read-only registry reference node type, wired into the home screen, app, and canvas (floating add menu). - Imported registry frameworks open read-only: content edits and structural changes (add/remove items and associations) are blocked while layout changes stay allowed, enforced centrally in EditorContext. A header "Enable editing" action forks the framework after confirmation, recording derivation. - Node properties panel shows registry provenance (CTID / CTDL URI / source registry) read-only, and the header/home reflect Imported vs Forked state. Co-Authored-By: Claude Opus 4.8 --- apps/editor/package-lock.json | 104 +----------- apps/editor/src/app/App.tsx | 3 + .../infrastructure/caseApi/CaseApiClient.ts | 65 ++++++++ apps/editor/src/ui/editor/EditorCanvas.tsx | 83 ++++++++-- .../src/ui/editor/components/CanvasHeader.tsx | 33 +++- .../editor/components/EdgePropertiesPanel.tsx | 8 +- .../editor/components/FloatingAddButton.tsx | 14 +- .../editor/components/NodePropertiesPanel.tsx | 148 +++++++++++++++++- .../src/ui/editor/state/EditorContext.tsx | 86 ++++++++-- apps/editor/src/ui/home/HomeScreen.tsx | 37 +++++ .../src/ui/home/ImportFromRegistryDialog.tsx | 124 +++++++++++++++ 11 files changed, 566 insertions(+), 139 deletions(-) create mode 100644 apps/editor/src/ui/home/ImportFromRegistryDialog.tsx 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/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) ── */} + +