feat: support multi-canvas (designs) in frontend-only standalone mode
Standalone mode (VITE_STANDALONE=true) skipped the designs system entirely: the design list stayed empty (switcher hidden) and all canvases collapsed onto a single localStorage key. Add a localStorage-backed design layer mirroring the backend designs API. - standaloneStorage util: list/create/update/delete designs + per-design canvas storage (homelable_designs + homelable_canvas:<id>). ensureSeed migrates a legacy single-canvas install into a default design so existing data survives. - App.tsx: load/seed designs, save + switch canvases per design id in standalone. - Sidebar.tsx: create/update/delete dispatch to standaloneStorage when standalone. - Tests for the new storage util. ha-relevant: no
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Standalone multi-canvas (designs) persistence tests.
|
||||
*
|
||||
* Verifies the localStorage-backed design list + per-design canvas storage used
|
||||
* when VITE_STANDALONE=true, including migration of a legacy single-canvas
|
||||
* install into a default design.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
import * as ss from '@/utils/standaloneStorage'
|
||||
|
||||
const DESIGNS_KEY = 'homelable_designs'
|
||||
const LEGACY_CANVAS_KEY = 'homelable_canvas'
|
||||
const canvasKey = (id: string) => `${LEGACY_CANVAS_KEY}:${id}`
|
||||
|
||||
function node(id: string): Node<NodeData> {
|
||||
return { id, type: 'server', position: { x: 0, y: 0 }, data: { label: id, type: 'server', status: 'unknown', services: [] } }
|
||||
}
|
||||
const noEdges: Edge<EdgeData>[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('standaloneStorage designs', () => {
|
||||
it('listDesigns returns empty before any seed', () => {
|
||||
expect(ss.listDesigns()).toEqual([])
|
||||
})
|
||||
|
||||
it('ensureSeed creates a default design once and is idempotent', () => {
|
||||
const first = ss.ensureSeed()
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0].name).toBe('My Homelab')
|
||||
expect(first[0].design_type).toBe('network')
|
||||
|
||||
const second = ss.ensureSeed()
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0].id).toBe(first[0].id) // same design, not recreated
|
||||
})
|
||||
|
||||
it('ensureSeed migrates legacy single-canvas data into the default design', () => {
|
||||
const legacy = { nodes: [node('a'), node('b')], edges: noEdges, theme_id: 'dark', custom_style: null }
|
||||
localStorage.setItem(LEGACY_CANVAS_KEY, JSON.stringify(legacy))
|
||||
|
||||
const [design] = ss.ensureSeed()
|
||||
const migrated = ss.loadCanvas(design.id)
|
||||
expect(migrated?.nodes).toHaveLength(2)
|
||||
expect(migrated?.theme_id).toBe('dark')
|
||||
// Legacy bare key is consumed so it can't shadow per-design data later.
|
||||
expect(localStorage.getItem(LEGACY_CANVAS_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('createDesign appends and persists a new design', () => {
|
||||
ss.ensureSeed()
|
||||
const created = ss.createDesign('Garage', 'network')
|
||||
expect(ss.listDesigns().map((d) => d.id)).toContain(created.id)
|
||||
expect(ss.listDesigns()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('saveCanvas / loadCanvas round-trips per design without cross-talk', () => {
|
||||
const a = ss.createDesign('A')
|
||||
const b = ss.createDesign('B')
|
||||
ss.saveCanvas(a.id, { nodes: [node('a1')], edges: noEdges, theme_id: 'default' })
|
||||
ss.saveCanvas(b.id, { nodes: [node('b1'), node('b2')], edges: noEdges, theme_id: 'default' })
|
||||
|
||||
expect(ss.loadCanvas(a.id)?.nodes).toHaveLength(1)
|
||||
expect(ss.loadCanvas(b.id)?.nodes).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('loadCanvas returns null for an unsaved design', () => {
|
||||
const d = ss.createDesign('Empty')
|
||||
expect(ss.loadCanvas(d.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('updateDesign patches name/icon and bumps updated_at', () => {
|
||||
const d = ss.createDesign('Old')
|
||||
const updated = ss.updateDesign(d.id, { name: 'New', icon: 'router' })
|
||||
expect(updated?.name).toBe('New')
|
||||
expect(updated?.icon).toBe('router')
|
||||
expect(ss.listDesigns()[0].name).toBe('New')
|
||||
})
|
||||
|
||||
it('updateDesign returns null for an unknown id', () => {
|
||||
expect(ss.updateDesign('nope', { name: 'x' })).toBeNull()
|
||||
})
|
||||
|
||||
it('deleteDesign removes the design and its canvas data', () => {
|
||||
const a = ss.createDesign('A')
|
||||
const b = ss.createDesign('B')
|
||||
ss.saveCanvas(a.id, { nodes: [node('a1')], edges: noEdges })
|
||||
|
||||
ss.deleteDesign(a.id)
|
||||
expect(ss.listDesigns().map((d) => d.id)).toEqual([b.id])
|
||||
expect(localStorage.getItem(canvasKey(a.id))).toBeNull()
|
||||
})
|
||||
|
||||
it('tolerates corrupt JSON in the designs key', () => {
|
||||
localStorage.setItem(DESIGNS_KEY, '{not valid')
|
||||
expect(ss.listDesigns()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Standalone-mode persistence (VITE_STANDALONE=true).
|
||||
*
|
||||
* No backend is available, so designs (multi-canvas) and their canvas data are
|
||||
* persisted directly to localStorage:
|
||||
* - `homelable_designs` → Design[] (the canvas list)
|
||||
* - `homelable_canvas:<id>` → { nodes, edges, theme_id, custom_style } per design
|
||||
*
|
||||
* Legacy single-canvas installs stored everything under `homelable_canvas`
|
||||
* (no per-design key, no design list). `ensureSeed()` migrates that data into a
|
||||
* default design on first run so existing users keep their canvas.
|
||||
*/
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
import { generateUUID } from '@/utils/uuid'
|
||||
|
||||
const DESIGNS_KEY = 'homelable_designs'
|
||||
const LEGACY_CANVAS_KEY = 'homelable_canvas'
|
||||
const canvasKey = (designId: string) => `${LEGACY_CANVAS_KEY}:${designId}`
|
||||
|
||||
export interface StandaloneCanvas {
|
||||
nodes: Node<NodeData>[]
|
||||
edges: Edge<EdgeData>[]
|
||||
theme_id?: string
|
||||
custom_style?: CustomStyleDef | null
|
||||
}
|
||||
|
||||
function readJSON<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? (JSON.parse(raw) as T) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString()
|
||||
}
|
||||
|
||||
/** Read the design list. Returns [] when none have been created yet. */
|
||||
export function listDesigns(): Design[] {
|
||||
return readJSON<Design[]>(DESIGNS_KEY) ?? []
|
||||
}
|
||||
|
||||
function writeDesigns(designs: Design[]): void {
|
||||
localStorage.setItem(DESIGNS_KEY, JSON.stringify(designs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarantee at least one design exists and return the full list.
|
||||
* Migrates a legacy single-canvas install into a default design on first run.
|
||||
*/
|
||||
export function ensureSeed(): Design[] {
|
||||
const existing = listDesigns()
|
||||
if (existing.length > 0) return existing
|
||||
|
||||
const design: Design = {
|
||||
id: generateUUID(),
|
||||
name: 'My Homelab',
|
||||
design_type: 'network',
|
||||
icon: null,
|
||||
created_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
}
|
||||
writeDesigns([design])
|
||||
|
||||
// Migrate legacy canvas data (stored under the bare key) into this design.
|
||||
const legacy = readJSON<StandaloneCanvas>(LEGACY_CANVAS_KEY)
|
||||
if (legacy && localStorage.getItem(canvasKey(design.id)) === null) {
|
||||
localStorage.setItem(canvasKey(design.id), JSON.stringify(legacy))
|
||||
localStorage.removeItem(LEGACY_CANVAS_KEY)
|
||||
}
|
||||
return [design]
|
||||
}
|
||||
|
||||
export function createDesign(name: string, icon?: string | null, design_type: DesignType = 'network'): Design {
|
||||
const design: Design = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
design_type,
|
||||
icon: icon ?? null,
|
||||
created_at: nowIso(),
|
||||
updated_at: nowIso(),
|
||||
}
|
||||
writeDesigns([...listDesigns(), design])
|
||||
return design
|
||||
}
|
||||
|
||||
export function updateDesign(id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>): Design | null {
|
||||
const designs = listDesigns()
|
||||
const idx = designs.findIndex((d) => d.id === id)
|
||||
if (idx === -1) return null
|
||||
const updated: Design = { ...designs[idx], ...patch, updated_at: nowIso() }
|
||||
designs[idx] = updated
|
||||
writeDesigns(designs)
|
||||
return updated
|
||||
}
|
||||
|
||||
export function deleteDesign(id: string): void {
|
||||
writeDesigns(listDesigns().filter((d) => d.id !== id))
|
||||
localStorage.removeItem(canvasKey(id))
|
||||
}
|
||||
|
||||
/** Load a design's canvas. Returns null when the design has never been saved. */
|
||||
export function loadCanvas(designId: string): StandaloneCanvas | null {
|
||||
return readJSON<StandaloneCanvas>(canvasKey(designId))
|
||||
}
|
||||
|
||||
export function saveCanvas(designId: string, data: StandaloneCanvas): void {
|
||||
localStorage.setItem(canvasKey(designId), JSON.stringify(data))
|
||||
}
|
||||
Reference in New Issue
Block a user