From ad90bbb5ae0636629bcc73535955704616ce83bd Mon Sep 17 00:00:00 2001 From: Alan Lail Date: Tue, 26 May 2026 12:24:45 -0400 Subject: [PATCH] Always display the absolute uri for relevant fields when data is provided by the server. When working with a local copy, estimate absolute path using local origin --- .gitignore | 1 + apps/editor/package-lock.json | 1 + apps/editor/src/app/App.tsx | 7 +++ .../framework/mappers/case/toCasePackage.ts | 27 ++++++++++ apps/editor/src/ui/editor/EditorCanvas.tsx | 50 ++++++++++++------- .../editor/components/ViewCFPackageDialog.tsx | 22 ++++---- apps/opencase/eslint.config.mjs | 2 +- 7 files changed, 80 insertions(+), 30 deletions(-) diff --git a/.gitignore b/.gitignore index 5186cd0..8c401e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env AGENTS.md +CLAUDE.md .DS_Store diff --git a/apps/editor/package-lock.json b/apps/editor/package-lock.json index a19fbef..2343ec1 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", diff --git a/apps/editor/src/app/App.tsx b/apps/editor/src/app/App.tsx index f043f3c..33595a7 100644 --- a/apps/editor/src/app/App.tsx +++ b/apps/editor/src/app/App.tsx @@ -395,6 +395,12 @@ function AppInner() { removeFrameworkFromStorage(activeFrameworkId) }, [api, tenantId, activeFrameworkId, caseApiVersion, removeFrameworkFromStorage]) + // Handler to fetch the published CFPackage from the server (returns CASE JSON with absolute URIs) + const handleFetchCfPackage = useCallback(async () => { + if (!activeFrameworkId) throw new Error('No active framework') + return api.getCfPackage({ docId: activeFrameworkId, caseVersion: caseApiVersion }) + }, [api, activeFrameworkId, caseApiVersion]) + // Handler to save the CFPackage to the server // Must be defined before early returns (React hooks rules) const handleSaveToServer = useCallback( @@ -493,6 +499,7 @@ function AppInner() { onSaveToServer={tenantId ? handleSaveToServer : undefined} isPublishedToOpenCase={activeFrameworkId ? publishedFrameworkIds.has(activeFrameworkId) : false} onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined} + onFetchCfPackage={activeFrameworkId ? handleFetchCfPackage : undefined} /> ) diff --git a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts index d81d7f4..a4f3a1d 100644 --- a/apps/editor/src/application/framework/mappers/case/toCasePackage.ts +++ b/apps/editor/src/application/framework/mappers/case/toCasePackage.ts @@ -749,6 +749,33 @@ export function toOpenCaseFormat(cfPackage: CFPackage): CaseV1p1Package { } } +/** + * Walk a CASE JSON object and prepend baseUrl to any relative /ims/case/ URI strings. + * Leaves already-absolute URIs and non-CASE strings untouched. + */ +export function absolutizeCaseUris(payload: T, baseUrl: string): T { + const seen = new WeakSet() + + const normalize = (v: string): string => + v.startsWith('/ims/case/') ? `${baseUrl}${v}` : v + + const walk = (value: unknown): unknown => { + if (value === null || value === undefined) return value + if (typeof value === 'string') return normalize(value) + if (typeof value !== 'object') return value + if (seen.has(value as object)) return value + seen.add(value as object) + if (Array.isArray(value)) return value.map(walk) + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = k === 'uri' && typeof v === 'string' ? normalize(v) : walk(v) + } + return out + } + + return walk(payload) as T +} + /** * Convenience type for the export parameters. */ diff --git a/apps/editor/src/ui/editor/EditorCanvas.tsx b/apps/editor/src/ui/editor/EditorCanvas.tsx index ca6773f..c1920fd 100644 --- a/apps/editor/src/ui/editor/EditorCanvas.tsx +++ b/apps/editor/src/ui/editor/EditorCanvas.tsx @@ -21,7 +21,7 @@ import type { CaseEditorNodeType, CaseEditorEdge } from '@/ui/editor/reactflow/t import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types' import { useAuth } from '@/app/providers/AuthProvider' import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph' -import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' +import { absolutizeCaseUris, frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' type EditorCanvasProps = { onBack?: () => void @@ -30,9 +30,11 @@ type EditorCanvasProps = { isPublishedToOpenCase?: boolean /** Archive the current framework on the server and navigate home */ onArchiveFramework?: () => Promise + /** Fetch the published CFPackage from the server (returns CASE JSON with absolute URIs) */ + onFetchCfPackage?: () => Promise } -export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework }: Readonly) { +export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage }: Readonly) { const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth() const { nodes, @@ -89,6 +91,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const [externalFwViewportCenter, setExternalFwViewportCenter] = useState<{ x: number; y: number } | undefined>(undefined) 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) @@ -139,20 +142,33 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen const saveCtxRef = useRef({ caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings }) saveCtxRef.current = { caseVersion, edgeType: settings.edgeType, cfItemTypes, cfSubjects, cfConcepts, cfLicenses, cfAssociationGroupings } - // Generate CFPackage from current editor state and open the viewer - const handleViewCFPackage = useCallback(() => { - const { nodes: n, edges: e } = graphRef.current - const ctx = saveCtxRef.current - const { framework, layout } = fromEditorGraph({ graph: { nodes: n, edges: e } }) - const cfPackage = frameworkToCfPackage({ - framework, layout, incrementVersion: false, - caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, - cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, - cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, - }) - setGeneratedCfPackage(cfPackage) - setCfPackageDialogOpen(true) - }, []) + // Open the CFPackage viewer. Fetches from the server when published (absolute URIs); + // falls back to local generation for unsaved/draft frameworks. + const handleViewCFPackage = useCallback(async () => { + if (isPublishedToOpenCase && onFetchCfPackage) { + setCfPackageDialogOpen(true) + setViewCaseLoading(true) + try { + const pkg = await onFetchCfPackage() + setGeneratedCfPackage(pkg) + } finally { + setViewCaseLoading(false) + } + } else { + const { nodes: n, edges: e } = graphRef.current + const ctx = saveCtxRef.current + const { framework, layout } = fromEditorGraph({ graph: { nodes: n, edges: e } }) + const cfPackage = frameworkToCfPackage({ + framework, layout, incrementVersion: false, + caseVersion: ctx.caseVersion, edgeType: ctx.edgeType, + cfItemTypes: ctx.cfItemTypes, cfSubjects: ctx.cfSubjects, + cfConcepts: ctx.cfConcepts, cfLicenses: ctx.cfLicenses, cfAssociationGroupings: ctx.cfAssociationGroupings, + }) + const caseJson = toOpenCaseFormat(cfPackage) + setGeneratedCfPackage(absolutizeCaseUris(caseJson, window.location.origin)) + setCfPackageDialogOpen(true) + } + }, [isPublishedToOpenCase, onFetchCfPackage]) // Save: Generate CFPackage with version increment and POST to server const handleSave = useCallback(async () => { @@ -167,7 +183,6 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen }) const openCasePackage = toOpenCaseFormat(cfPackage) console.log('[Save] Generated OpenCASE package:', openCasePackage) - setGeneratedCfPackage(cfPackage) if (onSaveToServer) { setSaveStatus('saving') @@ -1344,6 +1359,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen }} cfPackage={generatedCfPackage} caseVersion={caseVersion} + loading={viewCaseLoading} /> diff --git a/apps/editor/src/ui/editor/components/ViewCFPackageDialog.tsx b/apps/editor/src/ui/editor/components/ViewCFPackageDialog.tsx index 15c707c..756434b 100644 --- a/apps/editor/src/ui/editor/components/ViewCFPackageDialog.tsx +++ b/apps/editor/src/ui/editor/components/ViewCFPackageDialog.tsx @@ -3,27 +3,25 @@ import { Button } from '@/ui/shared/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/ui/shared/components/ui/dialog' import type { CFPackage } from '@/domain/case/types' import type { CaseVersion } from '@/application/framework/mappers/case/CasePackageSnapshot' -import { toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage' type Props = { open: boolean onClose: () => void cfPackage: CFPackage | null caseVersion: CaseVersion + loading?: boolean } -export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVersion }: Readonly) { +export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVersion, loading }: Readonly) { const [copied, setCopied] = useState(false) - // Convert to OpenCASE REST API format (lowercase property names) const jsonString = useMemo(() => { if (!cfPackage) return '' - const openCasePackage = toOpenCaseFormat(cfPackage) - return JSON.stringify(openCasePackage, null, 2) + return JSON.stringify(cfPackage, null, 2) }, [cfPackage]) const copyToClipboard = useCallback(async () => { - if (!jsonString) return + if (!jsonString || loading) return try { await globalThis.navigator?.clipboard?.writeText(jsonString) setCopied(true) @@ -39,10 +37,10 @@ export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVers setCopied(true) globalThis.setTimeout(() => setCopied(false), 2000) } - }, [jsonString]) + }, [jsonString, loading]) const downloadJson = useCallback(() => { - if (!cfPackage) return + if (!cfPackage || loading) return const blob = new Blob([jsonString], { type: 'application/json' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') @@ -53,7 +51,7 @@ export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVers a.click() document.body.removeChild(a) URL.revokeObjectURL(url) - }, [cfPackage, jsonString]) + }, [cfPackage, loading, jsonString]) const stats = useMemo(() => { if (!cfPackage) return null @@ -91,7 +89,7 @@ export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVers
-            {jsonString || 'No CFPackage data'}
+            {loading ? 'Loading…' : jsonString || 'No CFPackage data'}
           
@@ -99,10 +97,10 @@ export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVers - - diff --git a/apps/opencase/eslint.config.mjs b/apps/opencase/eslint.config.mjs index 1f54995..d55712a 100644 --- a/apps/opencase/eslint.config.mjs +++ b/apps/opencase/eslint.config.mjs @@ -12,7 +12,7 @@ export default defineConfig([ }, }, { - files: ['**/*.cjs'], + files: ['**/*.cjs', '**/*.mjs'], languageOptions: { globals: { ...globals.node,