Merge pull request #155 from Pouzor/fix/liveview-parity
fix(liveview): nest docker children, apply theme & custom style
This commit is contained in:
@@ -34,8 +34,10 @@ async def liveview_canvas(
|
||||
edges = (await db.execute(select(Edge))).scalars().all()
|
||||
state = await db.get(CanvasState, 1)
|
||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 1}
|
||||
custom_style: dict[str, Any] | None = state.custom_style if state else None
|
||||
return CanvasStateResponse(
|
||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||
viewport=viewport,
|
||||
custom_style=custom_style,
|
||||
)
|
||||
|
||||
@@ -112,6 +112,28 @@ async def test_liveview_returns_saved_canvas(client: AsyncClient, auth_headers):
|
||||
assert nodes[0]["label"] == "Live Node"
|
||||
|
||||
|
||||
# ── custom_style + theme propagation ─────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_liveview_returns_custom_style_and_theme(client: AsyncClient, auth_headers):
|
||||
"""custom_style and viewport.theme_id from a saved canvas surface in liveview."""
|
||||
settings.liveview_key = "test-key"
|
||||
headers = await auth_headers()
|
||||
payload = {
|
||||
"nodes": [],
|
||||
"edges": [],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 1, "theme_id": "matrix"},
|
||||
"custom_style": {"fontFamily": "Inter", "nodeRadius": 12},
|
||||
}
|
||||
await client.post("/api/v1/canvas/save", json=payload, headers=headers)
|
||||
|
||||
res = await client.get("/api/v1/liveview?key=test-key")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["viewport"].get("theme_id") == "matrix"
|
||||
assert body["custom_style"] == {"fontFamily": "Inter", "nodeRadius": 12}
|
||||
|
||||
|
||||
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { liveviewApi } from '@/api/client'
|
||||
import type { NodeData } from '@/types'
|
||||
import type { NodeData, CustomStyleDef } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STORAGE_KEY = 'homelable_canvas'
|
||||
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
|
||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||
const { fitView } = useReactFlow()
|
||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||
const setTheme = useThemeStore((s) => s.setTheme)
|
||||
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||
const theme = THEMES[activeTheme]
|
||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||
// - standalone → always ready (localStorage, no key required)
|
||||
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
|
||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||
const proxmoxMap = new Map<string, boolean>(
|
||||
(apiNodes as ApiNode[])
|
||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
||||
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||
.map((n: ApiNode) => [n.id, true])
|
||||
)
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
if (savedTheme) setTheme(savedTheme)
|
||||
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
|
||||
loadCanvas(
|
||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
|
||||
const detail: string = err.response.data?.detail ?? ''
|
||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||
})
|
||||
}, [loadCanvas])
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitViewPending || nodes.length === 0) return
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { useCanvasStore } from '@/stores/canvasStore'
|
||||
import { useThemeStore } from '@/stores/themeStore'
|
||||
|
||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||
|
||||
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
||||
})
|
||||
|
||||
// ── Nested children (docker_container inside docker_host) ────────────────
|
||||
|
||||
it('nests docker_container under docker_host parent (container_mode=true)', async () => {
|
||||
setSearch('?key=valid')
|
||||
const nestedPayload = {
|
||||
data: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'host', type: 'docker', label: 'Docker Host', status: 'online',
|
||||
services: [], pos_x: 0, pos_y: 0, container_mode: true,
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ctr', type: 'docker_container', label: 'nginx', status: 'online',
|
||||
services: [], pos_x: 20, pos_y: 30, parent_id: 'host',
|
||||
created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(nestedPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
const ctr = useCanvasStore.getState().nodes.find((n) => n.id === 'ctr')
|
||||
expect(ctr?.parentId).toBe('host')
|
||||
expect(ctr?.extent).toBe('parent')
|
||||
})
|
||||
|
||||
// ── Theme + custom_style applied from payload ────────────────────────────
|
||||
|
||||
it('applies viewport.theme_id and custom_style from the payload', async () => {
|
||||
setSearch('?key=valid')
|
||||
const styledPayload = {
|
||||
data: {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1, theme_id: 'matrix' },
|
||||
custom_style: { fontFamily: 'Inter', nodeRadius: 12 },
|
||||
},
|
||||
}
|
||||
vi.mocked(liveviewApi.load).mockResolvedValue(styledPayload as never)
|
||||
render(<LiveView />)
|
||||
await waitFor(() => expect(screen.getByTestId('react-flow')).toBeDefined())
|
||||
expect(useThemeStore.getState().activeTheme).toBe('matrix')
|
||||
expect(useThemeStore.getState().customStyle).toEqual({ fontFamily: 'Inter', nodeRadius: 12 })
|
||||
})
|
||||
|
||||
// ── No editing props passed ───────────────────────────────────────────────
|
||||
|
||||
it('does not show any Access Denied when key is valid', async () => {
|
||||
|
||||
Reference in New Issue
Block a user