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
53 changes: 50 additions & 3 deletions apps/editor/src/ui/editor/EditorCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import FloatingAddButton from '@/ui/editor/components/FloatingAddButton'
import AddExternalFrameworkDialog from '@/ui/editor/components/AddExternalFrameworkDialog'
import ViewCFPackageDialog from '@/ui/editor/components/ViewCFPackageDialog'
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'
import type { CFDocument, CFItem, CFPackage } from '@/domain/case/types'
import { useAuth } from '@/app/providers/AuthProvider'
Expand Down Expand Up @@ -85,6 +86,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
const reactFlowRef = useRef<ReactFlowInstance<CaseEditorNodeType> | null>(null)
const [rfReady, setRfReady] = useState(false)
const didInitialViewportRef = useRef(false)
const prevLayoutVersionRef = useRef<number | null>(null)
const [leaveOpen, setLeaveOpen] = useState(false)
const [settingsOpen, setSettingsOpen] = useState(false)
const [externalFwDialogOpen, setExternalFwDialogOpen] = useState(false)
Expand Down Expand Up @@ -1015,6 +1017,44 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
[ensureNodeVisible, logSelectionDebug, selectedNodeIds.length, selectedEdgeIds.length],
)

// Center on the framework's root node, at a fixed, comfortable zoom —
// regardless of framework size — so the user lands oriented on the root
// instead of a fit-to-everything view that can shrink the root to a speck
// in a large framework. Runs on the very first paint AND whenever the user
// switches layout mode (Hierarchy/Star "views"), since a layout switch
// moves every node and should re-orient the same way a fresh load does.
const centerOnRoot = useCallback(() => {
const instance = reactFlowRef.current
const wrap = reactFlowWrapRef.current
if (!instance || !wrap) return

const DEFAULT_ROOT_ZOOM = 1
const animate = didInitialViewportRef.current
const duration = animate ? 200 : 0

const center = () => {
const instance2 = reactFlowRef.current
if (!instance2) return
const root = instance2.getNodes().find(isFrameworkNode)
if (!root) return
const { w, h } = getNodeSize(root)
instance2.setCenter(root.position.x + w / 2, root.position.y + h / 2, {
zoom: DEFAULT_ROOT_ZOOM,
duration,
})
didInitialViewportRef.current = true
}

// Two rAFs to let React Flow apply any pending node measurements/positions.
const id = globalThis.requestAnimationFrame(() => center())
const id2 = globalThis.requestAnimationFrame(() => center())

return () => {
globalThis.cancelAnimationFrame(id)
globalThis.cancelAnimationFrame(id2)
}
}, [])

const fitToContents = useCallback(() => {
const instance = reactFlowRef.current
const wrap = reactFlowWrapRef.current
Expand Down Expand Up @@ -1103,10 +1143,17 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
}
}, [])

// Make the initial viewport leave room for the floating header so the top-most node isn't hidden behind it.
// First paint, and any explicit layout-mode switch (Hierarchy/Star "reset"),
// center on the root node. A layout switch moves every node, so it should
// re-orient the same way a fresh load does. Node-count-only changes (items
// added/removed without a layout switch) instead fall back to fitting the
// whole graph, leaving room for the floating header so the top-most node
// isn't hidden behind it.
useEffect(() => {
if (!rfReady) return
const cleanup = fitToContents()
const layoutChanged = prevLayoutVersionRef.current === null || prevLayoutVersionRef.current !== layoutVersion
prevLayoutVersionRef.current = layoutVersion
const cleanup = layoutChanged ? centerOnRoot() : fitToContents()
const onResize = () => {
fitToContents()
}
Expand All @@ -1115,7 +1162,7 @@ export default function EditorCanvas({ onBack, onSaveToServer, isPublishedToOpen
cleanup?.()
globalThis.removeEventListener('resize', onResize)
}
}, [rfReady, nodesWithCallbacks.length, layoutVersion, fitToContents])
}, [rfReady, nodesWithCallbacks.length, layoutVersion, fitToContents, centerOnRoot])

return (
<div className="relative h-screen w-screen">
Expand Down
77 changes: 18 additions & 59 deletions apps/opencase/src/domain/case/entities/CFAssociation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,45 +45,43 @@ export class CFAssociation {
uri = this.generateURI(tenantId, caseVersion, identifier)
}

// Transform originNodeURI - extract identifier from URN if present
// originNodeURI/destinationNodeURI always reference a CFItem (or CFDocument)
// within the SAME package being imported, so — like CFItem.CFDocumentURI —
// their uri is always regenerated to point at the local host, regardless of
// what URI shape (URN, absolute foreign-host URL, or relative path) the
// source supplied. Only the identifier is trusted from the source data.
let originId = raw.originNodeURI?.identifier ?? raw.originNode ?? 'unknown'
let originUri = raw.originNodeURI?.uri
if (originUri && UrnCaseUriHelper.isUrnCaseUri(originUri)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUri)
const originUriFromSource = raw.originNodeURI?.uri
if (originUriFromSource && UrnCaseUriHelper.isUrnCaseUri(originUriFromSource)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(originUriFromSource)
if (parsed) {
originId = parsed.identifier || originId
originUri = UrnCaseUriHelper.urnCaseToRelativePath(originUri, caseVersion)
}
} else {
originUri = originUri || this.generateItemURI(tenantId, caseVersion, originId)
}
const originNodeURI = {
title: raw.originNodeURI?.title ?? String(originId),
identifier: originId,
uri: originUri
uri: this.generateItemURI(tenantId, caseVersion, originId)
}

// Transform destinationNodeURI - extract identifier from URN if present

let destinationId = raw.destinationNodeURI?.identifier ?? raw.destinationNode ?? 'unknown'
let destinationUri = raw.destinationNodeURI?.uri
if (destinationUri && UrnCaseUriHelper.isUrnCaseUri(destinationUri)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUri)
const destinationUriFromSource = raw.destinationNodeURI?.uri
if (destinationUriFromSource && UrnCaseUriHelper.isUrnCaseUri(destinationUriFromSource)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(destinationUriFromSource)
if (parsed) {
destinationId = parsed.identifier || destinationId
destinationUri = UrnCaseUriHelper.urnCaseToRelativePath(destinationUri, caseVersion)
}
} else {
destinationUri = destinationUri || this.generateItemURI(tenantId, caseVersion, destinationId)
}
const destinationNodeURI = {
title: raw.destinationNodeURI?.title ?? String(destinationId),
identifier: destinationId,
uri: destinationUri
uri: this.generateItemURI(tenantId, caseVersion, destinationId)
}

// Transform CFAssociationGroupingURI if present

// CFAssociationGroupingURI references a per-tenant definition entity that
// OpenCASE serves itself, so it's rebased onto the local host too.
// CFAssociationGroupingURI must use LinkURI format (UUID identifier required)
const CFAssociationGroupingURI = this.transformLinkData(raw.CFAssociationGroupingURI, caseVersion)
const CFAssociationGroupingURI = LinkDataHelper.rebaseLinkData(raw.CFAssociationGroupingURI, caseVersion, 'CFAssociationGroupings')
if (CFAssociationGroupingURI) {
LinkDataHelper.validateLinkURI(CFAssociationGroupingURI, 'CFAssociationGroupingURI')
}
Expand All @@ -104,45 +102,6 @@ export class CFAssociation {
});
}

/**
* Transforms a LinkData object's URI if it's a URN, otherwise returns it unchanged
*/
private static transformLinkData(linkData: any, caseVersion: CaseVersion): LinkData | undefined {
if (!linkData) return undefined

// If it's already a LinkData object with a URI
if (typeof linkData === 'object' && linkData.uri) {
const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData.uri, caseVersion)
// If URI was a URN, also extract identifier from it
let identifier = linkData.identifier
if (linkData.uri && UrnCaseUriHelper.isUrnCaseUri(linkData.uri)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData.uri)
if (parsed) {
identifier = parsed.identifier || identifier
}
}
return {
...linkData,
uri: transformedUri || linkData.uri,
identifier: identifier || linkData.identifier
}
}

// If it's a string URI, transform it
if (typeof linkData === 'string') {
const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData, caseVersion)
const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData)
const identifier = parsed?.identifier || LinkDataHelper.extractIdFromURI(linkData)
return {
title: identifier || linkData,
identifier: identifier || linkData,
uri: transformedUri || linkData
}
}

return linkData
}

private static generateURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string {
const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0';
return `${basePath}/CFAssociations/${identifier}`;
Expand Down
49 changes: 6 additions & 43 deletions apps/opencase/src/domain/case/entities/CFDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,15 @@ export class CFDocument {
uri = this.generateURI(tenantId, caseVersion, identifier)
}

// Transform LinkData URIs if they are URNs
const licenseURI = this.transformLinkData(raw.licenseURI, caseVersion)
const CFPackageURI = this.transformLinkData(raw.CFPackageURI, caseVersion)
// Rebase reference URIs onto the local host — these point at per-tenant
// definition entities (licenses, packages, subjects) that OpenCASE serves
// itself, so they must resolve locally rather than to the source host.
const licenseURI = LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses')
const CFPackageURI = LinkDataHelper.rebaseLinkData(raw.CFPackageURI, caseVersion, 'CFPackages')
// subjectURI must use LinkURI format (UUID identifier required)
const subjectURI = Array.isArray(raw.subjectURI)
? raw.subjectURI.map((s: any) => {
const transformed = this.transformLinkData(s, caseVersion)
const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects')
if (transformed) {
LinkDataHelper.validateLinkURI(transformed, 'CFDocument.subjectURI')
}
Expand Down Expand Up @@ -97,45 +99,6 @@ export class CFDocument {
});
}

/**
* Transforms a LinkData object's URI if it's a URN, otherwise returns it unchanged
*/
private static transformLinkData(linkData: any, caseVersion: CaseVersion): LinkData | undefined {
if (!linkData) return undefined

// If it's already a LinkData object with a URI
if (typeof linkData === 'object' && linkData.uri) {
const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData.uri, caseVersion)
// If URI was a URN, also extract identifier from it
let identifier = linkData.identifier
if (linkData.uri && UrnCaseUriHelper.isUrnCaseUri(linkData.uri)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData.uri)
if (parsed) {
identifier = parsed.identifier || identifier
}
}
return {
...linkData,
uri: transformedUri || linkData.uri,
identifier: identifier || linkData.identifier
}
}

// If it's a string URI, transform it
if (typeof linkData === 'string') {
const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData, caseVersion)
const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData)
const identifier = parsed?.identifier || LinkDataHelper.extractIdFromURI(linkData)
return {
title: identifier || linkData,
identifier: identifier || linkData,
uri: transformedUri || linkData
}
}

return linkData
}

private static generateURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string {
// Generate a URI based on tenant, version, and identifier
const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0';
Expand Down
52 changes: 8 additions & 44 deletions apps/opencase/src/domain/case/entities/CFItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,17 @@ export class CFItem {
uri: generatedDocUri
}

// Transform LinkData URIs if they are URNs
const CFItemTypeURI = this.transformLinkData(raw.CFItemTypeURI, caseVersion)
const conceptKeywordsURI = this.transformLinkData(raw.conceptKeywordsURI, caseVersion)
const licenseURI = this.transformLinkData(raw.licenseURI, caseVersion)
// Rebase reference URIs onto the local host — these point at per-tenant
// definition entities (item types, concepts, licenses, subjects) that
// OpenCASE serves itself, so they must resolve locally rather than to
// the source host.
const CFItemTypeURI = LinkDataHelper.rebaseLinkData(raw.CFItemTypeURI, caseVersion, 'CFItemTypes')
const conceptKeywordsURI = LinkDataHelper.rebaseLinkData(raw.conceptKeywordsURI, caseVersion, 'CFConcepts')
const licenseURI = LinkDataHelper.rebaseLinkData(raw.licenseURI, caseVersion, 'CFLicenses')
// subjectURI must use LinkURI format (UUID identifier required)
const subjectURI = Array.isArray(raw.subjectURI)
? raw.subjectURI.map((s: any) => {
const transformed = this.transformLinkData(s, caseVersion)
const transformed = LinkDataHelper.rebaseLinkData(s, caseVersion, 'CFSubjects')
if (transformed) {
LinkDataHelper.validateLinkURI(transformed, 'CFItem.subjectURI')
}
Expand Down Expand Up @@ -124,45 +127,6 @@ export class CFItem {
});
}

/**
* Transforms a LinkData object's URI if it's a URN, otherwise returns it unchanged
*/
private static transformLinkData(linkData: any, caseVersion: CaseVersion): LinkData | undefined {
if (!linkData) return undefined

// If it's already a LinkData object with a URI
if (typeof linkData === 'object' && linkData.uri) {
const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData.uri, caseVersion)
// If URI was a URN, also extract identifier from it
let identifier = linkData.identifier
if (linkData.uri && UrnCaseUriHelper.isUrnCaseUri(linkData.uri)) {
const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData.uri)
if (parsed) {
identifier = parsed.identifier || identifier
}
}
return {
...linkData,
uri: transformedUri || linkData.uri,
identifier: identifier || linkData.identifier
}
}

// If it's a string URI, transform it
if (typeof linkData === 'string') {
const transformedUri = UrnCaseUriHelper.transformUrnIfPresent(linkData, caseVersion)
const parsed = UrnCaseUriHelper.parseUrnCaseUri(linkData)
const identifier = parsed?.identifier || LinkDataHelper.extractIdFromURI(linkData)
return {
title: identifier || linkData,
identifier: identifier || linkData,
uri: transformedUri || linkData
}
}

return linkData
}

private static generateURI(tenantId: TenantId, caseVersion: CaseVersion, identifier: string): string {
const basePath = caseVersion === '1.1' ? '/ims/case/v1p1' : '/ims/case/v1p0';
return `${basePath}/CFItems/${identifier}`;
Expand Down
6 changes: 4 additions & 2 deletions apps/opencase/src/domain/case/entities/CFRubric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ export class CFRubric {
identifier = parsed.identifier || identifier
uri = UrnCaseUriHelper.urnCaseToRelativePath(uri, caseVersion)
}
} else if (!uri && identifier) {
// Generate URI based on identifier if not provided
} else if (identifier) {
// Not a URN — always regenerate based on identifier, same as
// CFDocument/CFItem/CFAssociation, so a source-supplied absolute URL
// doesn't leave the rubric's own uri pointing at the source host.
uri = this.generateURI(tenantId, caseVersion, identifier)
}

Expand Down
Loading
Loading