feat: add Group Rectangle — resizable decorative zones on canvas

- New GroupRectNode: rounded rect with NodeResizer (8 handles), no
  connection handles, always behind network nodes via negative zIndex
- GroupRectModal: label, font preset (Inter/Mono/Serif), 3×3 text
  position grid, text/border/background color pickers, z-order 1–9
- Sidebar: "Add Rectangle" button (below Add Node), group rects
  excluded from node count stats
- Save/load: size persisted in custom_colors.width/height, no backend
  schema changes required
- elevateNodesOnSelect=false on ReactFlow so selected rects never
  pop above network nodes
- Tests: 3 new store tests + 9 GroupRectModal tests (66 total, all pass)
This commit is contained in:
Pouzor
2026-03-10 16:59:40 +01:00
parent c0f1d1ff1a
commit 1e72366d03
10 changed files with 604 additions and 27 deletions
@@ -0,0 +1,202 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { TextPosition } from '@/types'
export interface GroupRectFormData {
label: string
font: string
text_color: string
text_position: TextPosition
border_color: string
background_color: string
z_order: number
}
const DEFAULT_FORM: GroupRectFormData = {
label: '',
font: 'inter',
text_color: '#e6edf3',
text_position: 'top-left',
border_color: '#00d4ff',
background_color: '#00d4ff0d',
z_order: 1,
}
const FONTS = [
{ value: 'inter', label: 'Inter (sans-serif)' },
{ value: 'mono', label: 'JetBrains Mono' },
{ value: 'serif', label: 'Serif' },
]
const TEXT_POSITIONS: { value: TextPosition; label: string }[] = [
{ value: 'top-left', label: '↖' },
{ value: 'top-center', label: '↑' },
{ value: 'top-right', label: '↗' },
{ value: 'middle-left', label: '←' },
{ value: 'center', label: '·' },
{ value: 'middle-right', label: '→' },
{ value: 'bottom-left', label: '↙' },
{ value: 'bottom-center', label: '↓' },
{ value: 'bottom-right', label: '↘' },
]
interface GroupRectModalProps {
open: boolean
onClose: () => void
onSubmit: (data: GroupRectFormData) => void
onDelete?: () => void
initial?: Partial<GroupRectFormData>
title?: string
}
export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, title = 'Add Rectangle' }: GroupRectModalProps) {
const [form, setForm] = useState<GroupRectFormData>({ ...DEFAULT_FORM, ...initial })
const set = <K extends keyof GroupRectFormData>(key: K, value: GroupRectFormData[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
onSubmit(form)
onClose()
}
const colorFields = [
{ key: 'text_color' as const, label: 'Text' },
{ key: 'border_color' as const, label: 'Border' },
{ key: 'background_color' as const, label: 'Background' },
]
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
{/* Label */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label</Label>
<Input
value={form.label}
onChange={(e) => set('label', e.target.value)}
placeholder="Zone name…"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
{/* Font */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Font</Label>
<Select value={form.font} onValueChange={(v) => set('font', v)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{FONTS.map((f) => (
<SelectItem key={f.value} value={f.value} className="text-sm">
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Text position 3×3 grid */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Text Position</Label>
<div className="grid grid-cols-3 gap-1">
{TEXT_POSITIONS.map(({ value, label }) => {
const isSelected = form.text_position === value
return (
<button
key={value}
type="button"
title={value}
onClick={() => set('text_position', value)}
className="h-8 rounded text-base transition-colors"
style={{
background: isSelected ? '#00d4ff22' : '#21262d',
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
color: isSelected ? '#00d4ff' : '#8b949e',
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Colors */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Colors</Label>
<div className="grid grid-cols-3 gap-2">
{colorFields.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1 items-center">
<label
className="relative w-full h-7 rounded-md border cursor-pointer overflow-hidden"
style={{ borderColor: '#30363d' }}
>
<input
type="color"
value={form[key]}
onChange={(e) => set(key, e.target.value)}
className="absolute inset-0 w-full h-full cursor-pointer opacity-0"
/>
<div className="w-full h-full rounded-sm" style={{ background: form[key] }} />
</label>
<span className="text-[9px] text-muted-foreground/60">{label}</span>
</div>
))}
</div>
</div>
{/* Z-order */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Z-Order (1 = furthest back)</Label>
<Select value={String(form.z_order)} onValueChange={(v) => set('z_order', Number(v))}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (
<SelectItem key={n} value={String(n)} className="text-sm font-mono">
{n}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-between gap-2 pt-1">
{onDelete && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-[#f85149] hover:text-[#f85149] hover:bg-[#f85149]/10"
onClick={() => { onDelete(); onClose() }}
>
Delete
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="submit" size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90">
{title === 'Add Rectangle' ? 'Add' : 'Save'}
</Button>
</div>
</div>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,83 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { GroupRectModal, type GroupRectFormData } from '../GroupRectModal'
describe('GroupRectModal', () => {
it('renders nothing when closed', () => {
const { container } = render(
<GroupRectModal open={false} onClose={vi.fn()} onSubmit={vi.fn()} />
)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders form fields when open', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText('Zone name…')).toBeDefined()
expect(screen.getByText('Add Rectangle')).toBeDefined()
expect(screen.getByText('Text Position')).toBeDefined()
expect(screen.getByText('Z-Order (1 = furthest back)')).toBeDefined()
})
it('renders Edit Rectangle title when provided', () => {
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} title="Edit Rectangle" />)
expect(screen.getByText('Edit Rectangle')).toBeDefined()
})
it('calls onSubmit with form data on submit', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
const input = screen.getByPlaceholderText('Zone name…')
fireEvent.change(input, { target: { value: 'DMZ' } })
fireEvent.click(screen.getByText('Add'))
expect(onSubmit).toHaveBeenCalledOnce()
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.label).toBe('DMZ')
expect(submitted.font).toBe('inter')
expect(submitted.z_order).toBe(1)
})
it('calls onClose when Cancel is clicked', () => {
const onClose = vi.fn()
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} />)
fireEvent.click(screen.getByText('Cancel'))
expect(onClose).toHaveBeenCalledOnce()
})
it('shows Delete button when onDelete is provided', () => {
const onDelete = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} onDelete={onDelete} />)
expect(screen.getByText('Delete')).toBeDefined()
})
it('calls onDelete and onClose when Delete is clicked', () => {
const onDelete = vi.fn()
const onClose = vi.fn()
render(<GroupRectModal open onClose={onClose} onSubmit={vi.fn()} onDelete={onDelete} />)
fireEvent.click(screen.getByText('Delete'))
expect(onDelete).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('pre-fills form from initial prop', () => {
render(
<GroupRectModal
open
onClose={vi.fn()}
onSubmit={vi.fn()}
initial={{ label: 'Pre-filled', z_order: 5, font: 'mono' }}
/>
)
const input = screen.getByPlaceholderText('Zone name…') as HTMLInputElement
expect(input.value).toBe('Pre-filled')
})
it('selects text position button', () => {
const onSubmit = vi.fn()
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
// Click bottom-right (↘)
fireEvent.click(screen.getByTitle('bottom-right'))
fireEvent.click(screen.getByText('Add'))
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
expect(submitted.text_position).toBe('bottom-right')
})
})