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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.env
AGENTS.md
CLAUDE.md
.DS_Store
1 change: 1 addition & 0 deletions apps/editor/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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}
/>
</EditorProvider>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(payload: T, baseUrl: string): T {
const seen = new WeakSet<object>()

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<string, unknown> = {}
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
out[k] = k === 'uri' && typeof v === 'string' ? normalize(v) : walk(v)
}
return out
}

return walk(payload) as T
}

/**
* Convenience type for the export parameters.
*/
Expand Down
50 changes: 33 additions & 17 deletions apps/editor/src/ui/editor/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,9 +30,11 @@ type EditorCanvasProps = {
isPublishedToOpenCase?: boolean
/** Archive the current framework on the server and navigate home */
onArchiveFramework?: () => Promise<void>
/** Fetch the published CFPackage from the server (returns CASE JSON with absolute URIs) */
onFetchCfPackage?: () => Promise<CFPackage>
}

export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework }: Readonly<EditorCanvasProps>) {
export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage }: Readonly<EditorCanvasProps>) {
const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth()
const {
nodes,
Expand Down Expand Up @@ -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<CFPackage | null>(null)
const [viewCaseLoading, setViewCaseLoading] = useState(false)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'success' | 'error'>('idle')
const [saveError, setSaveError] = useState<string | null>(null)

Expand Down Expand Up @@ -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 () => {
Expand All @@ -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')
Expand Down Expand Up @@ -1344,6 +1359,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
}}
cfPackage={generatedCfPackage}
caseVersion={caseVersion}
loading={viewCaseLoading}
/>

</div>
Expand Down
22 changes: 10 additions & 12 deletions apps/editor/src/ui/editor/components/ViewCFPackageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props>) {
export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVersion, loading }: Readonly<Props>) {
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)
Expand All @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -91,18 +89,18 @@ export default function ViewCFPackageDialog({ open, onClose, cfPackage, caseVers

<div className="relative max-h-[50vh] overflow-auto rounded-lg border border-black/10 bg-slate-900">
<pre className="p-4 text-xs leading-relaxed text-slate-100">
<code>{jsonString || 'No CFPackage data'}</code>
<code>{loading ? 'Loading…' : jsonString || 'No CFPackage data'}</code>
</pre>
</div>

<DialogFooter className="gap-2 sm:gap-2">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
<Button variant="secondary" onClick={downloadJson} disabled={!cfPackage}>
<Button variant="secondary" onClick={downloadJson} disabled={!cfPackage || loading}>
Download JSON
</Button>
<Button onClick={copyToClipboard} disabled={!cfPackage}>
<Button onClick={copyToClipboard} disabled={!cfPackage || loading}>
{copied ? 'Copied!' : 'Copy to Clipboard'}
</Button>
</DialogFooter>
Expand Down
2 changes: 1 addition & 1 deletion apps/opencase/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export default defineConfig([
},
},
{
files: ['**/*.cjs'],
files: ['**/*.cjs', '**/*.mjs'],
languageOptions: {
globals: {
...globals.node,
Expand Down
Loading