feat: redesign Add Property as inline horizontal form with type selector (#100)

Replace the vertical grey popup with an inline horizontal form that matches
existing property row styling. Fields: [name] [type dropdown] [value] [✓] [×].
Type dropdown uses icon-left design consistent with the app's Select component.
Includes design file with empty and filled state frames.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Luca Rossi
2026-02-26 14:31:31 +01:00
committed by GitHub
parent 72295dadae
commit f842f2b580
3 changed files with 505 additions and 12 deletions

View File

@@ -0,0 +1,448 @@
{
"children": [
{
"type": "frame",
"id": "apEmpty",
"x": 0,
"y": 0,
"name": "Add Property — Inline Form (Empty State)",
"theme": { "Mode": "Light" },
"width": 320,
"height": 180,
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"padding": 12,
"children": [
{
"type": "text",
"id": "apEmptyDesc",
"name": "description",
"fill": "$--muted-foreground",
"content": "Inline add-property form: replaces the old grey popup. Fields are horizontal, matching existing property row layout. Shows after clicking + Add property.",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "apEmptyExisting1",
"name": "Existing Property Row — Status",
"width": "fill_container",
"cornerRadius": 4,
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "apEmptyLabel1",
"fill": "$--muted-foreground",
"content": "STATUS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2
},
{
"type": "frame",
"id": "apEmptyBadge1",
"fill": "$--accent-green-light",
"cornerRadius": 16,
"padding": [1, 6],
"children": [
{
"type": "text",
"id": "apEmptyBadgeText1",
"fill": "$--accent-green",
"content": "ACTIVE",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "600",
"letterSpacing": 1.2
}
]
}
]
},
{
"type": "frame",
"id": "apEmptySep",
"name": "Separator",
"width": "fill_container",
"height": 1,
"fill": "$--border"
},
{
"type": "frame",
"id": "apEmptyForm",
"name": "Inline Add Property Form — Empty",
"width": "fill_container",
"gap": 6,
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "apEmptyNameInput",
"name": "Name Input",
"width": 90,
"height": 26,
"fill": "$--muted",
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "apEmptyNamePlaceholder",
"fill": "$--muted-foreground",
"content": "Name",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "apEmptyTypeSelect",
"name": "Type Selector",
"width": 72,
"height": 26,
"fill": "$--muted",
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"padding": [4, 6],
"gap": 4,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "apEmptyTypeIcon",
"width": 12,
"height": 12,
"iconFontName": "type",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "apEmptyTypePlaceholder",
"fill": "$--muted-foreground",
"content": "Text",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "icon_font",
"id": "apEmptyTypeChevron",
"width": 10,
"height": 10,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "apEmptyValueInput",
"name": "Value Input",
"width": "fill_container",
"height": 26,
"fill": "$--muted",
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "apEmptyValuePlaceholder",
"fill": "$--muted-foreground",
"content": "Value",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "apEmptyConfirmBtn",
"name": "Confirm Button",
"width": 24,
"height": 24,
"fill": "$--primary",
"cornerRadius": 4,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "apEmptyConfirmIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#ffffff"
}
]
},
{
"type": "frame",
"id": "apEmptyCancelBtn",
"name": "Cancel Button",
"width": 24,
"height": 24,
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "apEmptyCancelIcon",
"width": 14,
"height": 14,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
}
]
}
]
},
{
"type": "frame",
"id": "apFilled",
"x": 400,
"y": 0,
"name": "Add Property — Inline Form (Filled State)",
"theme": { "Mode": "Light" },
"width": 320,
"height": 180,
"fill": "$--background",
"layout": "vertical",
"gap": 8,
"padding": 12,
"children": [
{
"type": "text",
"id": "apFilledDesc",
"name": "description",
"fill": "$--muted-foreground",
"content": "Filled state: user has typed a property name, selected a type (Date), and entered a value. Confirm button is enabled (blue). The form looks like the property row it will create.",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "apFilledExisting1",
"name": "Existing Property Row — Status",
"width": "fill_container",
"cornerRadius": 4,
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "apFilledLabel1",
"fill": "$--muted-foreground",
"content": "STATUS",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "500",
"letterSpacing": 1.2
},
{
"type": "frame",
"id": "apFilledBadge1",
"fill": "$--accent-green-light",
"cornerRadius": 16,
"padding": [1, 6],
"children": [
{
"type": "text",
"id": "apFilledBadgeText1",
"fill": "$--accent-green",
"content": "ACTIVE",
"fontFamily": "IBM Plex Mono",
"fontSize": 10,
"fontWeight": "600",
"letterSpacing": 1.2
}
]
}
]
},
{
"type": "frame",
"id": "apFilledSep",
"name": "Separator",
"width": "fill_container",
"height": 1,
"fill": "$--border"
},
{
"type": "frame",
"id": "apFilledForm",
"name": "Inline Add Property Form — Filled",
"width": "fill_container",
"gap": 6,
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "apFilledNameInput",
"name": "Name Input (filled)",
"width": 90,
"height": 26,
"fill": "$--muted",
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--primary" },
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "apFilledNameText",
"fill": "$--foreground",
"content": "deadline",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "apFilledTypeSelect",
"name": "Type Selector (Date selected)",
"width": 72,
"height": 26,
"fill": "$--muted",
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"padding": [4, 6],
"gap": 4,
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "apFilledTypeIcon",
"width": 12,
"height": 12,
"iconFontName": "calendar",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
},
{
"type": "text",
"id": "apFilledTypeText",
"fill": "$--foreground",
"content": "Date",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
},
{
"type": "icon_font",
"id": "apFilledTypeChevron",
"width": 10,
"height": 10,
"iconFontName": "chevron-down",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "apFilledValueInput",
"name": "Value Input (filled)",
"width": "fill_container",
"height": 26,
"fill": "$--muted",
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--primary" },
"padding": [4, 6],
"alignItems": "center",
"children": [
{
"type": "text",
"id": "apFilledValueText",
"fill": "$--foreground",
"content": "2026-03-15",
"fontFamily": "Inter",
"fontSize": 12,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "apFilledConfirmBtn",
"name": "Confirm Button (enabled)",
"width": 24,
"height": 24,
"fill": "$--primary",
"cornerRadius": 4,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "apFilledConfirmIcon",
"width": 14,
"height": 14,
"iconFontName": "check",
"iconFontFamily": "lucide",
"fill": "#ffffff"
}
]
},
{
"type": "frame",
"id": "apFilledCancelBtn",
"name": "Cancel Button",
"width": 24,
"height": 24,
"cornerRadius": 4,
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "icon_font",
"id": "apFilledCancelIcon",
"width": 14,
"height": 14,
"iconFontName": "x",
"iconFontFamily": "lucide",
"fill": "$--muted-foreground"
}
]
}
]
}
]
}
],
"variables": {
"--background": { "type": "color", "value": [{ "value": "#ffffff" }] },
"--foreground": { "type": "color", "value": [{ "value": "#37352F" }] },
"--muted": { "type": "color", "value": [{ "value": "#F0F0EF" }] },
"--muted-foreground": { "type": "color", "value": [{ "value": "#9B9A97" }] },
"--border": { "type": "color", "value": [{ "value": "#E9E9E7" }] },
"--primary": { "type": "color", "value": [{ "value": "#155DFF" }] },
"--accent-green": { "type": "color", "value": [{ "value": "#38A169" }] },
"--accent-green-light": { "type": "color", "value": [{ "value": "rgba(56, 161, 105, 0.1)" }] }
}
}

View File

@@ -224,6 +224,7 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByText('+ Add property'))
expect(screen.getByPlaceholderText('Property name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Value')).toBeInTheDocument()
expect(screen.getByTestId('add-property-type-trigger')).toBeInTheDocument()
})
it('adds property via the add form', () => {
@@ -240,7 +241,7 @@ describe('DynamicPropertiesPanel', () => {
const valueInput = screen.getByPlaceholderText('Value')
fireEvent.change(keyInput, { target: { value: 'priority' } })
fireEvent.change(valueInput, { target: { value: 'high' } })
fireEvent.click(screen.getByText('Add'))
fireEvent.click(screen.getByTestId('add-property-confirm'))
expect(onAddProperty).toHaveBeenCalledWith('priority', 'high')
})
@@ -479,7 +480,7 @@ describe('DynamicPropertiesPanel', () => {
/>
)
fireEvent.click(screen.getByText('+ Add property'))
fireEvent.click(screen.getByText('Cancel'))
fireEvent.click(screen.getByTestId('add-property-cancel'))
expect(screen.getByText('+ Add property')).toBeInTheDocument()
})

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { countWords } from '../utils/wikilinks'
import { Check, X, Type, Calendar, ToggleLeft, Circle, Link } from 'lucide-react'
import {
type PropertyDisplayMode,
getEffectiveDisplayMode,
@@ -203,29 +204,68 @@ function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
)
}
function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: string) => void; onCancel: () => void }) {
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: Calendar, boolean: ToggleLeft, status: Circle, url: Link,
}
function AddPropertyForm({ onAdd, onCancel }: {
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
}) {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue)
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
else if (e.key === 'Escape') onCancel()
}
const Icon = DISPLAY_MODE_ICONS[displayMode]
return (
<div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-muted p-3">
<div className="mt-1 flex items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
className="h-[26px] w-[90px] shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
/>
<Select value={displayMode} onValueChange={(v) => setDisplayMode(v as PropertyDisplayMode)}>
<SelectTrigger
size="sm"
className="h-[26px] w-[82px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
style={{ fontSize: 12, borderRadius: 4 }}
data-testid="add-property-type-trigger"
>
<Icon className="size-3 shrink-0 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{DISPLAY_MODE_OPTIONS.map(opt => {
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
return (
<SelectItem key={opt.value} value={opt.value}>
<OptIcon className="size-3.5 text-muted-foreground" />
{opt.label}
</SelectItem>
)
})}
</SelectContent>
</Select>
<input
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
className="h-[26px] min-w-0 flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Value" value={newValue}
onChange={(e) => setNewValue(e.target.value)} onKeyDown={handleKeyDown}
/>
<div className="flex justify-end gap-2">
<Button size="xs" onClick={() => onAdd(newKey, newValue)} disabled={!newKey.trim()}>Add</Button>
<Button size="xs" variant="outline" onClick={onCancel}>Cancel</Button>
</div>
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!newKey.trim()} title="Add property"
data-testid="add-property-confirm"
>
<Check className="size-3.5" />
</Button>
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
<X className="size-3.5" />
</Button>
</div>
)
}
@@ -454,9 +494,13 @@ export function DynamicPropertiesPanel({
reconcileListUpdate(newItems, onUpdateProperty, onDeleteProperty, key)
}, [onUpdateProperty, onDeleteProperty])
const handleAdd = useCallback((rawKey: string, rawValue: string) => {
const handleAdd = useCallback((rawKey: string, rawValue: string, mode: PropertyDisplayMode) => {
if (!rawKey.trim() || !onAddProperty) return
onAddProperty(rawKey.trim(), parseNewValue(rawValue))
if (mode !== 'text') {
saveDisplayModeOverride(rawKey.trim(), mode)
setDisplayOverrides(loadDisplayModeOverrides())
}
setShowAddDialog(false)
}, [onAddProperty])