feat: multi-line edge labels

Connection labels now accept line breaks. The label field in EdgeModal
is a textarea (Enter inserts a newline) and the rendered edge label uses
whitespace-pre-line so newlines display as multiple lines.

Closes #183

ha-relevant: yes
This commit is contained in:
Pouzor
2026-06-10 12:04:10 +02:00
parent b34b40794d
commit 7873f8b94f
5 changed files with 119 additions and 8 deletions
+7 -5
View File
@@ -4,6 +4,7 @@ import { RotateCcw } 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 { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
@@ -44,7 +45,7 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
e.preventDefault()
onSubmit({
type,
label: label || undefined,
label: label.trim() || undefined,
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
custom_color: customColor,
path_style: pathStyle,
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
)}
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional)</span></Label>
<Input
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
<Textarea
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. 1G, trunk..."
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
placeholder={'e.g. 1G, trunk...\nsecond line'}
rows={2}
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
/>
</div>
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
it('calls onSubmit with label when filled', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText('e.g. 1G, trunk...'), { target: { value: 'uplink' } })
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'uplink' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
})
it('preserves newlines in label so it can span multiple lines (issue #183)', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: 'line one\nline two' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('line one\nline two')
})
it('trims surrounding whitespace/blank lines from label on submit', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
fireEvent.change(screen.getByPlaceholderText(/e\.g\. 1G, trunk/), { target: { value: ' a\nb\n\n' } })
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
expect(onSubmit.mock.calls[0][0].label).toBe('a\nb')
})
it('renders the label field as a textarea (multi-line input)', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
expect(screen.getByPlaceholderText(/e\.g\. 1G, trunk/).tagName).toBe('TEXTAREA')
})
it('omits label from payload when empty', () => {
const onSubmit = vi.fn()
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
it('pre-fills label from initial prop', () => {
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
const input = screen.getByPlaceholderText('e.g. 1G, trunk...') as HTMLInputElement
const input = screen.getByPlaceholderText(/e\.g\. 1G, trunk/) as HTMLTextAreaElement
expect(input.value).toBe('trunk')
})