feat: add number property type

This commit is contained in:
lucaronin
2026-04-18 19:40:00 +02:00
parent 8c0a2e7ec8
commit 7deeb49751
8 changed files with 318 additions and 68 deletions

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Check, X } from 'lucide-react'
@@ -27,6 +28,17 @@ function dateToISO(day: Date): string {
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
function isValidNumberValue(value: string): boolean {
const trimmed = value.trim()
if (trimmed === '') return false
return Number.isFinite(Number(trimmed))
}
function canSubmitProperty({ key, value, displayMode }: { key: string; value: string; displayMode: PropertyDisplayMode }): boolean {
if (!key.trim()) return false
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
@@ -91,21 +103,42 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
)
}
function AddNumberInput({ value, onChange, onKeyDown }: {
value: string
onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void
}) {
return (
<Input
className={`${ADD_INPUT_CLASS} font-mono tabular-nums`}
type="text"
inputMode="decimal"
placeholder="0"
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
data-testid="add-property-number-input"
/>
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
}) {
switch (displayMode) {
case 'number':
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
default: return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
@@ -119,6 +152,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
const canSubmit = canSubmitProperty({ key: newKey, value: newValue, displayMode })
const handleModeChange = (mode: PropertyDisplayMode) => {
setDisplayMode(mode)
@@ -127,13 +161,13 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
if (e.key === 'Enter' && canSubmit) onAdd(newKey, newValue, displayMode)
else if (e.key === 'Escape') onCancel()
}
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<input
<Input
className="h-[26px] w-20 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
@@ -162,7 +196,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!newKey.trim()} title="Add property"
disabled={!canSubmit} title="Add property"
data-testid="add-property-confirm"
>
<Check className="size-3.5" />

View File

@@ -627,6 +627,14 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('smart property display — number', () => {
it('renders numeric properties with the number display affordance', () => {
renderEditablePanel({ estimate: -3.25 })
expect(screen.getByTestId('number-display')).toBeInTheDocument()
expect(screen.getByText('-3.25')).toBeInTheDocument()
})
})
describe('smart property display — status auto-detection', () => {
it('renders status badge for property named Status', () => {
renderEditablePanel({ Status: 'Active' })
@@ -741,6 +749,7 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByTestId('display-mode-trigger'))
expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-number')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-date')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument()
@@ -798,6 +807,13 @@ describe('DynamicPropertiesPanel', () => {
})
describe('type-aware add property form', () => {
it('shows number input when number type selected', () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
expect(screen.getByTestId('add-property-number-input')).toBeInTheDocument()
})
it('shows boolean toggle when boolean type selected', () => {
openAddPropertyForm()
// Switch type to boolean
@@ -829,6 +845,16 @@ describe('DynamicPropertiesPanel', () => {
expect(onAddProperty).toHaveBeenCalledWith('published', true)
})
it('stores trimmed decimal values as numbers when adding number properties', () => {
const { keyInput } = openAddPropertyForm()
fireEvent.change(keyInput, { target: { value: 'estimate' } })
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
fireEvent.change(screen.getByTestId('add-property-number-input'), { target: { value: ' -12.5 ' } })
fireEvent.click(screen.getByTestId('add-property-confirm'))
expect(onAddProperty).toHaveBeenCalledWith('estimate', -12.5)
})
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUpRight } from '@phosphor-icons/react'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { XIcon } from 'lucide-react'
import { isValidCssColor } from '../utils/colorUtils'
@@ -154,6 +155,77 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
)
}
function NumberValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: ScalarEditProps) {
const [editValue, setEditValue] = useState(value)
const restoreValue = useCallback(() => {
setEditValue(value)
}, [value])
const commitValue = useCallback(() => {
const trimmed = editValue.trim()
if (trimmed === '') {
onSave('')
return
}
const parsed = Number(trimmed)
if (Number.isFinite(parsed)) {
onSave(trimmed)
return
}
restoreValue()
onCancel()
}, [editValue, onCancel, onSave, restoreValue])
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
commitValue()
return
}
if (event.key === 'Escape') {
restoreValue()
onCancel()
}
}, [commitValue, onCancel, restoreValue])
if (isEditing) {
return (
<Input
className="h-7 w-full border-ring bg-muted px-2 py-1 text-left font-mono text-[12px] tabular-nums"
type="text"
inputMode="decimal"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitValue}
autoFocus
data-testid="number-input"
/>
)
}
return (
<button
type="button"
className="inline-flex h-6 w-full min-w-0 items-center justify-start overflow-hidden rounded-md border-none bg-muted/60 px-2 text-left font-mono text-[12px] tabular-nums text-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="number-display"
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</button>
)
}
function DateValue({ value, onSave, autoOpen = false, onCancel }: {
value: string
onSave: (newValue: string) => void
@@ -352,48 +424,38 @@ function createScalarEditProps({
}
}
function renderScalarDisplayMode({
propKey,
value,
isEditing,
resolvedMode,
vaultStatuses,
vaultTags,
onSave,
onSaveList,
onStartEdit,
onUpdate,
editProps,
}: SmartCellProps & {
resolvedMode: PropertyDisplayMode
type ScalarRendererProps = SmartCellProps & {
editProps: ScalarEditProps
}) {
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'tags':
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
case 'date':
return (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(v) => onSave(propKey, v)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
)
case 'boolean': {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}
}
const SCALAR_DISPLAY_RENDERERS: Partial<Record<PropertyDisplayMode, (props: ScalarRendererProps) => ReactNode>> = {
status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => (
<StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
),
tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => (
<TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
),
date: ({ propKey, value, isEditing, onSave, onStartEdit }) => (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(nextValue) => onSave(propKey, nextValue)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
),
number: ({ editProps }) => <NumberValue {...editProps} />,
boolean: ({ propKey, value, onUpdate }) => {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
},
url: ({ editProps }) => <UrlValue {...editProps} />,
color: ({ editProps }) => <ColorEditableValue {...editProps} />,
}
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode]
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
}
function ScalarValueCell(props: SmartCellProps) {