feat: replace static hardware fields with dynamic node properties
Replaces the 4 fixed hardware columns (cpu_count, cpu_model, ram_gb, disk_gb) with a flexible properties system. Each property has a key, value, icon (from a curated Lucide picker), and a visibility toggle that controls whether it appears on the canvas node card. - Backend: add `properties` JSON column to Node model; data migration converts existing hardware rows to properties with correct icons (idempotent, old columns kept for safety) - Backend: add `properties` to NodeBase, NodeUpdate, NodeSave schemas and canvasSerializer so values survive canvas save/load - Frontend: add NodeProperty type; new propertyIcons.ts registry (20 icons); BaseNode renders visible properties with legacy hardware fallback for unmigrated nodes - Frontend: DetailPanel gains interactive properties section (add / edit / remove / toggle visibility / icon picker) replacing the read-only hardware block; hardware section removed from NodeModal - Tests: 6 migration tests, 7 API tests, 8 DetailPanel property tests, 6 BaseNode render/fallback tests, 9 propertyIcons util tests
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { Cpu, HardDrive, MemoryStick } from 'lucide-react'
|
||||
import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '../propertyIcons'
|
||||
|
||||
describe('PROPERTY_ICONS', () => {
|
||||
it('contains the hardware migration icons', () => {
|
||||
expect(PROPERTY_ICONS['Cpu']).toBe(Cpu)
|
||||
expect(PROPERTY_ICONS['HardDrive']).toBe(HardDrive)
|
||||
expect(PROPERTY_ICONS['MemoryStick']).toBe(MemoryStick)
|
||||
})
|
||||
|
||||
it('has at least 10 icons', () => {
|
||||
expect(Object.keys(PROPERTY_ICONS).length).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('every value is a renderable component (function or object)', () => {
|
||||
for (const [, icon] of Object.entries(PROPERTY_ICONS)) {
|
||||
// Lucide icons can be functions or forwardRef objects depending on environment
|
||||
expect(icon).toBeTruthy()
|
||||
expect(['function', 'object']).toContain(typeof icon)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('PROPERTY_ICON_NAMES', () => {
|
||||
it('matches the keys of PROPERTY_ICONS', () => {
|
||||
expect(PROPERTY_ICON_NAMES).toEqual(expect.arrayContaining(Object.keys(PROPERTY_ICONS)))
|
||||
expect(PROPERTY_ICON_NAMES.length).toBe(Object.keys(PROPERTY_ICONS).length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePropertyIcon', () => {
|
||||
it('returns the icon for a known name', () => {
|
||||
expect(resolvePropertyIcon('Cpu')).toBe(Cpu)
|
||||
expect(resolvePropertyIcon('HardDrive')).toBe(HardDrive)
|
||||
})
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(resolvePropertyIcon(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for undefined input', () => {
|
||||
expect(resolvePropertyIcon(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for unknown icon name', () => {
|
||||
expect(resolvePropertyIcon('NotARealIcon')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(resolvePropertyIcon('')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -28,6 +28,7 @@ export interface ApiNode extends Record<string, unknown> {
|
||||
ram_gb?: number | null
|
||||
disk_gb?: number | null
|
||||
show_hardware?: boolean
|
||||
properties?: unknown[] | null
|
||||
width?: number | null
|
||||
height?: number | null
|
||||
bottom_handles?: number
|
||||
@@ -100,6 +101,7 @@ export function serializeNode(n: Node<NodeData>): Record<string, unknown> {
|
||||
ram_gb: n.data.ram_gb ?? null,
|
||||
disk_gb: n.data.disk_gb ?? null,
|
||||
show_hardware: n.data.show_hardware ?? false,
|
||||
properties: n.data.properties ?? [],
|
||||
width: n.width ?? null,
|
||||
height: n.height ?? null,
|
||||
bottom_handles: n.data.bottom_handles ?? 1,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export const PROPERTY_ICONS: Record<string, LucideIcon> = {
|
||||
Battery,
|
||||
Box,
|
||||
Clock,
|
||||
Cpu,
|
||||
Database,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Hash,
|
||||
Key,
|
||||
Layers,
|
||||
Link,
|
||||
MemoryStick,
|
||||
Monitor,
|
||||
Network,
|
||||
Server,
|
||||
Shield,
|
||||
Tag,
|
||||
Thermometer,
|
||||
Wifi,
|
||||
Zap,
|
||||
}
|
||||
|
||||
export const PROPERTY_ICON_NAMES = Object.keys(PROPERTY_ICONS) as (keyof typeof PROPERTY_ICONS)[]
|
||||
|
||||
export function resolvePropertyIcon(name: string | null | undefined): LucideIcon | null {
|
||||
if (!name) return null
|
||||
return PROPERTY_ICONS[name] ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user