Merge pull request #199 from Pouzor/feat/multiline-edge-labels
feat: multi-line edge labels
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { render } from '@testing-library/react'
|
||||||
|
import { ReactFlowProvider } from '@xyflow/react'
|
||||||
|
import type { EdgeProps, Edge } from '@xyflow/react'
|
||||||
|
import type { EdgeData } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Issue #183 — connection labels must support multiple lines.
|
||||||
|
*
|
||||||
|
* The label is a free-text string; newlines entered in the EdgeModal textarea
|
||||||
|
* are stored verbatim. The rendered label div must preserve those newlines
|
||||||
|
* (`whitespace-pre-line`) instead of collapsing them into a single line.
|
||||||
|
*
|
||||||
|
* <EdgeLabelRenderer> normally portals into a node that only exists inside a
|
||||||
|
* full <ReactFlow> host, so we stub it to a passthrough to render the label
|
||||||
|
* markup directly.
|
||||||
|
*/
|
||||||
|
vi.mock('@xyflow/react', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('@xyflow/react')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
EdgeLabelRenderer: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const { HomelableEdge } = await import('../index')
|
||||||
|
|
||||||
|
function renderEdge(data: Partial<EdgeData> = {}) {
|
||||||
|
const props = {
|
||||||
|
id: 'e1',
|
||||||
|
source: 'a',
|
||||||
|
target: 'b',
|
||||||
|
sourceX: 0,
|
||||||
|
sourceY: 0,
|
||||||
|
targetX: 100,
|
||||||
|
targetY: 100,
|
||||||
|
sourcePosition: 'bottom',
|
||||||
|
targetPosition: 'top',
|
||||||
|
data: { type: 'ethernet', ...data } as EdgeData,
|
||||||
|
selected: false,
|
||||||
|
} as unknown as EdgeProps<Edge<EdgeData>>
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<svg>
|
||||||
|
<HomelableEdge {...props} />
|
||||||
|
</svg>
|
||||||
|
</ReactFlowProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HomelableEdge label', () => {
|
||||||
|
it('renders the label text', () => {
|
||||||
|
const { getByText } = renderEdge({ label: 'uplink' })
|
||||||
|
expect(getByText('uplink')).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves newlines in the rendered label (issue #183)', () => {
|
||||||
|
const { container } = renderEdge({ label: 'line one\nline two' })
|
||||||
|
const label = Array.from(container.querySelectorAll('div.whitespace-pre-line')).find((d) =>
|
||||||
|
d.textContent === 'line one\nline two',
|
||||||
|
)
|
||||||
|
expect(label).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders no label div when label is empty', () => {
|
||||||
|
const { container } = renderEdge({ label: undefined })
|
||||||
|
expect(container.querySelector('div.whitespace-pre-line')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -406,7 +406,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle
|
|||||||
<EdgeLabelRenderer>
|
<EdgeLabelRenderer>
|
||||||
{data?.label && (
|
{data?.label && (
|
||||||
<div
|
<div
|
||||||
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded"
|
className="absolute pointer-events-none font-mono text-[10px] px-1.5 py-0.5 rounded whitespace-pre-line text-center"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
transform: `translate(-50%, -50%) translate(${labelPosition.x}px, ${labelPosition.y}px)`,
|
||||||
background: theme.colors.edgeLabelBackground,
|
background: theme.colors.edgeLabelBackground,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { RotateCcw } from 'lucide-react'
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
import { EDGE_TYPE_LABELS, type EdgeData, type EdgePathStyle, type EdgeType } from '@/types'
|
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()
|
e.preventDefault()
|
||||||
onSubmit({
|
onSubmit({
|
||||||
type,
|
type,
|
||||||
label: label || undefined,
|
label: label.trim() || undefined,
|
||||||
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
vlan_id: type === 'vlan' && vlanId ? parseInt(vlanId) : undefined,
|
||||||
custom_color: customColor,
|
custom_color: customColor,
|
||||||
path_style: pathStyle,
|
path_style: pathStyle,
|
||||||
@@ -96,12 +97,13 @@ export function EdgeModal({ open, onClose, onSubmit, onDelete, onClearWaypoints,
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<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>
|
<Label className="text-xs text-muted-foreground">Label <span className="text-muted-foreground/50">(optional, multi-line)</span></Label>
|
||||||
<Input
|
<Textarea
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder="e.g. 1G, trunk..."
|
placeholder={'e.g. 1G, trunk...\nsecond line'}
|
||||||
className={`bg-[#21262d] border-[#30363d] text-sm h-8 ${modalStyles['modal-radius']}`}
|
rows={2}
|
||||||
|
className={`bg-[#21262d] border-[#30363d] text-sm min-h-16 resize-none ${modalStyles['modal-radius']}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -47,11 +47,32 @@ describe('EdgeModal', () => {
|
|||||||
it('calls onSubmit with label when filled', () => {
|
it('calls onSubmit with label when filled', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
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' }))
|
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||||
expect(onSubmit.mock.calls[0][0].label).toBe('uplink')
|
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', () => {
|
it('omits label from payload when empty', () => {
|
||||||
const onSubmit = vi.fn()
|
const onSubmit = vi.fn()
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
@@ -171,7 +192,7 @@ describe('EdgeModal', () => {
|
|||||||
|
|
||||||
it('pre-fills label from initial prop', () => {
|
it('pre-fills label from initial prop', () => {
|
||||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={vi.fn()} initial={{ label: 'trunk' }} />)
|
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')
|
expect(input.value).toBe('trunk')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"min-h-16 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1.5 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
Reference in New Issue
Block a user