Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f8635df1c5 | |||
| 1cc9b7c52f | |||
| fdf2b1f2be | |||
| 5630e7d202 | |||
| be705f0cb9 |
@@ -34,8 +34,10 @@ async def liveview_canvas(
|
|||||||
edges = (await db.execute(select(Edge))).scalars().all()
|
edges = (await db.execute(select(Edge))).scalars().all()
|
||||||
state = await db.get(CanvasState, 1)
|
state = await db.get(CanvasState, 1)
|
||||||
viewport: dict[str, Any] = state.viewport if state else {"x": 0, "y": 0, "zoom": 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(
|
return CanvasStateResponse(
|
||||||
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
nodes=[NodeResponse.model_validate(n) for n in nodes],
|
||||||
edges=[EdgeResponse.model_validate(e) for e in edges],
|
edges=[EdgeResponse.model_validate(e) for e in edges],
|
||||||
viewport=viewport,
|
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"
|
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 ─────────────────────────────────────────────────
|
# ── Re-disable after enabling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.2.0",
|
"@base-ui/react": "^1.2.0",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.0.3",
|
"version": "2.1.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { nodeTypes } from '@/components/canvas/nodes/nodeTypes'
|
|||||||
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { liveviewApi } from '@/api/client'
|
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 STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
const STORAGE_KEY = 'homelable_canvas'
|
||||||
@@ -40,6 +40,8 @@ function LiveViewCanvas() {
|
|||||||
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
const { nodes, edges, loadCanvas, fitViewPending, clearFitViewPending } = useCanvasStore()
|
||||||
const { fitView } = useReactFlow()
|
const { fitView } = useReactFlow()
|
||||||
const activeTheme = useThemeStore((s) => s.activeTheme)
|
const activeTheme = useThemeStore((s) => s.activeTheme)
|
||||||
|
const setTheme = useThemeStore((s) => s.setTheme)
|
||||||
|
const setCustomStyle = useThemeStore((s) => s.setCustomStyle)
|
||||||
const theme = THEMES[activeTheme]
|
const theme = THEMES[activeTheme]
|
||||||
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
// Derive initial view state synchronously (avoids calling setState inside an effect):
|
||||||
// - standalone → always ready (localStorage, no key required)
|
// - standalone → always ready (localStorage, no key required)
|
||||||
@@ -73,9 +75,12 @@ function LiveViewCanvas() {
|
|||||||
const { nodes: apiNodes, edges: apiEdges } = res.data
|
const { nodes: apiNodes, edges: apiEdges } = res.data
|
||||||
const proxmoxMap = new Map<string, boolean>(
|
const proxmoxMap = new Map<string, boolean>(
|
||||||
(apiNodes as ApiNode[])
|
(apiNodes as ApiNode[])
|
||||||
.filter((n: ApiNode) => n.type === 'proxmox' || n.type === 'group')
|
.filter((n: ApiNode) => n.type === 'group' || n.container_mode === true)
|
||||||
.map((n: ApiNode) => [n.id, n.type === 'group' ? true : n.container_mode !== false])
|
.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(
|
loadCanvas(
|
||||||
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
(apiNodes as ApiNode[]).map((n) => deserializeApiNode(n, proxmoxMap)),
|
||||||
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
(apiEdges as ApiEdge[]).map(deserializeApiEdge),
|
||||||
@@ -87,7 +92,7 @@ function LiveViewCanvas() {
|
|||||||
const detail: string = err.response.data?.detail ?? ''
|
const detail: string = err.response.data?.detail ?? ''
|
||||||
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
setViewState(detail === 'Live view is disabled' ? 'disabled' : 'invalid-key')
|
||||||
})
|
})
|
||||||
}, [loadCanvas])
|
}, [loadCanvas, setTheme, setCustomStyle])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!fitViewPending || nodes.length === 0) return
|
if (!fitViewPending || nodes.length === 0) return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -124,6 +125,55 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
expect(nodes.find((n) => n.id === 'n1')).toBeDefined()
|
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 ───────────────────────────────────────────────
|
// ── No editing props passed ───────────────────────────────────────────────
|
||||||
|
|
||||||
it('does not show any Access Denied when key is valid', async () => {
|
it('does not show any Access Denied when key is valid', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user