feat: add label position (inside/outside) and text size to Zone modal
- Label position toggle: inside (default) or outside the border - Outside mode renders the label above/below the zone based on text_position - Text size selector: 10/12/14/16/18/20px (default 12) - Both fields persisted in custom_colors (no backend schema change needed) - 8 new frontend tests, 1 new backend test
This commit is contained in:
@@ -104,6 +104,25 @@ async def test_save_canvas_persists_custom_colors(client: AsyncClient, headers:
|
|||||||
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
|
assert canvas["nodes"][0]["custom_colors"] == {"border": "#ff0000", "icon": "#00ff00"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_save_canvas_persists_zone_label_position_and_text_size(client: AsyncClient, headers: dict):
|
||||||
|
"""label_position and text_size are stored in custom_colors and returned unchanged."""
|
||||||
|
n1 = node_payload(custom_colors={
|
||||||
|
"border": "#00d4ff",
|
||||||
|
"border_style": "solid",
|
||||||
|
"border_width": 3,
|
||||||
|
"label_position": "outside",
|
||||||
|
"text_size": 16,
|
||||||
|
"text_color": "#e6edf3",
|
||||||
|
})
|
||||||
|
await client.post("/api/v1/canvas/save", json={"nodes": [n1], "edges": [], "viewport": {}}, headers=headers)
|
||||||
|
|
||||||
|
canvas = (await client.get("/api/v1/canvas", headers=headers)).json()
|
||||||
|
cc = canvas["nodes"][0]["custom_colors"]
|
||||||
|
assert cc["label_position"] == "outside"
|
||||||
|
assert cc["text_size"] == 16
|
||||||
|
assert cc["border_width"] == 3
|
||||||
|
|
||||||
|
|
||||||
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
async def test_save_canvas_persists_edge_custom_color_and_path_style(client: AsyncClient, headers: dict):
|
||||||
n1 = node_payload()
|
n1 = node_payload()
|
||||||
n2 = node_payload()
|
n2 = node_payload()
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export default function App() {
|
|||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
|
text_size: data.text_size,
|
||||||
|
label_position: data.label_position,
|
||||||
font: data.font,
|
font: data.font,
|
||||||
z_order: data.z_order,
|
z_order: data.z_order,
|
||||||
},
|
},
|
||||||
@@ -211,6 +213,8 @@ export default function App() {
|
|||||||
background: data.background_color,
|
background: data.background_color,
|
||||||
text_color: data.text_color,
|
text_color: data.text_color,
|
||||||
text_position: data.text_position,
|
text_position: data.text_position,
|
||||||
|
text_size: data.text_size,
|
||||||
|
label_position: data.label_position,
|
||||||
font: data.font,
|
font: data.font,
|
||||||
z_order: data.z_order,
|
z_order: data.z_order,
|
||||||
},
|
},
|
||||||
@@ -462,6 +466,8 @@ export default function App() {
|
|||||||
border_style: rc.border_style ?? 'solid',
|
border_style: rc.border_style ?? 'solid',
|
||||||
border_width: rc.border_width ?? 2,
|
border_width: rc.border_width ?? 2,
|
||||||
background_color: rc.background ?? '#00d4ff0d',
|
background_color: rc.background ?? '#00d4ff0d',
|
||||||
|
text_size: rc.text_size ?? 12,
|
||||||
|
label_position: rc.label_position ?? 'inside',
|
||||||
z_order: rc.z_order ?? 1,
|
z_order: rc.z_order ?? 1,
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -35,10 +35,31 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
const borderWidth = rc.border_width ?? 2
|
const borderWidth = rc.border_width ?? 2
|
||||||
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
const backgroundColor = rc.background ?? 'rgba(0,212,255,0.05)'
|
||||||
const textColor = rc.text_color ?? '#e6edf3'
|
const textColor = rc.text_color ?? '#e6edf3'
|
||||||
|
const textSize: number = rc.text_size ?? 12
|
||||||
|
const labelPosition: string = rc.label_position ?? 'inside'
|
||||||
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
const fontFamily = FONT_FAMILIES[rc.font ?? 'inter'] ?? FONT_FAMILIES.inter
|
||||||
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
const textPos = (rc.text_position ?? 'top-left') as TextPosition
|
||||||
const posStyle = POSITION_STYLES[textPos]
|
const posStyle = POSITION_STYLES[textPos]
|
||||||
|
|
||||||
|
const outsideJustify = textPos.includes('right') ? 'flex-end'
|
||||||
|
: (textPos.includes('center') || textPos === 'center') ? 'center'
|
||||||
|
: 'flex-start'
|
||||||
|
|
||||||
|
const isOutsideBottom = textPos.startsWith('bottom')
|
||||||
|
const outsideOffset = textSize + 16
|
||||||
|
const outsideVertical: React.CSSProperties = isOutsideBottom
|
||||||
|
? { bottom: -outsideOffset }
|
||||||
|
: { top: -outsideOffset }
|
||||||
|
|
||||||
|
const sharedTextStyle: React.CSSProperties = {
|
||||||
|
color: textColor,
|
||||||
|
fontFamily,
|
||||||
|
fontSize: textSize,
|
||||||
|
fontWeight: 500,
|
||||||
|
userSelect: 'none',
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NodeResizer
|
<NodeResizer
|
||||||
@@ -56,6 +77,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'visible',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -65,10 +88,6 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
background: backgroundColor,
|
background: backgroundColor,
|
||||||
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
fontFamily,
|
|
||||||
color: textColor,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
}}
|
}}
|
||||||
@@ -77,8 +96,24 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
setEditingGroupRectId(id)
|
setEditingGroupRectId(id)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.label && (
|
{labelPosition === 'outside' && data.label && (
|
||||||
<span style={{ textAlign: posStyle.textAlign, userSelect: 'none', whiteSpace: 'pre-wrap' }}>
|
<span
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
...outsideVertical,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: outsideJustify,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
...sharedTextStyle,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{labelPosition === 'inside' && data.label && (
|
||||||
|
<span style={{ textAlign: posStyle.textAlign, ...sharedTextStyle }}>
|
||||||
{data.label}
|
{data.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ import type { TextPosition } from '@/types'
|
|||||||
|
|
||||||
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
export type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
|
||||||
|
|
||||||
|
export type LabelPosition = 'inside' | 'outside'
|
||||||
|
|
||||||
export interface GroupRectFormData {
|
export interface GroupRectFormData {
|
||||||
label: string
|
label: string
|
||||||
font: string
|
font: string
|
||||||
text_color: string
|
text_color: string
|
||||||
text_position: TextPosition
|
text_position: TextPosition
|
||||||
|
text_size: number
|
||||||
|
label_position: LabelPosition
|
||||||
border_color: string
|
border_color: string
|
||||||
border_style: BorderStyle
|
border_style: BorderStyle
|
||||||
border_width: number
|
border_width: number
|
||||||
@@ -28,6 +32,20 @@ const BORDER_STYLES: { value: BorderStyle; label: string; preview: string }[] =
|
|||||||
{ value: 'none', label: 'None', preview: ' ' },
|
{ value: 'none', label: 'None', preview: ' ' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const TEXT_SIZES: { value: number; label: string }[] = [
|
||||||
|
{ value: 10, label: '10' },
|
||||||
|
{ value: 12, label: '12' },
|
||||||
|
{ value: 14, label: '14' },
|
||||||
|
{ value: 16, label: '16' },
|
||||||
|
{ value: 18, label: '18' },
|
||||||
|
{ value: 20, label: '20' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const LABEL_POSITIONS: { value: LabelPosition; label: string }[] = [
|
||||||
|
{ value: 'inside', label: 'Inside' },
|
||||||
|
{ value: 'outside', label: 'Outside' },
|
||||||
|
]
|
||||||
|
|
||||||
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
const BORDER_WIDTHS: { value: number; label: string }[] = [
|
||||||
{ value: 1, label: '1px' },
|
{ value: 1, label: '1px' },
|
||||||
{ value: 2, label: '2px' },
|
{ value: 2, label: '2px' },
|
||||||
@@ -41,6 +59,8 @@ const DEFAULT_FORM: GroupRectFormData = {
|
|||||||
font: 'inter',
|
font: 'inter',
|
||||||
text_color: '#e6edf3',
|
text_color: '#e6edf3',
|
||||||
text_position: 'top-left',
|
text_position: 'top-left',
|
||||||
|
text_size: 12,
|
||||||
|
label_position: 'inside',
|
||||||
border_color: '#00d4ff',
|
border_color: '#00d4ff',
|
||||||
border_style: 'solid',
|
border_style: 'solid',
|
||||||
border_width: 2,
|
border_width: 2,
|
||||||
@@ -155,6 +175,31 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Label position */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Label Position</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{LABEL_POSITIONS.map(({ value, label }) => {
|
||||||
|
const isSelected = form.label_position === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('label_position', value)}
|
||||||
|
className="flex items-center justify-center h-8 rounded text-xs transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Colors */}
|
{/* Colors */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Colors</Label>
|
<Label className="text-xs text-muted-foreground">Colors</Label>
|
||||||
@@ -179,6 +224,32 @@ export function GroupRectModal({ open, onClose, onSubmit, onDelete, initial, tit
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Text size */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label className="text-xs text-muted-foreground">Text Size</Label>
|
||||||
|
<div className="grid grid-cols-6 gap-1">
|
||||||
|
{TEXT_SIZES.map(({ value, label }) => {
|
||||||
|
const isSelected = form.text_size === value
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set('text_size', value)}
|
||||||
|
className="flex items-center justify-center h-8 rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? '#00d4ff22' : '#21262d',
|
||||||
|
border: `1px solid ${isSelected ? '#00d4ff88' : '#30363d'}`,
|
||||||
|
color: isSelected ? '#00d4ff' : '#8b949e',
|
||||||
|
fontSize: value,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Border style */}
|
{/* Border style */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
<Label className="text-xs text-muted-foreground">Border Style</Label>
|
||||||
|
|||||||
@@ -124,6 +124,84 @@ describe('GroupRectModal', () => {
|
|||||||
expect(submitted.border_style).toBe('dotted')
|
expect(submitted.border_style).toBe('dotted')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders Label Position section with inside/outside options', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Label Position')).toBeDefined()
|
||||||
|
expect(screen.getByText('Inside')).toBeDefined()
|
||||||
|
expect(screen.getByText('Outside')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults label_position to inside', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.label_position).toBe('inside')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects outside label position on click', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Outside'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.label_position).toBe('outside')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills label_position from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initial={{ label_position: 'outside' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.label_position).toBe('outside')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders Text Size section with 6 options', () => {
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
|
expect(screen.getByText('Text Size')).toBeDefined()
|
||||||
|
expect(screen.getByText('10')).toBeDefined()
|
||||||
|
expect(screen.getByText('20')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('defaults text_size to 12', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.text_size).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects text size on click', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={onSubmit} />)
|
||||||
|
fireEvent.click(screen.getByText('18'))
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.text_size).toBe(18)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pre-fills text_size from initial prop', () => {
|
||||||
|
const onSubmit = vi.fn()
|
||||||
|
render(
|
||||||
|
<GroupRectModal
|
||||||
|
open
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
initial={{ text_size: 16 }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
fireEvent.click(screen.getByText('Add'))
|
||||||
|
const submitted = onSubmit.mock.calls[0][0] as GroupRectFormData
|
||||||
|
expect(submitted.text_size).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
it('renders Border Width section with 5 options', () => {
|
it('renders Border Width section with 5 options', () => {
|
||||||
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
render(<GroupRectModal open onClose={vi.fn()} onSubmit={vi.fn()} />)
|
||||||
expect(screen.getByText('Border Width')).toBeDefined()
|
expect(screen.getByText('Border Width')).toBeDefined()
|
||||||
|
|||||||
Reference in New Issue
Block a user