fix: repair live view + settings in standalone multi-canvas mode
Two gaps surfaced after adding per-design localStorage storage: - Live view (/view) still read the legacy bare `homelable_canvas` key, which no longer exists once canvases are keyed per design — the read-only tab rendered empty. Now passes the active design id (?design=<id>) and reads that design's canvas, falling back to the first design. - The Settings modal was gated out entirely in standalone, leaving the Settings button dead even though canvas prefs (snap, hide-IP) work without a backend. Mount it in standalone; only the backend status-check section stays hidden. ha-relevant: no
This commit is contained in:
@@ -498,7 +498,10 @@ export default function App() {
|
||||
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||
const handleViewOnly = useCallback(async () => {
|
||||
if (STANDALONE) {
|
||||
window.open('/view', '_blank', 'noopener,noreferrer')
|
||||
// Standalone reads canvas from localStorage; pass the active design id so
|
||||
// the read-only tab renders the same canvas the user is viewing.
|
||||
const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view'
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -933,9 +936,9 @@ export default function App() {
|
||||
onCancel={() => setPendingContainerAdd(null)}
|
||||
/>
|
||||
|
||||
{!STANDALONE && (
|
||||
{/* Mounted in standalone too: status-check settings are hidden inside,
|
||||
but canvas prefs (snap, hide-IP) still apply. */}
|
||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||
)}
|
||||
|
||||
<PendingDevicesModal
|
||||
open={pendingModalOpen}
|
||||
|
||||
@@ -30,10 +30,10 @@ import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||
import { liveviewApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import type { NodeData, CustomStyleDef } from '@/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
const STORAGE_KEY = 'homelable_canvas'
|
||||
|
||||
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
||||
|
||||
@@ -55,14 +55,16 @@ function LiveViewCanvas() {
|
||||
|
||||
useEffect(() => {
|
||||
if (STANDALONE) {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
// ?design=<id> selects which canvas to render; fall back to the first
|
||||
// design when omitted. Standalone stores full React Flow nodes/edges, so
|
||||
// no API deserialization is needed.
|
||||
const designId = new URLSearchParams(window.location.search).get('design')
|
||||
?? standaloneStorage.listDesigns()[0]?.id
|
||||
const saved = designId ? standaloneStorage.loadCanvas(designId) : null
|
||||
if (saved) {
|
||||
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
|
||||
loadCanvas(savedNodes, savedEdges)
|
||||
}
|
||||
} catch {
|
||||
// empty canvas on parse error — show empty canvas
|
||||
if (saved.theme_id) setTheme(saved.theme_id)
|
||||
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||
loadCanvas(saved.nodes, saved.edges)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,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'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
|
||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||
|
||||
@@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => {
|
||||
|
||||
// ── Standalone mode ────────────────────────────────────────────────────────
|
||||
|
||||
// Captures props from the re-imported (resetModules) ReactFlow so standalone
|
||||
// tests can assert which nodes were rendered without reaching into the fresh
|
||||
// canvas-store module instance.
|
||||
let standaloneRfProps: Record<string, unknown> = {}
|
||||
const XYFLOW_MOCK = {
|
||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
ReactFlow: () => <div data-testid="react-flow" />,
|
||||
ReactFlow: (props: Record<string, unknown>) => { standaloneRfProps = props; return <div data-testid="react-flow" /> },
|
||||
Background: () => null,
|
||||
Controls: () => null,
|
||||
BackgroundVariant: { Dots: 'dots' },
|
||||
@@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('loads canvas from localStorage without calling the API', async () => {
|
||||
const stored = {
|
||||
it('loads the active design canvas from localStorage without calling the API', async () => {
|
||||
const design = standaloneStorage.createDesign('Main')
|
||||
standaloneStorage.saveCanvas(design.id, {
|
||||
nodes: [{
|
||||
id: 'ls-node', type: 'router',
|
||||
position: { x: 10, y: 20 },
|
||||
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
|
||||
}],
|
||||
edges: [],
|
||||
}
|
||||
localStorage.setItem('homelable_canvas', JSON.stringify(stored))
|
||||
})
|
||||
|
||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||
vi.resetModules()
|
||||
const mockLoad = vi.fn()
|
||||
vi.doMock('@xyflow/react', () => XYFLOW_MOCK)
|
||||
vi.doMock('@xyflow/react/dist/style.css', () => ({}))
|
||||
vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } }))
|
||||
const { default: LiveViewStandalone } = await import('../LiveView')
|
||||
|
||||
setSearch(`?design=${design.id}`)
|
||||
render(<LiveViewStandalone />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('ls-node')
|
||||
expect(mockLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the first design when no ?design= param is given', async () => {
|
||||
const design = standaloneStorage.createDesign('Only')
|
||||
standaloneStorage.saveCanvas(design.id, {
|
||||
nodes: [{
|
||||
id: 'fb-node', type: 'server',
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label: 'Srv', type: 'server', status: 'unknown', services: [] },
|
||||
}],
|
||||
edges: [],
|
||||
})
|
||||
|
||||
vi.stubEnv('VITE_STANDALONE', 'true')
|
||||
vi.resetModules()
|
||||
@@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||
})
|
||||
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node')
|
||||
expect(mockLoad).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user