fix(liveview): nest docker children, apply theme & custom style
- Live view rebuilt parent map from proxmox/group only, so docker_container (and any vm/lxc with container_mode=true) lost parentId and rendered outside its host. Match App.tsx logic: include any node with container_mode=true. - Live view ignored viewport.theme_id and custom_style on load, so the shared canvas always rendered in default theme. Apply both like the authenticated canvas does. - Backend /liveview response now includes custom_style from CanvasState. Adds regression tests on both ends.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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