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
4 changes: 3 additions & 1 deletion calm-hub-ui/src/admin/panels/EntitlementsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,9 @@ describe('EntitlementsPanel', () => {
await waitFor(() =>
expect(screen.getByRole('region', { name: /global admin access/i })).toBeInTheDocument()
);
expect(userAccessSvc.getNamespaceUserAccess).toHaveBeenCalledWith('GLOBAL');
await waitFor(() =>
expect(userAccessSvc.getNamespaceUserAccess).toHaveBeenCalledWith('GLOBAL')
);
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel, breadcrumb
[nodeSearchTerm, nodeTypeFilter, nodeTypes]
);
const calmService = useMemo(() => new CalmService(), []);
const defaultLayoutState = useDefaultLayout(data.name, data.id, data.calmType);
const defaultLayoutState = useDefaultLayout(data.name, data.id, data.calmType, data.data as Record<string, unknown> | undefined);
// Destructured locals so handleSaveLayout/handleResetLayout below can depend
// on exactly the (already useCallback-stable) functions they call, rather
// than the whole result object β€” which still changes identity whenever
Expand Down Expand Up @@ -428,6 +428,26 @@ export function DiagramSection({ data, onItemSelect, hasDetailsPanel, breadcrumb
const showLayoutActions = !comparing && activeTab === 'diagram';
const layoutActions = showLayoutActions && (
<div className="flex items-center gap-1">
{defaultLayoutState.hasBothSources && (
<div className="join" role="group" aria-label="Layout source">
<button
type="button"
className={`join-item btn btn-xs ${defaultLayoutState.layoutSource === 'document' || (defaultLayoutState.layoutSource === 'auto' && defaultLayoutState.documentLayout) ? 'btn-active' : ''}`}
onClick={() => defaultLayoutState.setLayoutSource('document')}
title="Use layout from document"
>
Document
</button>
<button
type="button"
className={`join-item btn btn-xs ${defaultLayoutState.layoutSource === 'server' || (defaultLayoutState.layoutSource === 'auto' && !defaultLayoutState.documentLayout) ? 'btn-active' : ''}`}
onClick={() => defaultLayoutState.setLayoutSource('server')}
title="Use saved layout from server"
>
Saved
</button>
</div>
)}
{canSaveLayout && (
<button
type="button"
Expand Down
94 changes: 92 additions & 2 deletions calm-hub-ui/src/hub/hooks/useDefaultLayout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ describe('useDefaultLayout', () => {
5,
expect.objectContaining({
for: '/api/calm/namespaces/finos/architectures/5',
pins: [{ 'unique-id': 'node-a', position: { x: 1, y: 2 } }],
nodes: { 'node-a': { x: 1, y: 2 } },
}),
'architectures'
);
Expand Down Expand Up @@ -312,11 +312,101 @@ describe('useDefaultLayout', () => {
9,
expect.objectContaining({
for: '/api/calm/namespaces/finos/patterns/9',
pins: [{ 'unique-id': 'node-a', position: { x: 1, y: 2 } }],
nodes: { 'node-a': { x: 1, y: 2 } },
}),
'patterns'
);
expect(result.current.defaultLayout).toEqual(positions);
});
});

describe('document layout (_layout in metadata)', () => {
it('extracts _layout from architecture metadata and exposes it as documentLayout', () => {
layoutServiceMock.getDefaultLayout.mockResolvedValue(null);
const archData = {
metadata: {
_layout: {
'node-a': { x: 10, y: 20, w: 100, h: 50 },
'node-b': { x: 30, y: 40, w: 200, h: 80 },
},
},
};
const { result } = renderHook(() => useDefaultLayout(namespace, '5', 'Architectures', archData));

expect(result.current.documentLayout).toEqual([
{ id: 'node-a', position: { x: 10, y: 20 }, width: 100, height: 50 },
{ id: 'node-b', position: { x: 30, y: 40 }, width: 200, height: 80 },
]);
});

it('returns null documentLayout when metadata has no _layout', () => {
layoutServiceMock.getDefaultLayout.mockResolvedValue(null);
const archData = { metadata: { other: 'stuff' } };
const { result } = renderHook(() => useDefaultLayout(namespace, '5', 'Architectures', archData));

expect(result.current.documentLayout).toBeNull();
});

it('returns null documentLayout when no architectureData is passed', () => {
layoutServiceMock.getDefaultLayout.mockResolvedValue(null);
const { result } = renderHook(() => useDefaultLayout(namespace, '5', 'Architectures'));

expect(result.current.documentLayout).toBeNull();
});

it('uses document layout as defaultLayout when layoutSource is auto and _layout exists', async () => {
layoutServiceMock.getDefaultLayout.mockResolvedValue({
nodes: { 'node-a': { x: 999, y: 999 } },
});
const archData = {
metadata: {
_layout: { 'node-a': { x: 10, y: 20, w: 100, h: 50 } },
},
};
const { result } = renderHook(() => useDefaultLayout(namespace, '5', 'Architectures', archData));

// Auto mode prefers document layout when available
expect(result.current.defaultLayout).toEqual([
{ id: 'node-a', position: { x: 10, y: 20 }, width: 100, height: 50 },
]);
});

it('reports hasBothSources when both _layout and server layout exist', async () => {
layoutServiceMock.getDefaultLayout.mockResolvedValue({
nodes: { 'node-a': { x: 999, y: 999 } },
});
const archData = {
metadata: {
_layout: { 'node-a': { x: 10, y: 20 } },
},
};
const { result } = renderHook(() => useDefaultLayout(namespace, '5', 'Architectures', archData));

await waitFor(() => expect(result.current.hasBothSources).toBe(true));
});

it('switches to server layout when setLayoutSource is called with server', async () => {
layoutServiceMock.getDefaultLayout.mockResolvedValue({
nodes: { 'node-a': { x: 999, y: 888 } },
});
const archData = {
metadata: {
_layout: { 'node-a': { x: 10, y: 20 } },
},
};
const { result } = renderHook(() => useDefaultLayout(namespace, '5', 'Architectures', archData));

await waitFor(() => expect(result.current.hasBothSources).toBe(true));

act(() => {
result.current.setLayoutSource('server');
});

await waitFor(() =>
expect(result.current.defaultLayout).toEqual([
{ id: 'node-a', position: { x: 999, y: 888 } },
])
);
});
});
});
88 changes: 82 additions & 6 deletions calm-hub-ui/src/hub/hooks/useDefaultLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { CalmService } from '../../service/calm-service.js';
import { LayoutService, LayoutResourceType } from '../../service/layout-service.js';
import { isSlug } from '../../model/calm.js';
import { CalmLayout, pinsToStoredPositions, storedPositionsToPins } from '../../model/layout.js';
import { CalmLayout, apiResponseToStoredPositions, storedPositionsToLayoutMap, layoutMapToStoredPositions, type LayoutMap } from '../../model/layout.js';
import { buildViewportKey, clearStoredNodePositions, StoredNodePosition } from '../../visualizer/services/node-position-service.js';

export type LayoutSource = 'document' | 'server' | 'auto';

export interface UseDefaultLayoutResult {
/**
* Namespace + calmType + resolved numeric id β€” the single key both the
Expand Down Expand Up @@ -40,6 +42,30 @@ export interface UseDefaultLayoutResult {
saveError: string | null;
save: (positions: StoredNodePosition[]) => Promise<void>;
reset: () => void;
/** Layout extracted from the architecture document's metadata._layout, if present. */
documentLayout: StoredNodePosition[] | null;
/** Which layout source is currently active. */
layoutSource: LayoutSource;
/** Switch between document and server layout sources. */
setLayoutSource: (source: LayoutSource) => void;
/** Whether both document and server layouts are available (show the toggle). */
hasBothSources: boolean;
}

const LAYOUT_SOURCE_PREFIX = 'calm-hub:layout-source:';

function loadPreferredSource(viewportKey: string | null | undefined): LayoutSource | null {
if (!viewportKey) return null;
try {
return localStorage.getItem(`${LAYOUT_SOURCE_PREFIX}${viewportKey}`) as LayoutSource | null;
} catch { return null; }
}

function savePreferredSource(viewportKey: string | null | undefined, source: LayoutSource): void {
if (!viewportKey) return;
try {
localStorage.setItem(`${LAYOUT_SOURCE_PREFIX}${viewportKey}`, source);
} catch { /* ignore */ }
}

const SUPPORTED_TYPES: Partial<Record<string, LayoutResourceType>> = {
Expand All @@ -52,8 +78,12 @@ const SUPPORTED_TYPES: Partial<Record<string, LayoutResourceType>> = {
* pattern, and owns the save/reset actions. Returns an inert result (no
* fetch, no key) for anything else β€” dropped files and any other calmType
* are out of scope for server-side layouts.
*
* When `architectureData` includes `metadata._layout`, it is offered as a
* layout source alongside the server-side saved layout, with a user-selectable
* toggle when both are available.
*/
export function useDefaultLayout(namespace: string, id: string, calmType: string): UseDefaultLayoutResult {
export function useDefaultLayout(namespace: string, id: string, calmType: string, architectureData?: Record<string, unknown>): UseDefaultLayoutResult {
const calmService = useMemo(() => new CalmService(), []);
const layoutService = useMemo(() => new LayoutService(), []);
const urlType = SUPPORTED_TYPES[calmType];
Expand All @@ -75,6 +105,16 @@ export function useDefaultLayout(namespace: string, id: string, calmType: string
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);

// Extract layout from architecture document metadata._layout if present.
const documentLayout = useMemo<StoredNodePosition[] | null>(() => {
if (!isSupportedType || !architectureData) return null;
const metadata = architectureData.metadata;
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null;
const layoutData = (metadata as Record<string, unknown>)._layout;
if (!layoutData || typeof layoutData !== 'object' || Array.isArray(layoutData)) return null;
return layoutMapToStoredPositions(layoutData as LayoutMap);
}, [isSupportedType, architectureData]);

// Resolve a slug id to its numeric id once per (namespace, id, calmType).
useEffect(() => {
if (!isSupportedType) {
Expand Down Expand Up @@ -120,6 +160,22 @@ export function useDefaultLayout(namespace: string, id: string, calmType: string
? null
: undefined;

// Layout source selection β€” user can toggle between document and server layouts.
const [layoutSource, setLayoutSourceState] = useState<LayoutSource>('auto');

useEffect(() => {
const saved = loadPreferredSource(viewportKey);
if (saved) setLayoutSourceState(saved);
else setLayoutSourceState('auto');
}, [viewportKey]);

const setLayoutSource = useCallback((source: LayoutSource) => {
setLayoutSourceState(source);
savePreferredSource(viewportKey, source);
if (viewportKey) clearStoredNodePositions(viewportKey);
setLayoutEpoch((epoch) => epoch + 1);
}, [viewportKey]);

// Fetch the server default once the id is settled (resolved to a number,
// or resolution finished without a match).
useEffect(() => {
Expand All @@ -144,7 +200,7 @@ export function useDefaultLayout(namespace: string, id: string, calmType: string
.getDefaultLayout(namespace, resolvedId, urlType)
.then((layout) => {
if (cancelled) return;
setDefaultLayout(layout ? pinsToStoredPositions(layout) : null);
setDefaultLayout(layout ? apiResponseToStoredPositions(layout as unknown as Record<string, unknown>) : null);
})
.catch(() => {
if (!cancelled) setDefaultLayout(null);
Expand All @@ -162,11 +218,13 @@ export function useDefaultLayout(namespace: string, id: string, calmType: string
try {
const layout: CalmLayout = {
for: `/api/calm/namespaces/${namespace}/${urlType}/${resolvedId}`,
pins: storedPositionsToPins(positions),
nodes: storedPositionsToLayoutMap(positions),
};
await layoutService.saveDefaultLayout(namespace, resolvedId, layout, urlType);
if (viewportKey) clearStoredNodePositions(viewportKey);
setDefaultLayout(positions);
setLayoutSourceState('server');
savePreferredSource(viewportKey, 'server');
setLayoutEpoch((epoch) => epoch + 1);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save default layout');
Expand All @@ -186,20 +244,38 @@ export function useDefaultLayout(namespace: string, id: string, calmType: string

const canSave = resolvedId !== undefined;

// The server-side layout fetched from the API.
const serverLayout = defaultLayout;

const hasBothSources = documentLayout != null && serverLayout != null;

// Resolve which layout to expose as `defaultLayout` based on user selection.
const effectiveDefaultLayout = useMemo<StoredNodePosition[] | null | undefined>(() => {
if (layoutSource === 'document' && documentLayout) return documentLayout;
if (layoutSource === 'server') return serverLayout ?? null;
// 'auto': prefer document if it exists, else server
if (documentLayout) return documentLayout;
return serverLayout;
}, [layoutSource, documentLayout, serverLayout]);

// Memoised so consumers that key a useCallback/useMemo off the whole result
// (e.g. DiagramSection's handleSaveLayout) get a stable reference instead of
// a fresh object literal β€” and therefore a fresh dependency β€” every render.
return useMemo(
() => ({
viewportKey,
defaultLayout,
defaultLayout: effectiveDefaultLayout,
layoutEpoch,
canSave,
saving,
saveError,
save,
reset,
documentLayout,
layoutSource,
setLayoutSource,
hasBothSources,
}),
[viewportKey, defaultLayout, layoutEpoch, canSave, saving, saveError, save, reset]
[viewportKey, effectiveDefaultLayout, layoutEpoch, canSave, saving, saveError, save, reset, documentLayout, layoutSource, setLayoutSource, hasBothSources]
);
}
Loading
Loading