feat: manage canvases with custom name and icon
Make designs (canvases) fully user-manageable: create with a chosen name and icon, rename, change icon, and delete. Replaces the hardcoded "New Electrical Design" button with a generic "New Canvas" flow. - Add Design.icon column + migration that backfills legacy rows (electrical -> zap, others -> dashboard) - DesignModal: name input + curated lucide icon picker (create + edit) - Sidebar switcher gains per-canvas edit/delete; delete guards the last canvas and confirms - designStore: addDesign/updateDesign/removeDesign with active reassignment - Fix data loss on design switch: abort load when the save fails and keep unsaved edits; skip the save-old step when the previous canvas was deleted - designsApi create/update carry icon; design_type kept for back-compat Tests: backend design CRUD (icon + cascade + last-canvas guard), designStore actions, designIcons resolver, DesignModal create/edit/validation. ha-relevant: yes
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
|
||||
export interface DesignFormData {
|
||||
name: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
interface DesignModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: DesignFormData) => void
|
||||
initial?: DesignFormData
|
||||
title?: string
|
||||
submitLabel?: string
|
||||
}
|
||||
|
||||
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
|
||||
const [name, setName] = useState(initial?.name ?? '')
|
||||
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
onSubmit({ name: trimmed, icon })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="design-name">Name</Label>
|
||||
<Input
|
||||
id="design-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmit() }}
|
||||
placeholder="e.g. Home Network, Rack Power"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>Icon</Label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{DESIGN_ICONS.map((entry) => {
|
||||
const Icon = entry.icon
|
||||
const selected = entry.key === icon
|
||||
return (
|
||||
<button
|
||||
key={entry.key}
|
||||
type="button"
|
||||
aria-label={entry.label}
|
||||
aria-pressed={selected}
|
||||
title={entry.label}
|
||||
onClick={() => setIcon(entry.key)}
|
||||
className={`flex items-center justify-center aspect-square rounded-md border transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'border-[#00d4ff] bg-[#00d4ff]/10 text-[#00d4ff]'
|
||||
: 'border-border text-muted-foreground hover:text-foreground hover:border-[#30363d]'
|
||||
}`}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} disabled={!name.trim()}>{submitLabel}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DesignModal } from '../DesignModal'
|
||||
import { DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
|
||||
|
||||
function renderModal(props: Partial<Parameters<typeof DesignModal>[0]> = {}) {
|
||||
const onClose = vi.fn()
|
||||
const onSubmit = vi.fn()
|
||||
render(<DesignModal open onClose={onClose} onSubmit={onSubmit} {...props} />)
|
||||
return { onClose, onSubmit }
|
||||
}
|
||||
|
||||
describe('DesignModal', () => {
|
||||
it('creates with the typed name and default icon', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Home Network' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Home Network', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('submits the selected icon', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Rack Power' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Electrical' })) // zap icon's aria-label
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Rack Power', icon: 'zap' })
|
||||
})
|
||||
|
||||
it('trims whitespace and blocks empty names', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
// Empty → submit disabled, no call.
|
||||
const submit = screen.getByRole('button', { name: 'Create' })
|
||||
expect(submit).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Name'), { target: { value: ' Spaced ' } })
|
||||
fireEvent.click(submit)
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Spaced', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('prefills name and icon in edit mode', () => {
|
||||
const { onSubmit } = renderModal({
|
||||
initial: { name: 'Existing', icon: 'server' },
|
||||
title: 'Edit Canvas',
|
||||
submitLabel: 'Save',
|
||||
})
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Existing')
|
||||
// The server icon button is pre-selected.
|
||||
expect(screen.getByRole('button', { name: 'Server' })).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Existing', icon: 'server' })
|
||||
})
|
||||
|
||||
it('submits on Enter from the name field', () => {
|
||||
const { onSubmit } = renderModal()
|
||||
const input = screen.getByLabelText('Name')
|
||||
fireEvent.change(input, { target: { value: 'Quick' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: 'Quick', icon: DEFAULT_DESIGN_ICON })
|
||||
})
|
||||
|
||||
it('calls onClose from Cancel', () => {
|
||||
const { onClose, onSubmit } = renderModal()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user