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
Expand Up @@ -3,3 +3,4 @@ AGENTS.md
CLAUDE.md
.DS_Store
node_modules/
*.tsbuildinfo
42 changes: 22 additions & 20 deletions apps/editor/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ function AppInner() {

const [authCallbackState, setAuthCallbackState] = useState<'idle' | 'processing' | 'error'>('idle')
const [remoteOpenState, setRemoteOpenState] = useState<'idle' | 'loading'>('idle')
// Framework ids confirmed against the server this session (fetched from it or just saved to it).
// The localStorage cache can hold entries the server no longer has — or never had, e.g. drafts
// from before save-on-create — so the editor only opens a framework once it's in this set.
const [serverVerifiedIds, setServerVerifiedIds] = useState<ReadonlySet<string>>(() => new Set())
const markServerVerified = useCallback((id: string) => {
setServerVerifiedIds((prev) => (prev.has(id) ? prev : new Set(prev).add(id)))
}, [])
const authStatusRef = useRef(authStatus)
authStatusRef.current = authStatus

Expand Down Expand Up @@ -298,10 +305,6 @@ function AppInner() {
setScreen('home')
}, [])

const openFramework = useCallback((id: string) => {
navigateToFramework(id)
}, [navigateToFramework])

/** Remove a framework from localStorage (used after archive or hard delete) */
const removeFrameworkFromStorage = useCallback((docId: string) => {
setFrameworks((prev) => {
Expand Down Expand Up @@ -329,27 +332,29 @@ function AppInner() {
const createNew = useCallback(async (draft: CreateFrameworkDraft) => {
const fw = createNewFrameworkDraft(draft)
if (tenantId) await saveNewFrameworkToServer(fw, tenantId)
markServerVerified(fw.id)

setFrameworks((prev) => {
const next = [fw, ...prev]
saveFrameworks(next)
return next
})
navigateToFramework(fw.id)
}, [navigateToFramework, tenantId, saveNewFrameworkToServer])
}, [navigateToFramework, tenantId, saveNewFrameworkToServer, markServerVerified])

/** Create a HomeFramework from a pre-populated domain Framework (e.g. from spreadsheet upload). */
const createFromFramework = useCallback(async (framework: Framework) => {
const fw = createHomeFrameworkFromDomain(framework)
if (tenantId) await saveNewFrameworkToServer(fw, tenantId)
markServerVerified(fw.id)

setFrameworks((prev) => {
const next = [fw, ...prev]
saveFrameworks(next)
return next
})
navigateToFramework(fw.id)
}, [navigateToFramework, tenantId, saveNewFrameworkToServer])
}, [navigateToFramework, tenantId, saveNewFrameworkToServer, markServerVerified])

const openRemoteFramework = useCallback(
async (docId: string, opts?: { replace?: boolean }) => {
Expand Down Expand Up @@ -384,6 +389,7 @@ function AppInner() {

// Create a HomeFramework entry from the domain Framework
const fw = createHomeFrameworkFromDomain(framework, mirrorStatus)
markServerVerified(fw.id)
if (pkg.CFDocument?.extensions) {
fw.cfDocument = { ...fw.cfDocument, extensions: pkg.CFDocument.extensions }
}
Expand Down Expand Up @@ -419,24 +425,25 @@ function AppInner() {
setRemoteOpenState('idle')
}
},
[api, mergeCfDefinitions, navigateToFramework],
[api, mergeCfDefinitions, navigateToFramework, markServerVerified],
)

// If the URL points at a framework that isn't in the local cache yet (e.g. a hard refresh,
// or a deep link to a framework never opened on this device), fetch it from the server.
// If the URL points at a framework not yet confirmed against the server this session (e.g. a
// hard refresh or deep link), fetch it from the server — even when it's in the local cache,
// since the cached copy may be stale or may never have been saved at all.
// Falls back to home if it can't be loaded (deleted, no access, etc.).
useEffect(() => {
if (!activeFrameworkId) return
if (authStatus !== 'authenticated') return
if (frameworks.some((f) => f.id === activeFrameworkId)) return
if (serverVerifiedIds.has(activeFrameworkId)) return
let cancelled = false
openRemoteFramework(activeFrameworkId, { replace: true }).catch((err: unknown) => {
if (cancelled) return
console.warn('[App] Failed to restore framework from URL:', err)
navigateHome({ replace: true })
})
return () => { cancelled = true }
}, [activeFrameworkId, authStatus, frameworks, openRemoteFramework, navigateHome])
}, [activeFrameworkId, authStatus, serverVerifiedIds, openRemoteFramework, navigateHome])

// Load a framework from the server into the local session without navigating to it.
// Used by TreePanelView when the user selects a crosswalk target that isn't loaded locally yet.
Expand Down Expand Up @@ -520,10 +527,11 @@ function AppInner() {
removeFrameworkFromStorage(activeFrameworkId)
}, [api, tenantId, activeFrameworkId, caseApiVersion, removeFrameworkFromStorage])

// Handler to fetch the published CFPackage from the server (returns CASE JSON with absolute URIs)
// Handler to fetch the published CFPackage from the server (returns CASE JSON with absolute URIs).
// This is the user-facing view/export, so omit OpenCASE extensions (ext:opencase).
const handleFetchCfPackage = useCallback(async () => {
if (!activeFrameworkId) throw new Error('No active framework')
return api.getCfPackage({ docId: activeFrameworkId, caseVersion: caseApiVersion })
return api.getCfPackage({ docId: activeFrameworkId, caseVersion: caseApiVersion, includeExtensions: false })
}, [api, activeFrameworkId, caseApiVersion])

// Handler to save the CFPackage to the server
Expand Down Expand Up @@ -707,7 +715,6 @@ function AppInner() {

const homeScreen = (
<HomeScreen
onOpenFramework={openFramework}
onOpenRemoteFramework={openRemoteFramework}
onRemoveFromStorage={removeFrameworkFromStorage}
remoteOpenLoading={remoteOpenState === 'loading'}
Expand All @@ -720,7 +727,7 @@ function AppInner() {
return homeScreen
}

if (!activeFramework || !activeGraph) {
if (!activeFramework || !activeGraph || !serverVerifiedIds.has(activeFramework.id)) {
return homeScreen
}

Expand All @@ -740,11 +747,6 @@ function AppInner() {
<EditorCanvas
onBack={() => navigateHome()}
onSaveToServer={tenantId ? handleSaveToServer : undefined}
// Every framework that reaches `frameworks` was either loaded from the server
// or saved to it before being added locally (see saveNewFrameworkToServer,
// openRemoteFramework, handleLoadTargetFramework, handleSaveToServer) — so if
// there's an active framework at all, it's known-published.
isPublishedToOpenCase={Boolean(activeFrameworkId)}
onArchiveFramework={tenantId && activeFrameworkId ? handleArchiveFramework : undefined}
onFetchCfPackage={activeFrameworkId ? handleFetchCfPackage : undefined}
availableFrameworks={frameworks}
Expand Down
7 changes: 5 additions & 2 deletions apps/editor/src/infrastructure/caseApi/CaseApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,12 @@ export class CaseApiClient {
return []
}

async getCfPackage(params: { docId: string; caseVersion?: 'v1p0' | 'v1p1' }): Promise<CFPackage> {
async getCfPackage(params: { docId: string; caseVersion?: 'v1p0' | 'v1p1'; includeExtensions?: boolean }): Promise<CFPackage> {
const v = params.caseVersion ?? 'v1p1'
const res = (await this._http.get(`/ims/case/${v}/CFPackages/${encodeURIComponent(params.docId)}`)) as unknown
const res = (await this._http.get(
`/ims/case/${v}/CFPackages/${encodeURIComponent(params.docId)}`,
{ includeExtensions: params.includeExtensions },
)) as unknown
if (!res || typeof res !== 'object') throw new Error('Unexpected CFPackage response shape')

if ('CFDocument' in res) {
Expand Down
21 changes: 14 additions & 7 deletions apps/editor/src/infrastructure/caseApi/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export type RequestOptions = {
/**
* Send X-CASE-EDITOR so OpenCASE includes its proprietary `extensions` (ext:opencase) data.
* Defaults to true; pass false for spec-clean CASE JSON (e.g. user-facing export).
*/
includeExtensions?: boolean
}

export type HttpClient = {
get: (_url: string) => Promise<unknown>
get: (_url: string, _options?: RequestOptions) => Promise<unknown>
post: (_url: string, _body: unknown) => Promise<unknown>
put: (_url: string, _body: unknown) => Promise<unknown>
patch: (_url: string, _body: unknown) => Promise<unknown>
Expand Down Expand Up @@ -54,14 +62,13 @@ async function readBody(res: Response): Promise<unknown> {
}

export function createFetchHttpClient(baseUrl: string, options: FetchHttpClientOptions = {}): HttpClient {
const doRequest = async (method: string, url: string, body?: unknown): Promise<unknown> => {
const doRequest = async (method: string, url: string, body?: unknown, requestOptions: RequestOptions = {}): Promise<unknown> => {
const fullUrl = joinUrl(baseUrl, url)
const token = options.getAccessToken ? await options.getAccessToken() : null

const headers: Record<string, string> = {
// Required by OpenCASE to return OpenCASE extensions in responses
'X-CASE-EDITOR': 'true',
}
const headers: Record<string, string> = {}
// Required by OpenCASE to return OpenCASE extensions in responses
if (requestOptions.includeExtensions !== false) headers['X-CASE-EDITOR'] = 'true'
if (token) headers.Authorization = `Bearer ${token}`
if (body !== undefined) headers['Content-Type'] = 'application/json'

Expand All @@ -81,7 +88,7 @@ export function createFetchHttpClient(baseUrl: string, options: FetchHttpClientO
}

return {
get: (url) => doRequest('GET', url),
get: (url, requestOptions) => doRequest('GET', url, undefined, requestOptions),
post: (url, body) => doRequest('POST', url, body),
put: (url, body) => doRequest('PUT', url, body),
patch: (url, body) => doRequest('PATCH', url, body),
Expand Down
45 changes: 13 additions & 32 deletions apps/editor/src/ui/editor/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types'
import type { HomeFramework } from '@/ui/home/frameworkStore'
import { useAuth } from '@/app/providers/AuthProvider'
import { fromEditorGraph } from '@/ui/editor/reactflow/mapping/fromEditorGraph'
import { absolutizeCaseUris, frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage'
import { frameworkToCfPackage, toOpenCaseFormat } from '@/application/framework/mappers/case/toCasePackage'
import type { Framework } from '@/domain/framework/model/types'
import { hasFrameworkDataChanged } from '@/domain/framework/hasFrameworkDataChanged'

Expand Down Expand Up @@ -190,8 +190,6 @@ type MirrorStatus = { isModifiedFromSource?: boolean; sourcePackageURI?: string
type EditorCanvasProps = {
onBack?: () => void
onSaveToServer?: (cfPackage: ReturnType<typeof toOpenCaseFormat>, framework: Framework) => Promise<void>
/** Whether the current framework has been published to OpenCASE (loaded from or saved to server) */
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) */
Expand Down Expand Up @@ -244,7 +242,7 @@ function RemoteLinkInternalsSync({
return null
}

export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpenCase, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly<EditorCanvasProps>) {
export default function EditorCanvas({ onBack, onSaveToServer, onArchiveFramework, onFetchCfPackage, availableFrameworks, serverFrameworks, onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets, mirrorStatus }: Readonly<EditorCanvasProps>) {
const { status: authStatus, userName, tenantId, signOut, changePassword } = useAuth()
const {
nodes,
Expand Down Expand Up @@ -398,34 +396,19 @@ 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 }

// Open the CFPackage viewer. Fetches from the server when published (absolute URIs);
// falls back to local generation for unsaved/draft frameworks.
// Open the CFPackage viewer. Every framework open in the editor has been saved to the
// server, so the server copy (absolute URIs, no OpenCASE extensions) is the export.
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, remoteLinks: rl } = graphRef.current
const ctx = saveCtxRef.current
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))
setCfPackageDialogOpen(true)
if (!onFetchCfPackage) return
setCfPackageDialogOpen(true)
setViewCaseLoading(true)
try {
const pkg = await onFetchCfPackage()
setGeneratedCfPackage(pkg)
} finally {
setViewCaseLoading(false)
}
}, [isPublishedToOpenCase, onFetchCfPackage])
}, [onFetchCfPackage])

// Actually perform the save (network call). Split out from `handleSave` so
// the fork-warning dialog can defer this until the user confirms.
Expand Down Expand Up @@ -1780,7 +1763,6 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
availableFrameworks={availableFrameworks}
serverFrameworks={serverFrameworks}
onLoadTargetFramework={onLoadTargetFramework}
isSourcePublished={isPublishedToOpenCase}
onSaveAlignments={onSaveAlignments}
onLoadAlignmentsForTarget={onLoadAlignmentsForTarget}
onDiscoverAlignedTargets={onDiscoverAlignedTargets}
Expand Down Expand Up @@ -1822,7 +1804,6 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
onChangeNode={updateNodeData}
hideColorBand={activeView === 'tree'}
onViewCFPackage={handleViewCFPackage}
isPublishedToOpenCase={isPublishedToOpenCase}
availableLicenses={availableLicenses}
cfItemTypes={cfItemTypes}
ensureCfItemType={ensureCfItemType}
Expand Down
5 changes: 2 additions & 3 deletions apps/editor/src/ui/editor/components/NodePropertiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ type Props = {
onClose?: () => void
onChangeNode?: (_nodeId: string, _patch: CaseEditorNodeDataPatch) => void
onViewCFPackage?: () => void
isPublishedToOpenCase?: boolean
availableLicenses?: CFLicense[]
cfItemTypes?: CFItemType[]
ensureCfItemType?: (_title: string) => CFItemType | null
Expand All @@ -55,7 +54,7 @@ type Props = {
}

export default memo(function NodePropertiesPanel({
node, onClose, onChangeNode, onViewCFPackage, isPublishedToOpenCase, availableLicenses,
node, onClose, onChangeNode, onViewCFPackage, availableLicenses,
cfItemTypes = [], ensureCfItemType, cfSubjects = [], ensureCfSubject, cfConcepts = [], ensureCfConcept,
remoteLinks = [], onRemoveRemoteLink, onUpdateRemoteLinkType,
onRemoveRemoteFramework, onBrowseRemoteItems, onRefreshRemoteFramework, remoteFrameworkRefreshing,
Expand Down Expand Up @@ -723,7 +722,7 @@ export default memo(function NodePropertiesPanel({
) : null}

{/* ── OpenCASE URL (framework only) ── */}
{isFramework && isPublishedToOpenCase && opencaseUrl ? (
{isFramework && opencaseUrl ? (
<SidebarSection title="OpenCASE URL" subtitle="The CASE API endpoint for this framework." accentColor={accentColor} defaultOpen={false}>
<div className="flex items-stretch gap-2">
<a
Expand Down
17 changes: 3 additions & 14 deletions apps/editor/src/ui/editor/treePanel/TreePanelView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,6 @@ type Props = {
serverFrameworks?: Array<{ id: string; title: string }>
/** Load a framework from the server into the local session (called when a server-only target is selected) */
onLoadTargetFramework?: (id: string) => Promise<void>
/** Whether the source (left-panel) framework has been saved to the server. Alignment authoring requires stable server-assigned URIs. */
isSourcePublished?: boolean
/** Called with a serialized alignment CFPackage when the user saves pending associations. */
onSaveAlignments?: (cfPackage: unknown) => Promise<void>
/** Called when the user expands a target framework — returns any previously-saved alignment doc ID and associations for that pairing. */
Expand Down Expand Up @@ -174,7 +172,7 @@ function findNearestVisible(

// ── Component ─────────────────────────────────────────────────────────────

export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, isSourcePublished = false, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) {
export default function TreePanelView({ availableFrameworks = [], serverFrameworks = [], onLoadTargetFramework, onSaveAlignments, onLoadAlignmentsForTarget, onDiscoverAlignedTargets }: Props) {
const {
nodes,
cfItems,
Expand Down Expand Up @@ -890,7 +888,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor
onToggleExpand={handleLeftToggleExpand}
onSelect={handleSelect}
onAddChild={handleAddChild}
isDraggable={Boolean(expandedTargetFramework) && isSourcePublished}
isDraggable={Boolean(expandedTargetFramework)}
onDragStart={NOOP_DRAG_START}
associationCounts={leftAssociationCounts}
onBadgeClick={handleLeftBadgeClick}
Expand All @@ -906,15 +904,6 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor
)}
/>
</div>
{/* Gate callout — shown only when a target is expanded but source hasn't been saved */}
{expandedTargetFramework && !isSourcePublished && (
<div className="shrink-0 border-t border-amber-200 bg-amber-50 px-4 py-2.5">
<p className="text-xs text-amber-700">
<span className="font-semibold">Save this framework first</span> to enable alignment authoring.
Items need stable server-assigned URIs before associations can be created.
</p>
</div>
)}
</div>
</div>

Expand Down Expand Up @@ -1004,7 +993,7 @@ export default function TreePanelView({ availableFrameworks = [], serverFramewor
onBadgeClick={handleRightBadgeClick}
/>
</div>
{isSourcePublished && onSaveAlignments && target.hasUnsavedChanges && (
{onSaveAlignments && target.hasUnsavedChanges && (
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-black/10 bg-slate-50 px-4 py-2.5">
<span className="text-xs text-slate-600">
{targetAssocCount > 0
Expand Down
Loading