Files
homelable/frontend/src/components/modals/NodeModal.tsx
T
Pouzor df3b7a8cb0 fix: show validation error when label is empty in NodeModal
Silent return when label was empty made the Add button appear broken.
Native browser required tooltip doesn't render in Radix Dialog portals.
Now shows inline red error message and highlights the field.
2026-03-16 01:01:47 +01:00

347 lines
16 KiB
TypeScript

import { createElement, useState } from 'react'
import { RotateCcw, ChevronDown } from 'lucide-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 { NODE_TYPE_LABELS, type NodeData, type NodeType, type CheckMethod } from '@/types'
import { resolveNodeColors } from '@/utils/nodeColors'
import { ICON_REGISTRY, ICON_CATEGORIES } from '@/utils/nodeIcons'
const NODE_TYPES = Object.entries(NODE_TYPE_LABELS) as [NodeType, string][]
const CHECK_METHODS: CheckMethod[] = ['none', 'ping', 'http', 'https', 'tcp', 'ssh', 'prometheus', 'health']
const DEFAULT_DATA: Partial<NodeData> = {
type: 'server',
label: '',
hostname: '',
ip: '',
status: 'unknown',
check_method: 'ping',
services: [],
container_mode: true,
custom_colors: undefined,
custom_icon: undefined,
}
interface NodeModalProps {
open: boolean
onClose: () => void
onSubmit: (data: Partial<NodeData>) => void
initial?: Partial<NodeData>
title?: string
proxmoxNodes?: { id: string; label: string }[]
}
const CHILD_TYPES: NodeType[] = ['vm', 'lxc']
// NodeModal is always mounted with a key that changes on open/edit, so useState
// initial value is enough — no need for a reset effect.
export function NodeModal({ open, onClose, onSubmit, initial, title = 'Add Node', proxmoxNodes = [] }: NodeModalProps) {
const [form, setForm] = useState<Partial<NodeData>>({ ...DEFAULT_DATA, ...initial })
const [iconSearch, setIconSearch] = useState('')
const [iconPickerOpen, setIconPickerOpen] = useState(false)
const [labelError, setLabelError] = useState(false)
const set = (key: keyof NodeData, value: unknown) =>
setForm((f) => ({ ...f, [key]: value }))
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!form.label?.trim()) {
setLabelError(true)
return
}
setLabelError(false)
onSubmit(form)
onClose()
}
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="bg-[#161b22] border-[#30363d] text-foreground max-w-md">
<DialogHeader>
<DialogTitle className="text-sm font-semibold">{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 mt-2">
<div className="grid grid-cols-2 gap-3">
{/* Type */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Type</Label>
<Select value={form.type} onValueChange={(v) => set('type', v as NodeType)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{NODE_TYPES.map(([value, label]) => (
<SelectItem key={value} value={value} className="text-sm">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Icon */}
<div className="flex flex-col gap-1.5 col-span-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Icon</Label>
{form.custom_icon && (
<button
type="button"
onClick={() => { set('custom_icon', undefined); setIconPickerOpen(false) }}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
{/* Trigger button */}
<button
type="button"
onClick={() => setIconPickerOpen((o) => !o)}
className="flex items-center justify-between gap-2 h-8 px-3 rounded-md bg-[#21262d] border border-[#30363d] text-sm hover:border-[#8b949e] transition-colors"
>
<span className="flex items-center gap-2">
{(() => {
const entry = ICON_REGISTRY.find((e) => e.key === form.custom_icon)
if (entry) {
return <>{createElement(entry.icon, { size: 13, className: 'text-[#00d4ff]' })}<span className="text-foreground">{entry.label}</span></>
}
return <span className="text-muted-foreground">Default (from type)</span>
})()}
</span>
<ChevronDown size={12} className="text-muted-foreground shrink-0" style={{ transform: iconPickerOpen ? 'rotate(180deg)' : undefined, transition: 'transform 0.15s' }} />
</button>
{/* Inline picker panel */}
{iconPickerOpen && (
<div className="flex flex-col gap-2 p-2.5 rounded-md bg-[#0d1117] border border-[#30363d]">
<Input
value={iconSearch}
onChange={(e) => setIconSearch(e.target.value)}
placeholder="Search icons…"
className="bg-[#21262d] border-[#30363d] text-xs h-7"
autoFocus
/>
<div className="flex flex-col gap-2 max-h-52 overflow-y-auto">
{ICON_CATEGORIES.map((cat) => {
const entries = ICON_REGISTRY.filter(
(e) => e.category === cat &&
(iconSearch === '' || e.label.toLowerCase().includes(iconSearch.toLowerCase()) || e.key.includes(iconSearch.toLowerCase()))
)
if (entries.length === 0) return null
return (
<div key={cat}>
<p className="text-[9px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1">{cat}</p>
<div className="grid grid-cols-7 gap-1">
{entries.map((entry) => {
const isSelected = form.custom_icon === entry.key
return (
<button
key={entry.key}
type="button"
title={entry.label}
onClick={() => { set('custom_icon', isSelected ? undefined : entry.key); setIconPickerOpen(false) }}
className="flex items-center justify-center w-7 h-7 rounded transition-colors"
style={{
background: isSelected ? '#00d4ff22' : 'transparent',
border: isSelected ? '1px solid #00d4ff88' : '1px solid transparent',
color: isSelected ? '#00d4ff' : '#8b949e',
}}
onMouseEnter={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = '#21262d' }}
onMouseLeave={(e) => { if (!isSelected) (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
>
{createElement(entry.icon, { size: 13 })}
</button>
)
})}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
{/* Label */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Label *</Label>
<Input
value={form.label ?? ''}
onChange={(e) => { set('label', e.target.value); if (labelError) setLabelError(false) }}
placeholder="My Server"
className={`bg-[#21262d] text-sm h-8 ${labelError ? 'border-[#f85149] focus-visible:ring-[#f85149]' : 'border-[#30363d]'}`}
/>
{labelError && <p className="text-[11px] text-[#f85149]">Label is required</p>}
</div>
{/* Hostname */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Hostname</Label>
<Input
value={form.hostname ?? ''}
onChange={(e) => set('hostname', e.target.value)}
placeholder="server.lan"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* IP */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">IP Address</Label>
<Input
value={form.ip ?? ''}
onChange={(e) => set('ip', e.target.value)}
placeholder="192.168.1.x"
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Check method */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Method</Label>
<Select value={form.check_method ?? 'ping'} onValueChange={(v) => set('check_method', v as CheckMethod)}>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
{CHECK_METHODS.map((m) => (
<SelectItem key={m} value={m} className="text-sm font-mono">{m}</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Check target */}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Check Target</Label>
<Input
value={form.check_target ?? ''}
onChange={(e) => set('check_target', e.target.value)}
placeholder="http://..."
className="bg-[#21262d] border-[#30363d] font-mono text-sm h-8"
/>
</div>
{/* Parent Proxmox (VM / LXC only) */}
{CHILD_TYPES.includes(form.type as NodeType) && proxmoxNodes.length > 0 && (
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Parent Proxmox</Label>
<Select
value={form.parent_id ?? 'none'}
onValueChange={(v) => set('parent_id', v === 'none' ? undefined : v)}
>
<SelectTrigger className="bg-[#21262d] border-[#30363d] text-sm h-8">
<SelectValue placeholder="None (standalone)" />
</SelectTrigger>
<SelectContent className="bg-[#21262d] border-[#30363d]">
<SelectItem value="none" className="text-sm">None (standalone)</SelectItem>
{proxmoxNodes.map((n) => (
<SelectItem key={n.id} value={n.id} className="text-sm">{n.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Container mode (proxmox only) */}
{form.type === 'proxmox' && (
<div className="flex items-center justify-between col-span-2 py-1">
<div className="flex flex-col gap-0.5">
<Label className="text-xs text-muted-foreground">Container Mode</Label>
<span className="text-[10px] text-muted-foreground/60">Show VM/LXC nodes nested inside</span>
</div>
<button
type="button"
role="switch"
aria-checked={!!form.container_mode}
onClick={() => set('container_mode', !form.container_mode)}
className="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
style={{ background: form.container_mode ? '#ff6e00' : '#30363d' }}
>
<span
className="pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform"
style={{ transform: form.container_mode ? 'translateX(16px)' : 'translateX(0)' }}
/>
</button>
</div>
)}
{/* Appearance */}
<div className="flex flex-col gap-2 col-span-2">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Appearance</Label>
{form.custom_colors && (
<button
type="button"
onClick={() => set('custom_colors', undefined)}
className="flex items-center gap-1 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<RotateCcw size={10} /> Reset to defaults
</button>
)}
</div>
<div className="grid grid-cols-3 gap-2">
{(['border', 'background', 'icon'] as const).map((key) => {
const resolved = resolveNodeColors({ type: form.type ?? 'generic', custom_colors: form.custom_colors })
const currentValue = resolved[key]
const isCustom = !!form.custom_colors?.[key]
return (
<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 transition-all"
style={{ borderColor: isCustom ? currentValue : '#30363d' }}
title={`${key.charAt(0).toUpperCase() + key.slice(1)}: ${currentValue}`}
>
<input
type="color"
value={currentValue}
onChange={(e) => set('custom_colors', { ...form.custom_colors, [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: currentValue }} />
</label>
<span className="text-[9px] text-muted-foreground/60 capitalize">{key}</span>
</div>
)
})}
</div>
{!form.custom_colors && (
<p className="text-[10px] text-muted-foreground/50">Using default colors for {NODE_TYPE_LABELS[form.type ?? 'generic']}. Click a swatch to customize.</p>
)}
</div>
{/* Notes */}
<div className="flex flex-col gap-1.5 col-span-2">
<Label className="text-xs text-muted-foreground">Notes</Label>
<Input
value={form.notes ?? ''}
onChange={(e) => set('notes', e.target.value)}
placeholder="Optional notes"
className="bg-[#21262d] border-[#30363d] text-sm h-8"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<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 Node' ? 'Add' : 'Save'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}