devs
This commit is contained in:
42
README.md
42
README.md
@@ -0,0 +1,42 @@
|
||||
# ZUI
|
||||
|
||||
A node-based editor for configuration-driven content. Connect **Config** nodes (PlantUML, Markdown, or Wireframe) to **Render** nodes to see live output. Use **Variable** and **Function** nodes to feed data and custom logic into configs via Nunjucks templating.
|
||||
|
||||
## Run the app
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open the URL shown in the terminal (e.g. http://localhost:5173).
|
||||
|
||||
## Adding nodes
|
||||
|
||||
- **Right-click** on the canvas to open the context menu.
|
||||
- Choose **Create Node** → **Config**, **Render**, **Variable**, or **Function**.
|
||||
- Connect nodes by dragging from an output handle to an input handle.
|
||||
|
||||
## Saving and loading
|
||||
|
||||
- **Project → Export…** saves the current graph as a `.zui.json` file.
|
||||
- **Project → Import…** loads a previously saved project.
|
||||
|
||||
Projects are stored as JSON with a `version` field, plus `nodes` and `edges` arrays.
|
||||
|
||||
## Tech
|
||||
|
||||
- [React Flow](https://reactflow.dev/) for the graph canvas
|
||||
- [Nunjucks](https://mozilla.github.io/nunjucks/) for templating in configs
|
||||
- PlantUML diagrams via [Kroki](https://kroki.io/) (proxied in dev)
|
||||
- [Wireweave](https://github.com/wireweave/core) for wireframe UI → SVG
|
||||
|
||||
## Scripts
|
||||
|
||||
| Command | Description |
|
||||
|----------------|--------------------------|
|
||||
| `npm run dev` | Start dev server |
|
||||
| `npm run build`| Production build |
|
||||
| `npm run preview` | Preview production build |
|
||||
| `npm run test` | Run tests in watch mode |
|
||||
| `npm run test:run` | Run tests once |
|
||||
|
||||
2449
package-lock.json
generated
2449
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,9 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
@@ -32,10 +34,13 @@
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"jsdom": "^25.0.0",
|
||||
"vitest": "^2.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"shadcn": "^4.0.0",
|
||||
|
||||
33
src/fixtures/sample.zui.json
Normal file
33
src/fixtures/sample.zui.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "var_001",
|
||||
"type": "variable",
|
||||
"position": { "x": 50, "y": 100 },
|
||||
"data": { "value": "Zoe", "valueType": "string" },
|
||||
"style": { "width": 224, "height": 180 }
|
||||
},
|
||||
{
|
||||
"id": "cfg_001",
|
||||
"type": "config",
|
||||
"position": { "x": 424, "y": 50 },
|
||||
"data": {
|
||||
"plantuml": "@startuml\nactor User\nparticipant \"{{ var_001 }}\" as R\nUser -> R : loves\n@enduml\n",
|
||||
"title": "config-cfg_001"
|
||||
},
|
||||
"style": { "width": 320, "height": 320 }
|
||||
},
|
||||
{
|
||||
"id": "rnd_001",
|
||||
"type": "render",
|
||||
"position": { "x": 894, "y": 50 },
|
||||
"data": {},
|
||||
"style": { "width": 384, "height": 320 }
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e-var_001-cfg_001", "source": "var_001", "target": "cfg_001", "type": "animated" },
|
||||
{ "id": "e-cfg_001-rnd_001", "source": "cfg_001", "target": "rnd_001", "type": "animated" }
|
||||
]
|
||||
}
|
||||
107
src/hooks/useGraphStateWithHistory.test.ts
Normal file
107
src/hooks/useGraphStateWithHistory.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useGraphStateWithHistory } from './useGraphStateWithHistory'
|
||||
import type { AppNode, AppEdge } from '@/lib/nodeTypes'
|
||||
|
||||
const emptyNodes: AppNode[] = []
|
||||
const emptyEdges: AppEdge[] = []
|
||||
|
||||
function makeNode(id: string, type: string, x: number, y: number): AppNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: { x, y },
|
||||
data: {},
|
||||
style: { width: 200, height: 100 },
|
||||
}
|
||||
}
|
||||
|
||||
describe('useGraphStateWithHistory', () => {
|
||||
it('returns initial nodes and edges', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const initialEdges: AppEdge[] = [{ id: 'e1', source: 'a', target: 'b' }]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, initialEdges))
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe('a')
|
||||
expect(result.current.edges).toHaveLength(1)
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
|
||||
it('push to past on setNodes and allows undo', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(2)
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe('a')
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(true)
|
||||
})
|
||||
|
||||
it('redo restores state', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
})
|
||||
act(() => {
|
||||
result.current.redo()
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(2)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
|
||||
it('saveForDragEnd and commitDragEnd push drag snapshot to history', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
act(() => {
|
||||
result.current.saveForDragEnd()
|
||||
})
|
||||
act(() => {
|
||||
result.current.setNodes((prev) =>
|
||||
prev.map((n) => (n.id === 'b' ? { ...n, position: { x: 200, y: 50 } } : n))
|
||||
)
|
||||
})
|
||||
act(() => {
|
||||
result.current.commitDragEnd()
|
||||
})
|
||||
expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 200, y: 50 })
|
||||
act(() => {
|
||||
result.current.undo()
|
||||
})
|
||||
expect(result.current.nodes.find((n) => n.id === 'b')?.position).toEqual({ x: 100, y: 0 })
|
||||
})
|
||||
|
||||
it('setStateImmediate clears history', () => {
|
||||
const initialNodes = [makeNode('a', 'config', 0, 0)]
|
||||
const { result } = renderHook(() => useGraphStateWithHistory(initialNodes, emptyEdges))
|
||||
act(() => {
|
||||
result.current.setNodes((prev) => [...prev, makeNode('b', 'render', 100, 0)])
|
||||
})
|
||||
expect(result.current.canUndo).toBe(true)
|
||||
act(() => {
|
||||
result.current.setStateImmediate({
|
||||
nodes: [makeNode('c', 'variable', 0, 0)],
|
||||
edges: [],
|
||||
})
|
||||
})
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.nodes[0].id).toBe('c')
|
||||
expect(result.current.canUndo).toBe(false)
|
||||
expect(result.current.canRedo).toBe(false)
|
||||
})
|
||||
})
|
||||
52
src/lib/flowUtils.test.ts
Normal file
52
src/lib/flowUtils.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { getNextNodeId, replaceNodeIdInGraph, DEFAULT_NODE_STYLE } from './flowUtils'
|
||||
import { registerBuiltinNodes } from './registerBuiltinNodes'
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltinNodes()
|
||||
})
|
||||
|
||||
describe('getNextNodeId', () => {
|
||||
it('returns prefix + 001 when no existing ids', () => {
|
||||
expect(getNextNodeId('config', [])).toBe('cfg_001')
|
||||
expect(getNextNodeId('render', [])).toBe('rnd_001')
|
||||
expect(getNextNodeId('variable', [])).toBe('var_001')
|
||||
expect(getNextNodeId('function', [])).toBe('fn_001')
|
||||
})
|
||||
|
||||
it('increments after existing ids', () => {
|
||||
expect(getNextNodeId('config', ['cfg_001'])).toBe('cfg_002')
|
||||
expect(getNextNodeId('config', ['cfg_001', 'cfg_002', 'cfg_003'])).toBe('cfg_004')
|
||||
})
|
||||
|
||||
it('ignores ids of other types', () => {
|
||||
expect(getNextNodeId('config', ['rnd_001', 'var_001'])).toBe('cfg_001')
|
||||
expect(getNextNodeId('config', ['cfg_002'])).toBe('cfg_003')
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceNodeIdInGraph', () => {
|
||||
it('renames node and updates edges and references in data', () => {
|
||||
const nodes = [
|
||||
{ id: 'cfg_001', data: { title: 'cfg_001', plantuml: 'x' }, type: 'config', position: { x: 0, y: 0 } },
|
||||
{ id: 'rnd_001', data: {}, type: 'render', position: { x: 100, y: 0 } },
|
||||
]
|
||||
const edges = [
|
||||
{ id: 'e-cfg_001-rnd_001', source: 'cfg_001', target: 'rnd_001' },
|
||||
]
|
||||
const result = replaceNodeIdInGraph(nodes, edges, 'cfg_001', 'cfg_002')
|
||||
expect(result.nodes[0].id).toBe('cfg_002')
|
||||
expect(result.nodes[0].data).toEqual(expect.objectContaining({ title: 'cfg_002' }))
|
||||
expect(result.edges[0].source).toBe('cfg_002')
|
||||
expect(result.edges[0].id).toBe('e-cfg_002-rnd_001')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_NODE_STYLE', () => {
|
||||
it('has styles for config, render, variable, function', () => {
|
||||
expect(DEFAULT_NODE_STYLE.config).toEqual({ width: 320, height: 320 })
|
||||
expect(DEFAULT_NODE_STYLE.render).toEqual({ width: 384, height: 320 })
|
||||
expect(DEFAULT_NODE_STYLE.variable).toEqual({ width: 224, height: 180 })
|
||||
expect(DEFAULT_NODE_STYLE.function).toEqual({ width: 288, height: 260 })
|
||||
})
|
||||
})
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
getDefaultStyle,
|
||||
} from './nodeRegistry'
|
||||
|
||||
export function nodePropsAreEqual<P extends { id?: string; data?: any; width?: number; height?: number; selected?: boolean }>(
|
||||
/** Minimal node/edge shape for replaceNodeIdInGraph (avoids circular dependency on nodeTypes). */
|
||||
type GraphNode = { id: string; data?: unknown; [k: string]: unknown }
|
||||
type GraphEdge = { id: string; source: string; target: string; [k: string]: unknown }
|
||||
|
||||
export function nodePropsAreEqual<P extends { id?: string; data?: unknown; width?: number; height?: number; selected?: boolean }>(
|
||||
prev: P,
|
||||
next: P
|
||||
): boolean {
|
||||
@@ -56,13 +60,15 @@ function replaceInData(value: unknown, oldId: string, newId: string): unknown {
|
||||
|
||||
/** Update graph after renaming a node: change node id and all references in data and edges */
|
||||
export function replaceNodeIdInGraph(
|
||||
nodes: Array<{ id: string; data?: any; [k: string]: any }>,
|
||||
edges: Array<{ id: string; source: string; target: string; [k: string]: any }>,
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
oldId: string,
|
||||
newId: string
|
||||
): { nodes: typeof nodes; edges: typeof edges } {
|
||||
): { nodes: GraphNode[]; edges: GraphEdge[] } {
|
||||
const newNodes = nodes.map((n) =>
|
||||
n.id === oldId ? { ...n, id: newId } : { ...n, data: replaceInData(n.data, oldId, newId) as any }
|
||||
n.id === oldId
|
||||
? { ...n, id: newId, data: replaceInData(n.data, oldId, newId) }
|
||||
: { ...n, data: replaceInData(n.data, oldId, newId) }
|
||||
)
|
||||
const newEdges = edges.map((e) => ({
|
||||
...e,
|
||||
@@ -82,12 +88,12 @@ export const DEFAULT_NODE_STYLE: Record<string, { width: number; height: number
|
||||
}
|
||||
|
||||
/** Default data for a new node. Uses nodeRegistry when type is registered. */
|
||||
export function getDefaultDataForType(type: string, newId?: string): any {
|
||||
export function getDefaultDataForType(type: string, newId?: string): Record<string, unknown> {
|
||||
return getDefaultDataFromRegistry(type, newId)
|
||||
}
|
||||
|
||||
/** Data for Reset action. Uses nodeRegistry when type is registered. */
|
||||
export function getResetDataForType(type: string, nodeId?: string): any {
|
||||
export function getResetDataForType(type: string, nodeId?: string): Record<string, unknown> {
|
||||
return getResetDataFromRegistry(type, nodeId)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,18 @@ export type NodeHelpEntry = {
|
||||
content: React.ReactNode
|
||||
}
|
||||
|
||||
/** Props passed to registered node components: id, data, and optional dimensions/selection. */
|
||||
export type NodeComponentProps = {
|
||||
id: string
|
||||
data?: Record<string, unknown>
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export type NodeTypeDescriptor = {
|
||||
id: string
|
||||
component: React.ComponentType<any>
|
||||
component: React.ComponentType<NodeComponentProps>
|
||||
defaultStyle: { width: number; height: number }
|
||||
defaultData: Record<string, unknown>
|
||||
idPrefix: string
|
||||
@@ -61,8 +70,8 @@ export function getDefaultDataForType(type: string, newId?: string): Record<stri
|
||||
const desc = registry.get(type)
|
||||
if (!desc) return {}
|
||||
if (desc.getDefaultData) return { ...desc.getDefaultData(newId) }
|
||||
const base = { ...desc.defaultData }
|
||||
if (type === 'config' && newId) (base as any).title = `${newId}`
|
||||
const base: Record<string, unknown> = { ...desc.defaultData }
|
||||
if (type === 'config' && newId) base.title = `${newId}`
|
||||
return base
|
||||
}
|
||||
|
||||
|
||||
45
src/projectLoad.test.ts
Normal file
45
src/projectLoad.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { getRegisteredNodeTypeIds } from './lib/nodeRegistry'
|
||||
import { registerBuiltinNodes } from './lib/registerBuiltinNodes'
|
||||
import sample from './fixtures/sample.zui.json'
|
||||
|
||||
beforeAll(() => {
|
||||
registerBuiltinNodes()
|
||||
})
|
||||
|
||||
/** Minimal type for parsed .zui.json project file */
|
||||
type ProjectFile = {
|
||||
version?: number
|
||||
nodes: Array<{ id: string; type: string; data?: unknown; position?: { x: number; y: number }; style?: unknown }>
|
||||
edges: Array<{ id: string; source: string; target: string; type?: string }>
|
||||
}
|
||||
|
||||
describe('project load (.zui.json)', () => {
|
||||
it('parses sample project and asserts node/edge count and types', () => {
|
||||
const project = sample as ProjectFile
|
||||
expect(project).toBeDefined()
|
||||
expect(Array.isArray(project.nodes)).toBe(true)
|
||||
expect(Array.isArray(project.edges)).toBe(true)
|
||||
expect(project.nodes).toHaveLength(3)
|
||||
expect(project.edges).toHaveLength(2)
|
||||
const nodeTypes = project.nodes.map((n) => n.type)
|
||||
expect(nodeTypes).toContain('variable')
|
||||
expect(nodeTypes).toContain('config')
|
||||
expect(nodeTypes).toContain('render')
|
||||
const validTypeIds = getRegisteredNodeTypeIds()
|
||||
for (const n of project.nodes) {
|
||||
expect(validTypeIds).toContain(n.type)
|
||||
}
|
||||
expect(project.nodes[0].id).toBe('var_001')
|
||||
expect(project.nodes[1].id).toBe('cfg_001')
|
||||
expect(project.nodes[2].id).toBe('rnd_001')
|
||||
expect(project.edges[0].source).toBe('var_001')
|
||||
expect(project.edges[0].target).toBe('cfg_001')
|
||||
expect(project.edges[1].source).toBe('cfg_001')
|
||||
expect(project.edges[1].target).toBe('rnd_001')
|
||||
if (project.version != null) {
|
||||
expect(typeof project.version).toBe('number')
|
||||
expect(project.version).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,6 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: { '@': path.resolve(__dirname, './src') },
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user