diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index e54c7ad..7eeaf37 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -10,7 +10,7 @@ import { getAppConfig } from '@/app/config' import { CaseApiClient, type CfDocumentSummary } from '@/infrastructure/caseApi/CaseApiClient' import { createFetchHttpClient } from '@/infrastructure/caseApi/http' import { loadFrameworkFromCfPackage } from '@/application/framework/services/FrameworkLoader' -import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage } from '@/ui/editor/reactflow/mapping' +import { toReactFlowGraph, extractLayoutFromCfPackage, extractEditorSettingsFromCfPackage, extractRemoteFrameworkDataFromCfPackage, normalizeLinkedFrameworkColors } from '@/ui/editor/reactflow/mapping' import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' import type { LayoutState } from '@/ui/editor/reactflow/mapping' import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot' @@ -201,6 +201,19 @@ function AppInner() { setRoute('login') }, [authStatus, route]) + // SSO: ensure default tenant membership when org_id claim matches (idempotent). + const ensureSelfAttempted = useRef(null) + useEffect(() => { + if (authStatus !== 'authenticated' || !tenantId) return + const key = tenantId + if (ensureSelfAttempted.current === key) return + ensureSelfAttempted.current = key + void api.ensureSelfMembership({ tenantId }).catch((err: unknown) => { + // Expected when org_id claim is absent (non-SSO / local users). + console.debug('[App] ensure-self skipped or failed:', err) + }) + }, [authStatus, tenantId, api]) + // Fetch the full definitions catalogue from the management endpoint once authenticated. useEffect(() => { if (authStatus !== 'authenticated' || !tenantId) return @@ -371,6 +384,9 @@ function AppInner() { // Create a HomeFramework entry from the domain Framework const fw = createHomeFrameworkFromDomain(framework, mirrorStatus) + if (pkg.CFDocument?.extensions) { + fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions } + } // Store the extracted layout if (layout) { @@ -459,7 +475,13 @@ function AppInner() { // Get the stored layout for this framework (from CASE extensions) const layout = frameworkLayouts[activeFramework.id] - const graph = toReactFlowGraph({ framework: activeFramework.framework, layout }) + const remoteEditorData = extractRemoteFrameworkDataFromCfPackage({ + CFDocument: activeFramework.cfDocument, + CFItems: [], + CFAssociations: [], + }) + remoteEditorData.linkedFrameworks = normalizeLinkedFrameworkColors(remoteEditorData.linkedFrameworks) + const graph = toReactFlowGraph({ framework: activeFramework.framework, layout, remoteEditorData }) // If no saved layout, detect topology and apply appropriate layout if (!layout) { diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index 81f64b3..a1ed9d6 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -82,14 +82,12 @@ function makePackageUri(uuid: string): string { type OpencaseExtension = { layout?: NodeLayout notes?: string - /** Persisted handle ID on the origin (from) node — preserves user-defined edge anchors */ originHandle?: string - /** Persisted handle ID on the destination (to) node — preserves user-defined edge anchors */ destinationHandle?: string - /** Edge rendering style for this framework (e.g. 'default', 'smoothstep', 'straight') */ edgeType?: string - /** Visual color band hex color for item nodes */ colorBand?: string + linkedFrameworks?: unknown[] + remoteItemLinks?: unknown[] } /** @@ -103,7 +101,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.linkedFrameworks || opencaseData.remoteItemLinks) { const existing = (extensions[OPENCASE_EXT_KEY] as OpencaseExtension | undefined) ?? {} extensions[OPENCASE_EXT_KEY] = { ...existing, @@ -121,7 +119,7 @@ function frameworkToCfDocument( framework: Framework, caseVersion: CaseVersion, layout?: NodeLayout, - options?: { edgeType?: string } + options?: { edgeType?: string; linkedFrameworks?: unknown[]; remoteItemLinks?: unknown[] } ): CFDocument { const meta = framework.metadata const fwId = String(framework.id) @@ -156,8 +154,13 @@ function frameworkToCfDocument( title: docTitle, identifier: fwId, }, - extensions: (layout || options?.edgeType) - ? mergeOpencaseExtension(undefined, { layout, edgeType: options?.edgeType }) + extensions: (layout || options?.edgeType || options?.linkedFrameworks || options?.remoteItemLinks) + ? mergeOpencaseExtension(undefined, { + layout, + edgeType: options?.edgeType, + linkedFrameworks: options?.linkedFrameworks, + remoteItemLinks: options?.remoteItemLinks, + }) : undefined, } @@ -264,6 +267,7 @@ function associationToCfAssociation( } const existingExtensions = (md.extensions as CaseExtensions | undefined) ?? undefined + const remoteExt = (existingExtensions?.[OPENCASE_EXT_KEY] as { remoteLink?: boolean; remoteItemUri?: string; remoteItemIdentifier?: string; remoteLabel?: string; localItemUri?: string } | undefined) // Persist user-defined edge handle positions in ext:opencase const originHandle = s('originHandle') @@ -272,21 +276,35 @@ function associationToCfAssociation( ? mergeOpencaseExtension(existingExtensions, { originHandle, destinationHandle }) : existingExtensions + const isRemoteLink = remoteExt?.remoteLink === true + const cfAssociation: CFAssociation & { sourcedId: string } = { identifier: assocId, sourcedId: assocId, // OpenCASE requires sourcedId uri: s('caseUri') ?? `urn:case:association:${assocId}`, associationType: assoc.associationType, - originNodeURI: { - identifier: fromId, - uri: s('originUri') ?? `urn:case:item:${fromId}`, - title: fromTitle, - }, - destinationNodeURI: { - identifier: toId, - uri: s('destinationUri') ?? `urn:case:item:${toId}`, - title: toTitle, - }, + originNodeURI: isRemoteLink + ? { + identifier: fromId, + uri: remoteExt?.localItemUri ?? s('originUri') ?? `urn:case:item:${fromId}`, + title: fromTitle, + } + : { + identifier: fromId, + uri: s('originUri') ?? `urn:case:item:${fromId}`, + title: fromTitle, + }, + destinationNodeURI: isRemoteLink + ? { + identifier: remoteExt?.remoteItemIdentifier ?? toId, + uri: remoteExt?.remoteItemUri ?? s('destinationUri') ?? `urn:case:item:${toId}`, + title: remoteExt?.remoteLabel ?? toTitle, + } + : { + identifier: toId, + uri: s('destinationUri') ?? `urn:case:item:${toId}`, + title: toTitle, + }, sequenceNumber: n('sequenceNumber'), CFAssociationGroupingURI: s('CFAssociationGroupingIdentifier') ? { @@ -334,13 +352,19 @@ export function frameworkToCfPackage(params: { cfAssociationGroupings?: CFAssociationGrouping[] /** CFLicense definitions to include in CFDefinitions (from editor state) */ cfLicenses?: CFLicense[] + /** Remote framework editor data from canvas */ + remoteEditorData?: { linkedFrameworks: unknown[]; remoteItemLinks: unknown[] } }): CFPackage { - const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses } = params + const { framework, caseVersion, layout, edgeType, cfItemTypes, cfSubjects, cfConcepts, cfAssociationGroupings, cfLicenses, remoteEditorData } = params const fwId = String(framework.id) // Build CFDocument const documentLayout = layout?.byNodeId?.[fwId] - const document = frameworkToCfDocument(framework, caseVersion, documentLayout, { edgeType }) + const document = frameworkToCfDocument(framework, caseVersion, documentLayout, { + edgeType, + linkedFrameworks: remoteEditorData?.linkedFrameworks, + remoteItemLinks: remoteEditorData?.remoteItemLinks, + }) // Build CFItems const itemIds = Array.from(framework.items.keys()).map(String) diff --git a/apps/editor/src/infrastructure/auth/tokenScopes.test.ts b/apps/editor/src/infrastructure/auth/tokenScopes.test.ts new file mode 100644 index 0000000..c3017c9 --- /dev/null +++ b/apps/editor/src/infrastructure/auth/tokenScopes.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { tokenHasCaseOwner } from './tokenScopes' + +function makeToken (payload: Record): string { + const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString('base64url') + const body = Buffer.from(JSON.stringify(payload)).toString('base64url') + return `${header}.${body}.sig` +} + +describe('tokenScopes', () => { + it('detects case.owner from scope claim', () => { + expect(tokenHasCaseOwner(makeToken({ scope: 'case.read case.owner' }))).toBe(true) + }) + + it('detects admin membership role as tenant admin', () => { + expect(tokenHasCaseOwner(makeToken({ + resource_access: { 'tenant-demo': { roles: ['admin'] } }, + }))).toBe(true) + }) + + it('detects case.owner from resource_access roles', () => { + expect(tokenHasCaseOwner(makeToken({ + resource_access: { 'tenant-demo': { roles: ['case.owner'] } }, + }))).toBe(true) + }) + + it('returns false for author-only tokens', () => { + expect(tokenHasCaseOwner(makeToken({ scope: 'case.read case.write author' }))).toBe(false) + }) + + it('detects case.admin (system admin) as tenant admin for UI', () => { + expect(tokenHasCaseOwner(makeToken({ + resource_access: { 'tenant-system': { roles: ['case.admin'] } }, + }))).toBe(true) + }) +}) diff --git a/apps/editor/src/infrastructure/auth/tokenScopes.ts b/apps/editor/src/infrastructure/auth/tokenScopes.ts new file mode 100644 index 0000000..eb27897 --- /dev/null +++ b/apps/editor/src/infrastructure/auth/tokenScopes.ts @@ -0,0 +1,71 @@ +/** + * Decode a JWT payload without verifying signature (UI gating only). + * Server always enforces scopes. + */ +export function decodeJwtPayload (accessToken: string | null | undefined): Record | null { + if (!accessToken) return null + const parts = accessToken.split('.') + if (parts.length < 2) return null + try { + const json = base64UrlDecode(parts[1]) + return JSON.parse(json) as Record + } catch { + return null + } +} + +function base64UrlDecode (input: string): string { + let base64 = input.replaceAll('-', '+').replaceAll('_', '/') + const pad = base64.length % 4 + if (pad === 2) base64 += '==' + else if (pad === 3) base64 += '=' + else if (pad === 1) base64 += '===' // invalid length; atob may still throw + return atob(base64) +} + +function collectScopes (payload: Record): Set { + const scopes = new Set() + const raw = payload.scope + if (typeof raw === 'string') { + for (const s of raw.split(' ').filter(Boolean)) scopes.add(s) + } else if (Array.isArray(raw)) { + for (const s of raw) if (typeof s === 'string') scopes.add(s) + } + + const realmAccess = payload.realm_access as { roles?: unknown } | undefined + if (Array.isArray(realmAccess?.roles)) { + for (const r of realmAccess.roles) if (typeof r === 'string') scopes.add(r) + } + + const resourceAccess = payload.resource_access + if (resourceAccess && typeof resourceAccess === 'object') { + for (const client of Object.values(resourceAccess as Record)) { + const roles = client?.roles + if (Array.isArray(roles)) { + for (const r of roles) if (typeof r === 'string') scopes.add(r) + } + } + } + + // Membership labels + case.* hierarchy (matches OpenCASE middleware) + if (scopes.has('admin') || scopes.has('case.owner')) { + scopes.add('case.owner') + scopes.add('case.write') + scopes.add('case.read') + } else if (scopes.has('author') || scopes.has('case.write')) { + scopes.add('case.write') + scopes.add('case.read') + } else if (scopes.has('viewer') || scopes.has('case.read')) { + scopes.add('case.read') + } + + return scopes +} + +/** True when the access token can manage tenant members/keys (owner, membership admin, or system case.admin). */ +export function tokenHasCaseOwner (accessToken: string | null | undefined): boolean { + const payload = decodeJwtPayload(accessToken) + if (!payload) return false + const scopes = collectScopes(payload) + return scopes.has('case.owner') || scopes.has('admin') || scopes.has('case.admin') +} diff --git a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts index 8eeebf1..27d15d8 100644 --- a/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts +++ b/apps/editor/src/infrastructure/caseApi/CaseApiClient.ts @@ -1,5 +1,8 @@ import type { CFDocument, CFLicense, CFPackage, CFDefinition } from '@/domain/case/types' import type { HttpClient } from './http' +import type { CgeImportResult, CachedFrameworkItemSummary } from './cgeTypes' +export type { CgeImportResult, CachedFrameworkItemSummary, CgeFrameworkSummary, CgeSubscriptionSummary } from './cgeTypes' +export { normalizeCgeFrameworkList, normalizeCgeSubscriptionList } from './cgeTypes' export type OpenCaseCfPackageResponse = { CFPackage: CFPackage } @@ -51,6 +54,8 @@ export type CfDocumentSummary = { caseVersion?: string /** Server-level archive flag — independent of CASE adoptionStatus */ archived?: boolean + readOnly?: boolean + cgeFrameworkId?: string /** OpenCASE-proprietary data (e.g. sourcePackageURI/isModifiedFromSource for the mirror/fork badge) — only present when the request sends X-CASE-EDITOR. */ extensions?: { 'ext:opencase'?: OpenCaseListExtensions } } @@ -68,15 +73,61 @@ export class CaseApiClient { return {} } - async listManagementCfPackages(params: { tenantId: string; caseVersion?: '1.0' | '1.1' }): Promise { + async lookupTenantByOrgId(params: { orgId: string }): Promise<{ tenantId?: string } | null> { + const orgId = params.orgId.trim() + if (!orgId) return null + const res = (await this._http.get(`/public/tenant-lookup?orgId=${encodeURIComponent(orgId)}`)) as unknown + if (!res || typeof res !== 'object') return null + const any = res as { tenantId?: unknown } + if (typeof any.tenantId === 'string' && any.tenantId.trim()) return { tenantId: any.tenantId.trim() } + return {} + } + + /** + * SSO first-login: assign default author roles when org_id claim matches tenant. + * Idempotent. Caller should re-authenticate if status is `assigned`. + */ + async ensureSelfMembership(params: { tenantId: string }): Promise<{ + status: string + role?: string + scopes?: string[] + note?: string + }> { + return (await this._http.post( + `/management/tenants/${encodeURIComponent(params.tenantId)}/members/ensure-self`, + {}, + )) as { status: string; role?: string; scopes?: string[]; note?: string } + } + + async listManagementCfPackages(params: { tenantId: string; caseVersion?: '1.0' | '1.1' }): Promise { const caseVersion = params.caseVersion ?? '1.1' const res = (await this._http.get(`/management/tenants/${encodeURIComponent(params.tenantId)}/CFPackages?caseVersion=${encodeURIComponent(caseVersion)}`)) as unknown - // Be tolerant of shape differences: `{ CFPackages: [...] }` or `[...]`. - if (Array.isArray(res)) return res as OpenCaseManagementCfPackageSummary[] + if (res && typeof res === 'object' && 'frameworks' in res && Array.isArray((res as { frameworks: unknown }).frameworks)) { + return ((res as { frameworks: Array> }).frameworks).map((f) => ({ + identifier: String(f.sourcedId ?? f.identifier ?? ''), + title: typeof f.title === 'string' ? f.title : undefined, + creator: typeof f.creator === 'string' ? f.creator : undefined, + description: typeof f.description === 'string' ? f.description : undefined, + frameworkType: typeof f.frameworkType === 'string' ? f.frameworkType : undefined, + adoptionStatus: typeof f.adoptionStatus === 'string' ? f.adoptionStatus : undefined, + subject: typeof f.subject === 'string' ? f.subject : undefined, + version: typeof f.version === 'string' ? f.version : undefined, + lastChangeDateTime: typeof f.lastChangeDateTime === 'string' ? f.lastChangeDateTime : undefined, + caseVersion: typeof f.caseVersion === 'string' ? f.caseVersion : undefined, + readOnly: f.readOnly === true, + cgeFrameworkId: typeof f.cgeFrameworkId === 'string' ? f.cgeFrameworkId : undefined, + extensions: (f.extensions && typeof f.extensions === 'object') + ? f.extensions as { 'ext:opencase'?: OpenCaseListExtensions } + : undefined, + })) + } + + // Legacy tolerance + if (Array.isArray(res)) return res as CfDocumentSummary[] if (res && typeof res === 'object' && 'CFPackages' in res) { const any = res as { CFPackages?: unknown } - if (Array.isArray(any.CFPackages)) return any.CFPackages as OpenCaseManagementCfPackageSummary[] + if (Array.isArray(any.CFPackages)) return any.CFPackages as CfDocumentSummary[] } return [] } @@ -380,6 +431,321 @@ export class CaseApiClient { const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/api-keys/${encodeURIComponent(params.keyId)}` await this._http.delete(url) } + + // ── Member Management ─────────────────────────────────────────── + + /** + * List tenant members (Keycloak users with client roles). + * Requires `case.owner` scope. + */ + async listMembers(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members` + const res = (await this._http.get(url)) as unknown + if (res && typeof res === 'object' && 'members' in res) { + const obj = res as { members?: unknown } + if (Array.isArray(obj.members)) return obj.members as TenantMember[] + } + return [] + } + + /** + * Add or ensure a member by email and assign a role. + * New users get a temporary password (returned once) and must change it on first login. + * Requires `case.owner` scope. + */ + async createMember(params: { + tenantId: string + email: string + role: TenantMemberRole + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members` + const res = (await this._http.post(url, { + email: params.email, + role: params.role, + })) as unknown + if (res && typeof res === 'object') { + const obj = res as { + userId?: string + email?: string + role?: TenantMemberRole + scopes?: string[] + temporaryPassword?: string | null + mustChangePassword?: boolean + created?: boolean + } + if (obj.userId && obj.role) { + return { + userId: obj.userId, + email: obj.email ?? params.email, + username: null, + role: obj.role, + scopes: obj.scopes ?? [], + temporaryPassword: obj.temporaryPassword ?? null, + mustChangePassword: obj.mustChangePassword === true, + created: obj.created === true, + } + } + } + throw new Error('Unexpected create member response') + } + + /** + * Update a member's role. + * Requires `case.owner` scope. + */ + async updateMember(params: { + tenantId: string + userId: string + role: TenantMemberRole + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members/${encodeURIComponent(params.userId)}` + const res = (await this._http.patch(url, { role: params.role })) as unknown + if (res && typeof res === 'object') { + const obj = res as { userId?: string; email?: string | null; role?: TenantMemberRole; scopes?: string[] } + if (obj.userId && obj.role) { + return { + userId: obj.userId, + email: obj.email ?? null, + username: null, + role: obj.role, + scopes: obj.scopes ?? [], + } + } + } + throw new Error('Unexpected update member response') + } + + /** + * Remove a member's roles for this tenant. + * Requires `case.owner` scope. + */ + async deleteMember(params: { tenantId: string; userId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/members/${encodeURIComponent(params.userId)}` + await this._http.delete(url) + } + + // ── CASE Global (CGE) credentials ─────────────────────────────── + + /** + * Get public CGE credential status for a tenant (never returns the secret). + * Requires `case.owner` (or `case.admin`). + */ + async getCgeCredentials(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials` + const res = (await this._http.get(url)) as unknown + if (res && typeof res === 'object') { + const obj = res as CgeCredentialsPublic + return { + configured: obj.configured === true, + clientIdMasked: obj.clientIdMasked ?? null, + discoveryUrl: obj.discoveryUrl ?? null, + updatedAt: obj.updatedAt ?? null, + } + } + return { + configured: false, + clientIdMasked: null, + discoveryUrl: null, + updatedAt: null, + } + } + + /** + * Store / replace CGE org API key and OpenID discovery URL. + * Requires `case.owner` (or `case.admin`). + * Omit `clientSecret` (or pass empty) when updating discovery/clientId and keeping the existing secret. + */ + async putCgeCredentials(params: { + tenantId: string + clientId: string + clientSecret?: string + discoveryUrl: string + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials` + const res = (await this._http.put(url, { + clientId: params.clientId, + clientSecret: params.clientSecret ?? '', + discoveryUrl: params.discoveryUrl, + })) as unknown + if (res && typeof res === 'object') { + const obj = res as CgeCredentialsPublic + return { + configured: obj.configured === true, + clientIdMasked: obj.clientIdMasked ?? null, + discoveryUrl: obj.discoveryUrl ?? null, + updatedAt: obj.updatedAt ?? null, + } + } + throw new Error('Unexpected CGE credentials response') + } + + /** + * Delete stored CGE credentials for a tenant. + * Requires `case.owner` (or `case.admin`). + */ + async deleteCgeCredentials(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials` + await this._http.delete(url) + } + + /** + * Test CGE credentials by minting a client_credentials token. + * Requires `case.owner` (or `case.admin`). + */ + async testCgeCredentials(params: { tenantId: string }): Promise<{ ok: boolean; message: string }> { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/credentials/test` + const res = (await this._http.post(url, {})) as unknown + if (res && typeof res === 'object') { + const obj = res as { ok?: boolean; message?: string } + return { ok: obj.ok === true, message: obj.message ?? '' } + } + return { ok: false, message: 'Unexpected test response' } + } + + // ── CASE Global (CGE) framework discovery + cache ───────────────── + + async listCgeFrameworks(params: { + tenantId: string + /** Coalition API `search` param (title/id text search). */ + search?: string + /** @deprecated Use `search` — kept for backward compatibility. */ + q?: string + limit?: number + page?: number + /** @deprecated Use `page` — converted server-side when present. */ + offset?: number + region?: string + sector?: string + sort?: 'title' | 'published_at' | 'created_at' + order?: 'asc' | 'desc' + }): Promise { + const qs = new URLSearchParams() + const search = params.search ?? params.q + if (search) qs.set('search', search) + if (params.limit != null) qs.set('limit', String(params.limit)) + if (params.page != null) qs.set('page', String(params.page)) + if (params.offset != null) qs.set('offset', String(params.offset)) + if (params.region) qs.set('region', params.region) + if (params.sector) qs.set('sector', params.sector) + if (params.sort) qs.set('sort', params.sort) + if (params.order) qs.set('order', params.order) + const query = qs.toString() + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/frameworks${query ? `?${query}` : ''}` + return await this._http.get(url) + } + + async getCgeFramework(params: { tenantId: string; frameworkId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/frameworks/${encodeURIComponent(params.frameworkId)}` + return await this._http.get(url) + } + + async listCgeSubscriptions(params: { tenantId: string }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/subscriptions` + return await this._http.get(url) + } + + async createCgeSubscription(params: { + tenantId: string + frameworkId: string + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/subscriptions` + return await this._http.post(url, { frameworkId: params.frameworkId }) + } + + async importCgeFramework(params: { + tenantId: string + frameworkId: string + sourceUri?: string + registryId?: string + readOnly?: boolean + subscribe?: boolean + refresh?: boolean + linkedFromDocId?: string + caseVersion?: '1.0' | '1.1' + skipPublisherAuth?: boolean + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/import` + const res = (await this._http.post(url, { + frameworkId: params.frameworkId, + sourceUri: params.sourceUri, + registryId: params.registryId, + readOnly: params.readOnly ?? true, + subscribe: params.subscribe ?? false, + refresh: params.refresh ?? false, + linkedFromDocId: params.linkedFromDocId, + caseVersion: params.caseVersion ?? '1.1', + skipPublisherAuth: params.skipPublisherAuth ?? false, + })) as unknown + if (res && typeof res === 'object') { + const obj = res as CgeImportResult + return { + status: obj.status ?? 'imported', + id: obj.id ?? obj.docId ?? '', + docId: obj.docId ?? obj.id ?? '', + version: obj.version, + cgeFrameworkId: obj.cgeFrameworkId, + title: obj.title, + sourceUri: obj.sourceUri, + itemCount: obj.itemCount, + cachedAt: obj.cachedAt, + readOnly: obj.readOnly, + fromCache: obj.fromCache, + } + } + throw new Error('Unexpected CGE import response') + } + + async refreshCgeCache(params: { + tenantId: string + frameworkId: string + skipPublisherAuth?: boolean + }): Promise { + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/frameworks/${encodeURIComponent(params.frameworkId)}/refresh` + const res = (await this._http.post(url, { + skipPublisherAuth: params.skipPublisherAuth ?? false, + })) as unknown + if (res && typeof res === 'object') { + const obj = res as CgeImportResult + return { + status: obj.status ?? 'refreshed', + id: obj.id ?? obj.docId ?? '', + docId: obj.docId ?? obj.id ?? '', + cgeFrameworkId: obj.cgeFrameworkId, + title: obj.title, + sourceUri: obj.sourceUri, + itemCount: obj.itemCount, + cachedAt: obj.cachedAt, + } + } + throw new Error('Unexpected CGE refresh response') + } + + async searchCachedFrameworkItems(params: { + tenantId: string + docId: string + q?: string + limit?: number + caseVersion?: '1.0' | '1.1' + }): Promise<{ items: CachedFrameworkItemSummary[] }> { + const qs = new URLSearchParams() + if (params.q) qs.set('q', params.q) + if (params.limit != null) qs.set('limit', String(params.limit)) + if (params.caseVersion) qs.set('caseVersion', params.caseVersion) + const query = qs.toString() + const url = `/management/tenants/${encodeURIComponent(params.tenantId)}/cge/cache/${encodeURIComponent(params.docId)}/items${query ? `?${query}` : ''}` + const res = (await this._http.get(url)) as unknown + if (res && typeof res === 'object') { + const items = (res as Record).items + if (Array.isArray(items)) { + return { items: items as CachedFrameworkItemSummary[] } + } + } + return { items: [] } + } } /** Summary of an API key returned by the list endpoint. */ @@ -389,3 +755,20 @@ export type ApiKeySummary = { description: string } +export type TenantMemberRole = 'viewer' | 'author' | 'admin' + +export type TenantMember = { + userId: string + email: string | null + username?: string | null + role: TenantMemberRole | null + scopes: string[] +} + +export type CgeCredentialsPublic = { + configured: boolean + clientIdMasked: string | null + discoveryUrl: string | null + updatedAt: string | null +} + diff --git a/apps/editor/src/infrastructure/caseApi/cgeTypes.test.ts b/apps/editor/src/infrastructure/caseApi/cgeTypes.test.ts new file mode 100644 index 0000000..85f20d6 --- /dev/null +++ b/apps/editor/src/infrastructure/caseApi/cgeTypes.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { normalizeCgeFrameworkList, normalizeCgeSubscriptionList } from './cgeTypes' + +describe('normalizeCgeFrameworkList', () => { + it('reads coalition list responses wrapped in data', () => { + const result = normalizeCgeFrameworkList({ + data: [ + { + id: '4d669795-c669-46a0-9527-50b360911230', + frameworkId: '094a6795-78f7-454b-8d68-32b97a88b0cf', + title: 'Skills-First Roles', + providerName: '1EdTech', + sourceUri: 'https://example.com/pkg', + version: null, + }, + ], + pagination: { page: 1, limit: 30, total: 1, totalPages: 1 }, + }) + + expect(result).toEqual([ + { + frameworkId: '094a6795-78f7-454b-8d68-32b97a88b0cf', + registryId: '4d669795-c669-46a0-9527-50b360911230', + title: 'Skills-First Roles', + publisher: '1EdTech', + version: undefined, + sourceUri: 'https://example.com/pkg', + subscribed: false, + description: undefined, + }, + ]) + }) + + it('supports legacy frameworks array shape', () => { + const result = normalizeCgeFrameworkList({ + frameworks: [{ frameworkId: 'fw-1', title: 'Legacy', publisher: 'Org' }], + }) + expect(result).toHaveLength(1) + expect(result[0]?.frameworkId).toBe('fw-1') + }) +}) + +describe('normalizeCgeSubscriptionList', () => { + it('reads subscriptions wrapped in data', () => { + const result = normalizeCgeSubscriptionList({ + data: [{ frameworkId: 'fw-1', status: 'active' }], + }) + expect(result).toEqual([{ frameworkId: 'fw-1', status: 'active', subscribedAt: undefined }]) + }) +}) diff --git a/apps/editor/src/infrastructure/caseApi/cgeTypes.ts b/apps/editor/src/infrastructure/caseApi/cgeTypes.ts new file mode 100644 index 0000000..895946c --- /dev/null +++ b/apps/editor/src/infrastructure/caseApi/cgeTypes.ts @@ -0,0 +1,99 @@ +/** Coalition framework summary from CGE search/list APIs. */ +export type CgeFrameworkSummary = { + /** CASE framework identifier (CFDocument id / coalition frameworkId). */ + frameworkId: string + /** Coalition registry entry UUID (GET /frameworks/{id}). */ + registryId?: string + title: string + publisher?: string + version?: string + sourceUri?: string + subscribed?: boolean + description?: string +} + +export type CgeSubscriptionSummary = { + frameworkId: string + status?: string + subscribedAt?: string +} + +export type CgeImportResult = { + status: string + id: string + docId: string + version?: number + cgeFrameworkId?: string + title?: string + sourceUri?: string + itemCount?: number + cachedAt?: string + readOnly?: boolean + fromCache?: boolean +} + +export type CachedFrameworkItemSummary = { + identifier: string + uri?: string + fullStatement?: string + abbreviatedStatement?: string + humanCodingScheme?: string + CFItemType?: string +} + +function optionalText (value: unknown): string | undefined { + if (value == null) return undefined + return typeof value === 'string' ? value : String(value) +} + +function firstText (...values: unknown[]): string | undefined { + for (const value of values) { + if (value != null) return optionalText(value) + } + return undefined +} + +/** Extract an array from heterogeneous CGE list responses. */ +function extractCgeListArray (data: unknown, keys: string[]): unknown[] { + if (!data) return [] + if (Array.isArray(data)) return data + if (typeof data !== 'object') return [] + const obj = data as Record + for (const key of keys) { + const value = obj[key] + if (Array.isArray(value)) return value + } + return [] +} + +/** Normalize heterogeneous CGE API responses into framework summaries. */ +export function normalizeCgeFrameworkList (data: unknown): CgeFrameworkSummary[] { + const raw = extractCgeListArray(data, ['data', 'frameworks', 'items', 'results']) + + return raw.map((item) => { + const row = (item && typeof item === 'object' ? item : {}) as Record + return { + frameworkId: String(row.frameworkId ?? row.identifier ?? ''), + registryId: row.id ? String(row.id) : undefined, + title: String(row.title ?? row.name ?? 'Untitled framework'), + publisher: firstText(row.publisher, row.providerName, row.organization, row.creator), + version: optionalText(row.version), + sourceUri: firstText(row.sourceUri, row.source_uri, row.uri, row.packageUri), + subscribed: row.subscribed === true || row.isSubscribed === true || row.subscriptionStatus === 'active', + description: optionalText(row.description), + } + }).filter(f => f.frameworkId) +} + +export function normalizeCgeSubscriptionList (data: unknown): CgeSubscriptionSummary[] { + const raw = extractCgeListArray(data, ['data', 'subscriptions']) + + return raw.map((item) => { + const row = (item && typeof item === 'object' ? item : {}) as Record + return { + frameworkId: String(row.frameworkId ?? row.id ?? ''), + status: optionalText(row.status), + subscribedAt: firstText(row.subscribedAt, row.createdAt), + } + }).filter(s => s.frameworkId) +} diff --git a/apps/editor/src/infrastructure/caseApi/http.ts b/apps/editor/src/infrastructure/caseApi/http.ts index 848fe82..c1dbfd6 100644 --- a/apps/editor/src/infrastructure/caseApi/http.ts +++ b/apps/editor/src/infrastructure/caseApi/http.ts @@ -2,6 +2,7 @@ export type HttpClient = { get: (_url: string) => Promise post: (_url: string, _body: unknown) => Promise put: (_url: string, _body: unknown) => Promise + patch: (_url: string, _body: unknown) => Promise delete: (_url: string) => Promise } @@ -25,6 +26,19 @@ export class HttpError extends Error { } } +/** Extract a user-facing message from OpenCASE management API error bodies. */ +export function formatApiErrorMessage (error: unknown, fallback: string): string { + if (error instanceof HttpError && error.body && typeof error.body === 'object') { + const body = error.body as Record + if (typeof body.message === 'string' && body.message.trim()) return body.message + if (typeof body.error === 'string' && body.error.trim() && body.error !== 'cge_import_failed') { + return body.error + } + } + if (error instanceof Error && error.message) return error.message + return fallback +} + function joinUrl(baseUrl: string, url: string) { const b = baseUrl.replace(/\/+$/, '') const u = url.startsWith('/') ? url : `/${url}` @@ -70,6 +84,7 @@ export function createFetchHttpClient(baseUrl: string, options: FetchHttpClientO get: (url) => doRequest('GET', url), post: (url, body) => doRequest('POST', url, body), put: (url, body) => doRequest('PUT', url, body), + patch: (url, body) => doRequest('PATCH', url, body), delete: (url) => doRequest('DELETE', url), } } diff --git a/apps/editor/src/ui/auth/LoginScreen.tsx b/apps/editor/src/ui/auth/LoginScreen.tsx index 3183168..3166891 100644 --- a/apps/editor/src/ui/auth/LoginScreen.tsx +++ b/apps/editor/src/ui/auth/LoginScreen.tsx @@ -11,6 +11,8 @@ export default function LoginScreen() { const publicApi = useMemo(() => new CaseApiClient(createFetchHttpClient(cfg.opencaseBaseUrl)), [cfg.opencaseBaseUrl]) const [email, setEmail] = useState('') + const [orgId, setOrgId] = useState('') + const [mode, setMode] = useState<'email' | 'org'>('org') const [uiState, setUiState] = useState<'idle' | 'loading'>('idle') const [hint, setHint] = useState(null) @@ -49,6 +51,27 @@ export default function LoginScreen() { } }, [email, publicApi, setTenantId, signIn]) + const continueWithOrgId = useCallback(async () => { + const trimmed = orgId.trim() + if (!trimmed) return + setUiState('loading') + setHint(null) + try { + const res = await publicApi.lookupTenantByOrgId({ orgId: trimmed }) + const resolvedTenantId = res?.tenantId + if (resolvedTenantId) { + setTenantId(resolvedTenantId) + await signIn(resolvedTenantId) + return + } + setHint("If your organization is recognized, you'll be redirected to sign in.") + } catch { + setHint("If your organization is recognized, you'll be redirected to sign in.") + } finally { + setUiState('idle') + } + }, [orgId, publicApi, setTenantId, signIn]) + // Single-tenant mode: show a direct sign-in button (no email/tenant lookup) if (cfg.defaultTenantId) { return ( @@ -75,45 +98,74 @@ export default function LoginScreen() {
Sign in
-
Enter your email to continue to your organization’s sign-in.
+
+ {mode === 'org' + ? 'Enter your organization ID to continue to your organization’s sign-in.' + : 'Enter your email to continue to your organization’s sign-in.'} +
-
- - setEmail(e.target.value)} - placeholder="you@example.org" - autoComplete="email" - inputMode="email" - onKeyDown={(e) => { - if (e.key === 'Enter') void continueWithEmail() - }} - /> +
+ +
+ {mode === 'org' ? ( +
+ + setOrgId(e.target.value)} + placeholder="your-org-id" + autoComplete="organization" + onKeyDown={(e) => { + if (e.key === 'Enter') void continueWithOrgId() + }} + /> +
+ ) : ( +
+ + setEmail(e.target.value)} + placeholder="you@example.org" + autoComplete="email" + inputMode="email" + onKeyDown={(e) => { + if (e.key === 'Enter') void continueWithEmail() + }} + /> +
+ )} + {hint ?
{hint}
: null} {status === 'error' && error ?
{error}
: null}
-
- - - -
API: {cfg.opencaseBaseUrl}
) } - diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index acd0299..a30f27e 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -1,8 +1,8 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react' +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react' import type { ReactFlowInstance, Connection, Edge, NodeChange, EdgeChange } from '@xyflow/react' import type { OnBeforeDelete } from '@xyflow/react' import type { OnSelectionChangeFunc } from '@xyflow/react' -import { Background, BackgroundVariant, ConnectionMode, Controls, MiniMap, ReactFlow, SelectionMode } from '@xyflow/react' +import { Background, BackgroundVariant, ConnectionMode, Controls, MiniMap, ReactFlow, SelectionMode, useUpdateNodeInternals } from '@xyflow/react' import { nodeTypes } from '@/ui/editor/reactflow/nodeTypes' import { edgeTypes } from '@/ui/editor/reactflow/edgeTypes' import CanvasHeader from '@/ui/editor/components/CanvasHeader' @@ -14,9 +14,20 @@ import ConfirmActionDialog from '@/ui/editor/components/ConfirmActionDialog' 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 CgeFrameworkSearchPanel from '@/ui/editor/components/CgeFrameworkSearchPanel' +import RemoteFrameworkItemsPanel from '@/ui/editor/components/RemoteFrameworkItemsPanel' +import AssociationTypePickerDialog from '@/ui/editor/components/AssociationTypePickerDialog' import ViewCFPackageDialog from '@/ui/editor/components/ViewCFPackageDialog' import TreePanelView from '@/ui/editor/treePanel/TreePanelView' +import { CaseApiClient } from '@/infrastructure/caseApi/CaseApiClient' +import { createFetchHttpClient, formatApiErrorMessage } from '@/infrastructure/caseApi/http' +import { getAppConfig } from '@/app/config' +import type { CgeFrameworkSummary } from '@/infrastructure/caseApi/cgeTypes' +import { REMOTE_ITEM_MIME, parseRemoteItemDragPayload, type RemoteItemLink } from '@/ui/editor/remoteFramework/remoteFrameworkTypes' +import { + buildRemoteLinkEdges, + remoteLinkIdFromEdgeId, +} from '@/ui/editor/remoteFramework/buildRemoteLinkGraph' import { useEditor } from '@/ui/editor/state/EditorContext' import { isFrameworkNode, getNodeSize } from '@/ui/editor/state/helpers/nodeGeometry' import type { CaseEditorNodeType, CaseEditorEdge } from '@/ui/editor/reactflow/types' @@ -52,10 +63,13 @@ type ReactFlowGraphProps = { visible: boolean nodes: CaseEditorNodeType[] edges: CaseEditorEdge[] + /** Cross-framework links — drive handle-position resyncs via RemoteLinkInternalsSync */ + remoteLinks: RemoteItemLink[] onNodesChange: (changes: NodeChange[]) => void onEdgesChange: (changes: EdgeChange[]) => void onConnect: (connection: Connection) => void onNodeClick: (event: ReactMouseEvent, node: CaseEditorNodeType) => void + onNodeDoubleClick: (event: ReactMouseEvent, node: CaseEditorNodeType) => void onNodeDragStart: (event: ReactMouseEvent, node: CaseEditorNodeType) => void onNodeDragStop: () => void onEdgeClick: (event: ReactMouseEvent, edge: Edge) => void @@ -69,6 +83,8 @@ type ReactFlowGraphProps = { onReconnectEnd: (_: unknown, edge: Edge) => void onInit: (instance: ReactFlowInstance) => void onPointerDownCapture: (event: ReactPointerEvent) => void + onDragOver: (event: React.DragEvent) => void + onDrop: (event: React.DragEvent) => void } /** @@ -85,10 +101,12 @@ const ReactFlowGraph = memo(function ReactFlowGraph({ visible, nodes, edges, + remoteLinks, onNodesChange, onEdgesChange, onConnect, onNodeClick, + onNodeDoubleClick, onNodeDragStart, onNodeDragStop, onEdgeClick, @@ -102,9 +120,17 @@ const ReactFlowGraph = memo(function ReactFlowGraph({ onReconnectEnd, onInit, onPointerDownCapture, + onDragOver, + onDrop, }: ReactFlowGraphProps) { return ( -
+
nodes={nodes} edges={edges} @@ -112,6 +138,7 @@ const ReactFlowGraph = memo(function ReactFlowGraph({ onEdgesChange={onEdgesChange} onConnect={onConnect} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} onNodeDragStart={onNodeDragStart} onNodeDragStop={onNodeDragStop} onEdgeClick={onEdgeClick} @@ -140,6 +167,7 @@ const ReactFlowGraph = memo(function ReactFlowGraph({ proOptions={REACT_FLOW_PRO_OPTIONS} onInit={onInit} > + { + const frameworkRefIds = new Set(remoteLinks.map((l) => l.remoteFrameworkRefId)) + const frameworkNodeIds = nodes + .filter((n) => n.type === 'externalFrameworkNode' && frameworkRefIds.has((n.data as { refId?: string }).refId ?? '')) + .map((n) => n.id) + + const raf = requestAnimationFrame(() => { + for (const link of remoteLinks) { + updateNodeInternals(link.localItemId) + } + for (const nodeId of frameworkNodeIds) { + updateNodeInternals(nodeId) + } + }) + return () => cancelAnimationFrame(raf) + }, [remoteLinks, nodes, updateNodeInternals]) + + return null +} + export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { @@ -230,10 +287,30 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen updateSettings, addDetachedItem, addExternalFramework, + remoteLinks, + addRemoteLink, + removeRemoteLink, + updateRemoteLinkType, + removeRemoteFramework, + openCgeSearchPanel, + openRemoteItemsPanel, + closeSidePanel, + openExternalFrameworkSettings, + sidePanelMode, + setSidePanelMode, + activeRemoteFrameworkNodeId, + setActiveRemoteFrameworkNodeId, applyHierarchyLayout, applyStarLayout, } = useEditor() + const cfg = getAppConfig() + const { getAccessToken } = useAuth() + const api = useMemo( + () => new CaseApiClient(createFetchHttpClient(cfg.opencaseBaseUrl, { getAccessToken })), + [cfg.opencaseBaseUrl, getAccessToken], + ) + const reactFlowWrapRef = useRef(null) const reactFlowRef = useRef | null>(null) const [rfReady, setRfReady] = useState(false) @@ -241,13 +318,25 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const prevLayoutVersionRef = useRef(null) const [leaveOpen, setLeaveOpen] = useState(false) const [settingsOpen, setSettingsOpen] = useState(false) - const [externalFwDialogOpen, setExternalFwDialogOpen] = useState(false) - const [externalFwViewportCenter, setExternalFwViewportCenter] = useState<{ x: number; y: number } | undefined>(undefined) + const [pendingRemoteDrop, setPendingRemoteDrop] = useState<{ localItemId: string; payload: ReturnType } | null>(null) + const [remoteFrameworkRefreshing, setRemoteFrameworkRefreshing] = useState(false) const [cfPackageDialogOpen, setCfPackageDialogOpen] = useState(false) const [generatedCfPackage, setGeneratedCfPackage] = useState(null) const [viewCaseLoading, setViewCaseLoading] = useState(false) const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle') const [saveError, setSaveError] = useState(null) + const [selectedRemoteEdgeIds, setSelectedRemoteEdgeIds] = useState([]) + + const remoteLinkEdges = useMemo( + () => buildRemoteLinkEdges(nodesWithCallbacks, remoteLinks, selectedRemoteEdgeIds), + [nodesWithCallbacks, remoteLinks, selectedRemoteEdgeIds], + ) + + const allEditorEdges = useMemo( + () => [...editorEdges, ...remoteLinkEdges], + [editorEdges, remoteLinkEdges], + ) + const [activeView, setActiveView] = useState<'canvas' | 'tree'>('tree') const [forkWarningOpen, setForkWarningOpen] = useState(false) @@ -304,8 +393,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const edgeReconnectSuccessful = useRef(true) // ── Refs for save/export (keeps callbacks stable, avoids re-renders) ── - const graphRef = useRef({ nodes, edges: editorEdges }) - graphRef.current = { nodes, edges: editorEdges } + const graphRef = useRef({ nodes, edges: editorEdges, remoteLinks }) + graphRef.current = { nodes, edges: editorEdges, remoteLinks } const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings }) saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings } @@ -322,14 +411,15 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen setViewCaseLoading(false) } } else { - const { nodes: n, edges: e } = graphRef.current + const { nodes: n, edges: e, remoteLinks: rl } = graphRef.current const ctx = saveCtxRef.current - const { framework, layout } = fromEditorGraph({ graph: { nodes: n, edges: e } }) + const { framework, layout, remoteEditorData } = fromEditorGraph({ graph: { nodes: n, edges: e, remoteLinks: rl } }) const cfPackage = frameworkToCfPackage({ framework, layout, caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, + remoteEditorData, }) const caseJson = toOpenCaseFormat(cfPackage) setGeneratedCfPackage(absolutizeCaseUris(caseJson, window.location.origin)) @@ -368,14 +458,15 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen // data (not just canvas layout), in which case warn first: saving will // fork it into an independent local copy. const handleSave = useCallback(async () => { - const { nodes: n, edges: e } = graphRef.current + const { nodes: n, edges: e, remoteLinks: rl } = graphRef.current const ctx = saveCtxRef.current - const { framework, layout } = fromEditorGraph({ graph: { nodes: n, edges: e } }) + const { framework, layout, remoteEditorData } = fromEditorGraph({ graph: { nodes: n, edges: e, remoteLinks: rl } }) const cfPackage = frameworkToCfPackage({ framework, layout, caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, + remoteEditorData, }) const openCasePackage = toOpenCaseFormat(cfPackage) console.log('[Save] Generated OpenCASE package:', openCasePackage) @@ -430,7 +521,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen // Group edges by node pair (direction-agnostic) to fan out parallel labels. // This keeps multiple associations (e.g. precedes + exactMatchOf) readable. const byPair = new Map() - for (const edge of editorEdges) { + for (const edge of allEditorEdges) { const pairKey = edge.source < edge.target ? `${edge.source}__${edge.target}` @@ -453,7 +544,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen }) } - const result = editorEdges.map((edge) => { + const result = allEditorEdges.map((edge) => { const cached = prev.get(edge.id) const parallel = parallelOffsetsByEdgeId.get(edge.id) const parallelIndex = parallel?.parallelIndex ?? 0 @@ -482,7 +573,8 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen cached.parallelIndex === parallelIndex && cached.parallelCount === parallelCount ) { - const output: CaseEditorEdge = { ...edge, type: 'labeled' as const, data: cached.output.data, style: cached.output.style } + const resolvedType = edge.data?.isRemoteLink ? 'remoteLink' as const : 'labeled' as const + const output: CaseEditorEdge = { ...edge, type: resolvedType, data: cached.output.data, style: cached.output.style } next.set(edge.id, { input: edge, edgeType: globalEdgeType, filter: effectiveGroupingFilter, parallelIndex, parallelCount, output }) return output } @@ -496,7 +588,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen } let style = edge.style - if (effectiveGroupingFilter) { + if (effectiveGroupingFilter && !edge.data?.isRemoteLink) { const edgeGroupId = edge.data?.cfAssociation?.CFAssociationGroupingURI?.identifier const isRootEdge = edge.data?.isFrameworkRootConnection const matches = edgeGroupId === effectiveGroupingFilter @@ -510,14 +602,15 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen } } - const output: CaseEditorEdge = { ...edge, type: 'labeled' as const, data: baseData, style } + const resolvedType = edge.data?.isRemoteLink ? 'remoteLink' as const : 'labeled' as const + const output: CaseEditorEdge = { ...edge, type: resolvedType, data: baseData, style } next.set(edge.id, { input: edge, edgeType: globalEdgeType, filter: effectiveGroupingFilter, parallelIndex, parallelCount, output }) return output }) edgesCacheRef.current = next return result - }, [editorEdges, settings.edgeType, effectiveGroupingFilter]) + }, [allEditorEdges, settings.edgeType, effectiveGroupingFilter]) // React Flow stays MOUNTED (just CSS-hidden) while Tree View is active, so // that pan/zoom/selection state survives switching views. But feeding it a @@ -757,7 +850,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen Date.now() <= preserveDuringDrag.until && hasSelectChanges ) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') const reassertSelected = selectedNodeIds.map((id) => ({ type: 'select' as const, id, selected: true })) logSelectionDebug('onNodesChange/preserveMultiSelectionDuringDrag', { selectCount: selectChanges.length, @@ -830,7 +923,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen } } - const nonSelectChanges = changes.filter((c) => c.type !== 'select') + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') const reassertSelected = selectedNodeIds.map((id) => ({ type: 'select' as const, id, selected: true })) suppressSelectEchoRef.current = { until: Date.now() + 300 } logSelectionDebug('onNodesChange/blockedShiftDeselectAllBatch', { @@ -852,7 +945,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen selectChanges.some((c) => c.selected === true) && selectChanges.some((c) => c.selected === false) ) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') as NodeChange[] + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') as NodeChange[] suppressSelectEchoRef.current = { until: Date.now() + 300 } logSelectionDebug('onNodesChange/ignoredShiftMixedBatch', { originalCount: changes.length, @@ -865,7 +958,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen } if (suppressEcho && Date.now() <= suppressEcho.until && hasSelectChanges) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') logSelectionDebug('onNodesChange/suppressedSelectEcho', { forwardedNonSelectCount: nonSelectChanges.length }) if (nonSelectChanges.length > 0) onNodesChange(nonSelectChanges) return @@ -875,10 +968,59 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen logSelectionDebug('onNodesChange/forwarded', { changeCount: changes.length }) }, [logSelectionDebug, onNodesChange, selectedNodeIds]) + const handleRemoteEdgeChanges = useCallback((changes: EdgeChange[]) => { + for (const change of changes) { + if (!('id' in change) || !change.id.startsWith('remote_link_')) continue + + if (change.type === 'remove') { + const linkId = remoteLinkIdFromEdgeId(change.id) + if (linkId) removeRemoteLink(linkId) + setSelectedRemoteEdgeIds((prev) => prev.filter((id) => id !== change.id)) + continue + } + + if (change.type === 'select') { + if (change.selected) { + setSelectedRemoteEdgeIds((prev) => + shiftHeldRef.current + ? [...prev.filter((id) => id !== change.id), change.id] + : [change.id], + ) + const linkId = remoteLinkIdFromEdgeId(change.id) + const link = remoteLinks.find((l) => l.id === linkId) + if (link && !shiftHeldRef.current) { + onNodesChange([ + ...nodesWithCallbacks + .filter((n) => n.selected) + .map((n) => ({ type: 'select' as const, id: n.id, selected: false })), + { type: 'select', id: link.localItemId, selected: true }, + ]) + } + } else { + setSelectedRemoteEdgeIds((prev) => prev.filter((id) => id !== change.id)) + } + } + } + }, [nodesWithCallbacks, onNodesChange, remoteLinks, removeRemoteLink]) + const onEdgesChangeWithSelectionGuard = useCallback((changes: EdgeChange[]) => { + const remoteChanges = changes.filter((c) => 'id' in c && c.id.startsWith('remote_link_')) + const normalChanges = changes.filter((c) => !('id' in c) || !c.id.startsWith('remote_link_')) + + if (remoteChanges.length > 0) { + handleRemoteEdgeChanges(remoteChanges) + } + + if (normalChanges.length === 0) return + + if (normalChanges.some((c) => c.type === 'select' && c.selected === true)) { + setSelectedRemoteEdgeIds([]) + } + + const changesToProcess = normalChanges const suppressEcho = suppressSelectEchoRef.current const preserveDuringDrag = preserveSelectionDuringDragRef.current - const selectChanges = changes.filter((c): c is Extract => c.type === 'select') + const selectChanges = changesToProcess.filter((c): c is Extract => c.type === 'select') const hasSelectChanges = selectChanges.length > 0 logSelectionDebug('onEdgesChange/received', { hasSelectChanges, @@ -896,7 +1038,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen Date.now() <= preserveDuringDrag.until && hasSelectChanges ) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') const reassertSelected = selectedEdgeIds.map((id) => ({ type: 'select' as const, id, selected: true })) logSelectionDebug('onEdgesChange/preserveMultiSelectionDuringDrag', { selectCount: selectChanges.length, @@ -914,7 +1056,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen selectChanges.length > 1 && selectChanges.every((c) => c.selected === false) ) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') const reassertSelected = selectedEdgeIds.map((id) => ({ type: 'select' as const, id, selected: true })) suppressSelectEchoRef.current = { until: Date.now() + 300 } logSelectionDebug('onEdgesChange/blockedShiftDeselectAllBatch', { @@ -933,7 +1075,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen selectChanges.some((c) => c.selected === true) && selectChanges.some((c) => c.selected === false) ) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') as EdgeChange[] + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') as EdgeChange[] suppressSelectEchoRef.current = { until: Date.now() + 300 } logSelectionDebug('onEdgesChange/ignoredShiftMixedBatch', { originalCount: changes.length, @@ -946,15 +1088,15 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen } if (suppressEcho && Date.now() <= suppressEcho.until && hasSelectChanges) { - const nonSelectChanges = changes.filter((c) => c.type !== 'select') + const nonSelectChanges = changesToProcess.filter((c) => c.type !== 'select') logSelectionDebug('onEdgesChange/suppressedSelectEcho', { forwardedNonSelectCount: nonSelectChanges.length }) if (nonSelectChanges.length > 0) onEdgesChange(nonSelectChanges) return } - onEdgesChange(changes) - logSelectionDebug('onEdgesChange/forwarded', { changeCount: changes.length }) - }, [logSelectionDebug, onEdgesChange, selectedEdgeIds]) + onEdgesChange(changesToProcess) + logSelectionDebug('onEdgesChange/forwarded', { changeCount: changesToProcess.length }) + }, [handleRemoteEdgeChanges, logSelectionDebug, onEdgesChange, selectedEdgeIds]) const onPaneClick = useCallback((event: ReactMouseEvent) => { // Ignore Shift-modified pane clicks (they may occur as part of add-to-selection gestures). @@ -974,6 +1116,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen return } suppressSelectEchoRef.current = null + setSelectedRemoteEdgeIds([]) if (selectedNodeIds.length + selectedEdgeIds.length > 0) { logSelectionDebug('onPaneClick/clearSelection') clearSelection() @@ -1009,7 +1152,191 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen return { x: centerX, y: centerY } }, []) - // Keyboard shortcuts for adding items and external frameworks + const linkedFrameworkNodes = useMemo( + () => nodesWithCallbacks.filter((n) => n.type === 'externalFrameworkNode'), + [nodesWithCallbacks], + ) + + const hasSelection = Boolean(selectedNode || selectedEdge || (selectedNodeIds.length + selectedEdgeIds.length > 1)) + const showBrowsePanel = sidePanelMode === 'cgeSearch' || sidePanelMode === 'remoteItems' + const showPropertiesPanel = hasSelection && !showBrowsePanel + const sidePanelOpen = showBrowsePanel || showPropertiesPanel + + const handleAddCgeFrameworkToCanvas = useCallback(async (fw: CgeFrameworkSummary) => { + if (!tenantId) throw new Error('Sign in required') + + const existing = linkedFrameworkNodes.find((n) => n.data.cgeFrameworkId === fw.frameworkId) + if (existing) { + if (existing.data.cacheDocId && !existing.data.cacheError) { + openRemoteItemsPanel(existing.id) + } else { + openExternalFrameworkSettings(existing.id) + } + return + } + + const refId = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}` + const viewportCenter = getViewportCenter() + const nodeId = addExternalFramework({ + refId, + title: fw.title, + cgeFrameworkId: fw.frameworkId, + color: '', + sourceUri: fw.sourceUri, + uri: fw.sourceUri, + source: fw.publisher, + cacheLoading: true, + }, viewportCenter) + + try { + const result = await api.importCgeFramework({ + tenantId, + frameworkId: fw.frameworkId, + sourceUri: fw.sourceUri, + registryId: fw.registryId, + readOnly: true, + subscribe: false, + }) + updateNodeData(nodeId, { + title: result.title ?? fw.title, + cacheDocId: result.docId, + itemCount: result.itemCount, + cachedAt: result.cachedAt, + sourceUri: result.sourceUri ?? fw.sourceUri, + cacheError: null, + cacheLoading: false, + }) + setActiveRemoteFrameworkNodeId(nodeId) + setSidePanelMode('remoteItems') + } catch (e) { + updateNodeData(nodeId, { + cacheError: formatApiErrorMessage(e, 'Failed to download framework from publisher'), + cacheLoading: false, + }) + openExternalFrameworkSettings(nodeId) + } + }, [ + tenantId, + linkedFrameworkNodes, + getViewportCenter, + addExternalFramework, + api, + updateNodeData, + openRemoteItemsPanel, + openExternalFrameworkSettings, + setActiveRemoteFrameworkNodeId, + setSidePanelMode, + ]) + + const handleRefreshActiveRemoteFramework = useCallback(async () => { + const nodeId = activeRemoteFrameworkNodeId ?? selectedNode?.id + const node = linkedFrameworkNodes.find((n) => n.id === nodeId) ?? selectedNode + if (!tenantId || !node || node.type !== 'externalFrameworkNode') return + const cgeFrameworkId = node.data.cgeFrameworkId + if (!cgeFrameworkId) return + setRemoteFrameworkRefreshing(true) + updateNodeData(node.id, { cacheLoading: true, cacheError: null }) + try { + const result = node.data.cacheDocId + ? await api.refreshCgeCache({ + tenantId, + frameworkId: cgeFrameworkId, + skipPublisherAuth: node.data.skipPublisherAuth, + }) + : await api.importCgeFramework({ + tenantId, + frameworkId: cgeFrameworkId, + sourceUri: node.data.sourceUri ?? node.data.uri, + readOnly: true, + subscribe: false, + skipPublisherAuth: node.data.skipPublisherAuth, + }) + updateNodeData(node.id, { + title: result.title ?? node.data.title, + itemCount: result.itemCount, + cachedAt: result.cachedAt, + cacheDocId: result.docId ?? node.data.cacheDocId, + sourceUri: result.sourceUri ?? node.data.sourceUri, + cacheError: null, + cacheLoading: false, + }) + } catch (e) { + updateNodeData(node.id, { + cacheError: formatApiErrorMessage(e, 'Failed to download framework from publisher'), + cacheLoading: false, + }) + } finally { + setRemoteFrameworkRefreshing(false) + } + }, [tenantId, activeRemoteFrameworkNodeId, selectedNode, linkedFrameworkNodes, api, updateNodeData]) + + const handleRemoveActiveRemoteFramework = useCallback(() => { + const nodeId = activeRemoteFrameworkNodeId ?? selectedNode?.id + if (nodeId) removeRemoteFramework(nodeId) + }, [activeRemoteFrameworkNodeId, selectedNode?.id, removeRemoteFramework]) + + // Double-clicking a linked CASE Global framework opens its item browser once + // its cache has downloaded, or its settings panel when the download failed / + // hasn't happened yet. `useCallback` keeps ReactFlowGraph's memo intact. + const onNodeDoubleClick = useCallback((_: ReactMouseEvent, node: CaseEditorNodeType) => { + if (node.type !== 'externalFrameworkNode') return + const ext = node.data + if (ext.cacheDocId && !ext.cacheError) { + openRemoteItemsPanel(node.id) + } else { + openExternalFrameworkSettings(node.id) + } + }, [openRemoteItemsPanel, openExternalFrameworkSettings]) + + const handleRemoteDrop = useCallback((localItemId: string, raw: string) => { + const payload = parseRemoteItemDragPayload(raw) + if (!payload) return + setPendingRemoteDrop({ localItemId, payload }) + }, []) + + const confirmRemoteLink = useCallback((associationType: string) => { + if (!pendingRemoteDrop?.payload) return + const p = pendingRemoteDrop.payload + const linkId = globalThis.crypto?.randomUUID?.() ?? `link_${Date.now()}` + const link: RemoteItemLink = { + id: linkId, + localItemId: pendingRemoteDrop.localItemId, + remoteItemUri: p.uri ?? p.identifier, + remoteItemIdentifier: p.identifier, + remoteFrameworkRefId: p.remoteFrameworkRefId, + associationType, + remoteLabel: p.label, + remoteHumanCodingScheme: p.humanCodingScheme, + remoteFrameworkTitle: p.remoteFrameworkTitle, + remoteFrameworkColor: p.remoteFrameworkColor, + cfAssociationId: linkId, + } + addRemoteLink(link) + setPendingRemoteDrop(null) + }, [pendingRemoteDrop, addRemoteLink]) + + const onPaneDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes(REMOTE_ITEM_MIME)) { + e.preventDefault() + e.dataTransfer.dropEffect = 'link' + } + }, []) + + const onPaneDrop = useCallback((e: React.DragEvent) => { + const raw = e.dataTransfer.getData(REMOTE_ITEM_MIME) + if (!raw) return + e.preventDefault() + const instance = reactFlowRef.current + if (!instance) return + const position = instance.screenToFlowPosition({ x: e.clientX, y: e.clientY }) + const targetNode = instance.getIntersectingNodes({ x: position.x, y: position.y, width: 1, height: 1 }) + .find((n) => n.type === 'caseItemNode') + if (targetNode) { + handleRemoteDrop(targetNode.id, raw) + } + }, [handleRemoteDrop]) + + // Keyboard shortcuts for adding items and remote frameworks useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Check if user is typing in an input field @@ -1034,19 +1361,17 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen return } - // Cmd/Ctrl + F: Add external framework + // Cmd/Ctrl + F: Search CASE Global frameworks if (e.key.toLowerCase() === 'f') { e.preventDefault() - const viewportCenter = getViewportCenter() - setExternalFwViewportCenter(viewportCenter) - setExternalFwDialogOpen(true) + openCgeSearchPanel() return } } globalThis.addEventListener('keydown', handleKeyDown) return () => globalThis.removeEventListener('keydown', handleKeyDown) - }, [addDetachedItem, getViewportCenter]) + }, [addDetachedItem, getViewportCenter, openCgeSearchPanel]) const onBeforeDelete: OnBeforeDelete = useCallback( async ({ nodes, edges: deletedEdges }) => { @@ -1417,7 +1742,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen userName={userName ?? undefined} tenantId={tenantId ?? undefined} onChangePassword={authStatus === 'authenticated' && changePassword ? () => void changePassword() : undefined} - reserveRightForPanel={Boolean(selectedNode || selectedEdge || (selectedNodeIds.length + selectedEdgeIds.length > 1))} + reserveRightForPanel={sidePanelOpen} showSettings isDirty={isDirty} saveStatus={saveStatus} @@ -1468,10 +1793,12 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen visible={activeView !== 'tree'} nodes={canvasNodes} edges={canvasEdges} + remoteLinks={remoteLinks} onNodesChange={onNodesChangeWithSelectionGuard} onEdgesChange={onEdgesChangeWithSelectionGuard} onConnect={onConnect} onNodeClick={onNodeClick} + onNodeDoubleClick={onNodeDoubleClick} onNodeDragStart={onNodeDragStart} onNodeDragStop={onNodeDragStop} onEdgeClick={onEdgeClick} @@ -1485,11 +1812,13 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen onReconnectEnd={onReconnectEnd} onInit={onReactFlowInit} onPointerDownCapture={onCanvasPointerDownCapture} + onDragOver={onPaneDragOver} + onDrop={onPaneDrop} /> { clearSelection(); closeSidePanel() }} onChangeNode={updateNodeData} hideColorBand={activeView === 'tree'} onViewCFPackage={handleViewCFPackage} @@ -1501,12 +1830,19 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen ensureCfSubject={ensureCfSubject} cfConcepts={cfConcepts} ensureCfConcept={ensureCfConcept} + remoteLinks={remoteLinks} + onRemoveRemoteLink={removeRemoteLink} + onUpdateRemoteLinkType={updateRemoteLinkType} + onRemoveRemoteFramework={removeRemoteFramework} + onBrowseRemoteItems={openRemoteItemsPanel} + onRefreshRemoteFramework={selectedNode?.type === 'externalFrameworkNode' && selectedNode.data.cgeFrameworkId ? handleRefreshActiveRemoteFramework : undefined} + remoteFrameworkRefreshing={remoteFrameworkRefreshing} /> { clearSelection(); closeSidePanel() }} onChangeEdge={updateEdgeData} onFlipEdge={flipEdge} cfAssociationGroupings={cfAssociationGroupings} @@ -1514,11 +1850,11 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen /> { clearSelection(); closeSidePanel() }} onDeleteSelected={() => { const selectedNodes = nodesWithCallbacks.filter((n) => selectedNodeIds.includes(n.id)) const selectedEdgesForDelete = editorEdges.filter((e) => selectedEdgeIds.includes(e.id)) @@ -1617,30 +1953,59 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen onSave={updateSettings} /> + {showBrowsePanel ? ( +
+ {sidePanelMode === 'cgeSearch' && tenantId ? ( + + ) : null} + {sidePanelMode === 'remoteItems' && tenantId ? ( + n.id === (activeRemoteFrameworkNodeId ?? linkedFrameworkNodes[0]?.id))?.data.cgeFrameworkId) + ? handleRefreshActiveRemoteFramework + : undefined + } + onRemoveFramework={handleRemoveActiveRemoteFramework} + frameworkRefreshing={remoteFrameworkRefreshing} + onOpenFrameworkSettings={() => { + const nodeId = activeRemoteFrameworkNodeId ?? linkedFrameworkNodes[0]?.id + if (nodeId) { + setSelectedRemoteEdgeIds([]) + openExternalFrameworkSettings(nodeId) + } + }} + /> + ) : null} +
+ ) : null} + + setPendingRemoteDrop(null)} + onConfirm={confirmRemoteLink} + /> + { const viewportCenter = getViewportCenter() addDetachedItem(viewportCenter) }} onAddExternalFramework={() => { - const viewportCenter = getViewportCenter() - setExternalFwViewportCenter(viewportCenter) - setExternalFwDialogOpen(true) - }} - sidePanelOpen={Boolean(selectedNode || selectedEdge || (selectedNodeIds.length + selectedEdgeIds.length > 1))} - /> - - { - setExternalFwDialogOpen(false) - setExternalFwViewportCenter(undefined) - }} - onCreate={(draft) => { - setExternalFwDialogOpen(false) - addExternalFramework(draft, externalFwViewportCenter) - setExternalFwViewportCenter(undefined) + openCgeSearchPanel() }} + sidePanelOpen={sidePanelOpen} /> void + onConfirm: (associationType: string) => void +} + +export default function AssociationTypePickerDialog({ + open, + defaultType = 'isRelatedTo', + remoteLabel, + onCancel, + onConfirm, +}: Readonly) { + if (!open) return null + + const options = CASE_ASSOCIATION_TYPES.filter((t) => t !== 'isChildOf') + + return ( +
+ + ) +} diff --git a/apps/editor/src/ui/editor/components/CgeFrameworkSearchPanel.tsx b/apps/editor/src/ui/editor/components/CgeFrameworkSearchPanel.tsx new file mode 100644 index 0000000..be62f87 --- /dev/null +++ b/apps/editor/src/ui/editor/components/CgeFrameworkSearchPanel.tsx @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Button } from '@/ui/shared/components/ui/button' +import { Input } from '@/ui/shared/components/ui/input' +import type { CaseApiClient } from '@/infrastructure/caseApi/CaseApiClient' +import { normalizeCgeFrameworkList, normalizeCgeSubscriptionList } from '@/infrastructure/caseApi/CaseApiClient' +import type { CgeFrameworkSummary } from '@/infrastructure/caseApi/cgeTypes' +import { formatApiErrorMessage } from '@/infrastructure/caseApi/http' + +type Props = { + tenantId: string + api: CaseApiClient + onClose: () => void + onAddToCanvas: (framework: CgeFrameworkSummary) => Promise +} + +export default function CgeFrameworkSearchPanel({ tenantId, api, onClose, onAddToCanvas }: Readonly) { + const [query, setQuery] = useState('') + const [debouncedQuery, setDebouncedQuery] = useState('') + const [frameworks, setFrameworks] = useState([]) + const [subscribedIds, setSubscribedIds] = useState>(new Set()) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [busyId, setBusyId] = useState(null) + const [addingId, setAddingId] = useState(null) + + useEffect(() => { + const t = globalThis.setTimeout(() => setDebouncedQuery(query.trim()), 300) + return () => globalThis.clearTimeout(t) + }, [query]) + + const load = useCallback(async () => { + setLoading(true) + setError(null) + try { + const [fwRes, subRes] = await Promise.all([ + api.listCgeFrameworks({ tenantId, search: debouncedQuery || undefined, limit: 30 }), + api.listCgeSubscriptions({ tenantId }).catch(() => null), + ]) + const list = normalizeCgeFrameworkList(fwRes) + const subs = normalizeCgeSubscriptionList(subRes) + const subSet = new Set(subs.map((s) => s.frameworkId)) + for (const f of list) { + if (f.subscribed) subSet.add(f.frameworkId) + } + setFrameworks(list) + setSubscribedIds(subSet) + } catch (e) { + setError(formatApiErrorMessage(e, 'Search failed')) + setFrameworks([]) + } finally { + setLoading(false) + } + }, [api, tenantId, debouncedQuery]) + + useEffect(() => { + void load() + }, [load]) + + const handleSubscribe = async (frameworkId: string) => { + setBusyId(frameworkId) + setError(null) + try { + await api.createCgeSubscription({ tenantId, frameworkId }) + setSubscribedIds((prev) => new Set(prev).add(frameworkId)) + } catch (e) { + setError(formatApiErrorMessage(e, 'Subscribe failed')) + } finally { + setBusyId(null) + } + } + + const handleAdd = async (fw: CgeFrameworkSummary) => { + setAddingId(fw.frameworkId) + setError(null) + try { + await onAddToCanvas(fw) + } catch (e) { + setError(formatApiErrorMessage(e, 'Add to canvas failed')) + } finally { + setAddingId(null) + } + } + + const rows = useMemo(() => frameworks, [frameworks]) + + return ( +
+
+
+
+

Add remote framework

+

Search CASE Global registered frameworks

+
+ +
+ setQuery(e.target.value)} + placeholder="Search frameworks…" + className="mt-3" + autoFocus + /> +
+ +
+ {error ? ( +

{error}

+ ) : null} + {loading ? ( +

Searching…

+ ) : rows.length === 0 ? ( +

No frameworks found.

+ ) : ( +
    + {rows.map((fw) => { + const subscribed = subscribedIds.has(fw.frameworkId) + return ( +
  • +
    {fw.title}
    + {fw.publisher ?
    {fw.publisher}
    : null} + {fw.version ?
    v{fw.version}
    : null} +
    + {subscribed ? ( + Subscribed + ) : ( + + )} + +
    +
  • + ) + })} +
+ )} +
+
+ ) +} diff --git a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx index 347074f..18df491 100644 --- a/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx +++ b/apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx @@ -1,5 +1,8 @@ import { memo, useEffect, useMemo, useState } from 'react' +import type { CheckedState } from '@radix-ui/react-checkbox' import { Button } from '@/ui/shared/components/ui/button' +import { Checkbox } from '@/ui/shared/components/ui/checkbox' +import { Label } from '@/ui/shared/components/ui/label' import { ComboboxInput } from '@/ui/shared/components/ui/combobox-input' import { TagComboboxInput } from '@/ui/shared/components/ui/tag-combobox-input' import type { TagComboboxOption } from '@/ui/shared/components/ui/tag-combobox-input' @@ -19,6 +22,9 @@ import type { EducationLevelOption } from '@/ui/editor/terminology/educationLeve import { getAppConfig } from '@/app/config' import ColorBandPicker from '@/ui/editor/components/ColorBandPicker' import SidebarSection from './SidebarSection' +import type { RemoteItemLink } from '@/ui/editor/remoteFramework/remoteFrameworkTypes' +import { CASE_ASSOCIATION_TYPES } from '../reactflow/types' +import { ArrowPathIcon } from '@heroicons/react/24/solid' /* ── Shared styling constants ── */ const INPUT_CLS = 'w-full rounded-xl border border-black/15 bg-white px-3 py-2.5 text-base text-slate-900 focus-visible:outline-2 focus-visible:outline-violet-700/40 focus-visible:outline-offset-2' @@ -38,12 +44,21 @@ type Props = { ensureCfSubject?: (_title: string) => CFSubject | null cfConcepts?: CFConcept[] ensureCfConcept?: (_title: string) => CFConcept | null + remoteLinks?: RemoteItemLink[] + onRemoveRemoteLink?: (_linkId: string) => void + onUpdateRemoteLinkType?: (_linkId: string, _associationType: string) => void + onRemoveRemoteFramework?: (_nodeId: string) => void + onBrowseRemoteItems?: (_nodeId: string) => void + onRefreshRemoteFramework?: () => Promise + remoteFrameworkRefreshing?: boolean hideColorBand?: boolean } export default memo(function NodePropertiesPanel({ node, onClose, onChangeNode, onViewCFPackage, isPublishedToOpenCase, availableLicenses, cfItemTypes = [], ensureCfItemType, cfSubjects = [], ensureCfSubject, cfConcepts = [], ensureCfConcept, + remoteLinks = [], onRemoveRemoteLink, onUpdateRemoteLinkType, + onRemoveRemoteFramework, onBrowseRemoteItems, onRefreshRemoteFramework, remoteFrameworkRefreshing, hideColorBand = false, }: Readonly) { const [copied, setCopied] = useState(null) @@ -146,11 +161,21 @@ export default memo(function NodePropertiesPanel({ : (cfItem?.humanCodingScheme ?? cfItem?.alternativeLabel ?? cfItem?.CFItemType ?? 'Untitled item') const headerSubtitle = isExternalFramework - ? 'External reference' + ? (externalData?.cacheError + ? 'Cache unavailable' + : externalData?.cacheDocId + ? 'Cached remote framework' + : externalData?.cacheLoading + ? 'Downloading cache…' + : 'Remote framework (not cached)') : isFramework ? 'Framework' : (cfItem?.CFItemType ?? 'Item') + const itemRemoteLinks = node && isItemNode(node) + ? remoteLinks.filter((l) => l.localItemId === node.id) + : [] + return (