feat: selectable marker shapes per edge endpoint
Replace the on/off arrowhead toggle with a per-end shape picker. Each end (start/end) independently selects: none, arrow, arrow-open, circle, diamond, or square. Markers still recolor live from the resolved stroke color. Frontend: - MarkerShape type + edgeMarkers util (normalizeMarker, MARKER_GEOMETRY); legacy boolean coerces to 'arrow' on read. - Per-shape <marker> inner geometry; symmetric shapes use fixed orient. - MarkerShapePicker reused in EdgeModal and CustomStyleModal. - Serializer normalizes to shape strings. Backend: - Edge marker columns Boolean -> String (default 'none'); TEXT migration. - normalize_marker() + validators coerce legacy bool / unknown values. ha-relevant: yes
This commit is contained in:
@@ -124,26 +124,26 @@ describe('CustomStyleModal', () => {
|
||||
expect(markUnsaved).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('edge editor exposes Start/End arrow toggles defaulting off', () => {
|
||||
it('edge editor exposes Start/End marker pickers defaulting to none', () => {
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
const startBtn = screen.getByRole('button', { name: 'Start' })
|
||||
const endBtn = screen.getByRole('button', { name: 'End' })
|
||||
expect(startBtn.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(endBtn.getAttribute('aria-pressed')).toBe('false')
|
||||
const startNone = screen.getByRole('button', { name: 'Start marker none' })
|
||||
const endNone = screen.getByRole('button', { name: 'End marker none' })
|
||||
expect(startNone.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(endNone.getAttribute('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('toggling End arrow feeds arrowEnd to applyTypeEdgeStyle', () => {
|
||||
it('picking an End shape feeds arrowEnd to applyTypeEdgeStyle', () => {
|
||||
const applyTypeEdgeStyle = vi.fn()
|
||||
useCanvasStore.setState({ applyTypeEdgeStyle })
|
||||
render(<CustomStyleModal open onClose={vi.fn()} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'End' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'End marker diamond' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe(true)
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe(false)
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond')
|
||||
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none')
|
||||
})
|
||||
|
||||
it('editing path style updates the edge draft', () => {
|
||||
|
||||
@@ -165,39 +165,56 @@ describe('EdgeModal', () => {
|
||||
expect(onSubmit.mock.calls[0][0].animated).toBe('basic')
|
||||
})
|
||||
|
||||
// ── Arrow markers ─────────────────────────────────────────────────────────
|
||||
// ── Endpoint markers ──────────────────────────────────────────────────────
|
||||
|
||||
it('arrows default to off', () => {
|
||||
it('endpoints default to none', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(false)
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe('none')
|
||||
})
|
||||
|
||||
it('toggling End arrow sends marker_end: true', () => {
|
||||
it('picking an End arrow sends marker_end: "arrow"', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Arrow End/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'End marker arrow' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(false)
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe('none')
|
||||
})
|
||||
|
||||
it('toggling Start arrow sends marker_start: true', () => {
|
||||
it('picking a Start circle sends marker_start: "circle"', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /Arrow Start/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start marker circle' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe('circle')
|
||||
})
|
||||
|
||||
it('pre-fills arrows from initial', () => {
|
||||
it('allows a different shape on each end', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: true, marker_end: true }} />)
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Start marker diamond' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'End marker square' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe(true)
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe('square')
|
||||
})
|
||||
|
||||
it('pre-fills endpoint shapes from initial', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_start: 'diamond', marker_end: 'arrow-open' }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_start).toBe('diamond')
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow-open')
|
||||
})
|
||||
|
||||
it('coerces a legacy boolean initial marker to "arrow"', () => {
|
||||
const onSubmit = vi.fn()
|
||||
render(<EdgeModal open onClose={vi.fn()} onSubmit={onSubmit} initial={{ marker_end: true }} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Connect' }))
|
||||
expect(onSubmit.mock.calls[0][0].marker_end).toBe('arrow')
|
||||
})
|
||||
|
||||
it('selecting None after Snake omits animated from payload', () => {
|
||||
|
||||
Reference in New Issue
Block a user