feat: add number property type
This commit is contained in:
@@ -496,7 +496,8 @@ The app uses a single light theme — the vault-based theming system was removed
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
|
||||
|
||||
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
|
||||
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
|
||||
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
|
||||
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
getEffectiveDisplayMode,
|
||||
loadDisplayModeOverrides,
|
||||
saveDisplayModeOverride,
|
||||
removeDisplayModeOverride,
|
||||
@@ -22,6 +23,14 @@ function coerceValue(raw: string): FrontmatterValue {
|
||||
return raw
|
||||
}
|
||||
|
||||
function coerceNumberValue(raw: string): FrontmatterValue {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed === '') return ''
|
||||
|
||||
const parsed = Number(trimmed)
|
||||
return Number.isFinite(parsed) ? parsed : raw
|
||||
}
|
||||
|
||||
function parseNewValue(rawValue: string): FrontmatterValue {
|
||||
if (!rawValue.includes(',')) return rawValue.trim() || ''
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
@@ -125,6 +134,7 @@ function isHiddenPropertyKey(key: string): boolean {
|
||||
|
||||
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
|
||||
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
|
||||
if (mode === 'number') return coerceNumberValue(rawValue)
|
||||
if (mode === 'tags') {
|
||||
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
|
||||
return items
|
||||
@@ -137,6 +147,37 @@ function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
|
||||
else saveDisplayModeOverride(key, mode)
|
||||
}
|
||||
|
||||
function saveScalarProperty({
|
||||
key,
|
||||
newValue,
|
||||
frontmatter,
|
||||
displayOverrides,
|
||||
onUpdateProperty,
|
||||
onDeleteProperty,
|
||||
}: {
|
||||
key: string
|
||||
newValue: string
|
||||
frontmatter: ParsedFrontmatter
|
||||
displayOverrides: Record<string, PropertyDisplayMode>
|
||||
onUpdateProperty: (key: string, value: FrontmatterValue) => void
|
||||
onDeleteProperty?: (key: string) => void
|
||||
}) {
|
||||
const currentValue = frontmatter[key] ?? null
|
||||
const displayMode = getEffectiveDisplayMode(key, currentValue, displayOverrides)
|
||||
if (displayMode !== 'number') {
|
||||
onUpdateProperty(key, coerceValue(newValue))
|
||||
return
|
||||
}
|
||||
|
||||
const trimmed = newValue.trim()
|
||||
if (trimmed === '') {
|
||||
onDeleteProperty?.(key)
|
||||
return
|
||||
}
|
||||
|
||||
onUpdateProperty(key, coerceNumberValue(newValue))
|
||||
}
|
||||
|
||||
export interface PropertyPanelDeps {
|
||||
entries: VaultEntry[] | undefined
|
||||
entryIsA: string | null
|
||||
@@ -159,8 +200,9 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
|
||||
|
||||
const handleSaveValue = useCallback((key: string, newValue: string) => {
|
||||
setEditingKey(null)
|
||||
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
|
||||
}, [onUpdateProperty])
|
||||
if (!onUpdateProperty) return
|
||||
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
|
||||
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
|
||||
|
||||
const handleSaveList = useCallback((key: string, newItems: string[]) => {
|
||||
if (!onUpdateProperty) return
|
||||
|
||||
@@ -29,6 +29,11 @@ describe('detectPropertyType', () => {
|
||||
expect(detectPropertyType('published', false)).toBe('boolean')
|
||||
})
|
||||
|
||||
it('detects number from value type', () => {
|
||||
expect(detectPropertyType('estimate', 3)).toBe('number')
|
||||
expect(detectPropertyType('ratio', -1.5)).toBe('number')
|
||||
})
|
||||
|
||||
it('detects status from key name', () => {
|
||||
expect(detectPropertyType('status', 'Active')).toBe('status')
|
||||
expect(detectPropertyType('Status', 'Draft')).toBe('status')
|
||||
|
||||
@@ -2,10 +2,10 @@ import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { getAppStorageItem } from '../constants/appStorage'
|
||||
import { isValidCssColor, isColorKeyName } from './colorUtils'
|
||||
import { updateVaultConfigField } from './vaultConfigStore'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from 'lucide-react'
|
||||
import { canonicalSystemMetadataKey } from './systemMetadata'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
export type PropertyDisplayMode = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
|
||||
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
|
||||
@@ -33,17 +33,35 @@ function isDateString(value: string): boolean {
|
||||
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
|
||||
}
|
||||
|
||||
function isStatusKey(key: string): boolean {
|
||||
return keyMatchesPatterns(key, STATUS_KEY_PATTERNS)
|
||||
}
|
||||
|
||||
function isDateKey(key: string): boolean {
|
||||
return keyMatchesPatterns(key, DATE_KEY_PATTERNS)
|
||||
}
|
||||
|
||||
function isStatusString(key: string, value: string): boolean {
|
||||
if (isStatusKey(key)) return true
|
||||
if (isDateKey(key)) return false
|
||||
return STATUS_VALUES.has(value.toLowerCase())
|
||||
}
|
||||
|
||||
function isColorString(key: string, value: string): boolean {
|
||||
return isValidCssColor(value) && (value.startsWith('#') || isColorKeyName(key))
|
||||
}
|
||||
|
||||
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
|
||||
if (isIconKey(key)) return 'text'
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (isStatusString(key, strValue)) return 'status'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
|
||||
if (isColorString(key, strValue)) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (value === null || value === undefined) return 'text'
|
||||
if (typeof value === 'number') return 'number'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (isIconKey(key)) return 'text'
|
||||
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
|
||||
@@ -95,22 +113,25 @@ export function getEffectiveDisplayMode(
|
||||
return overrides[key] ?? detectPropertyType(key, value)
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
function resolveDateFromValue(value: string): Date | null {
|
||||
const isoMatch = value.match(ISO_DATE_RE)
|
||||
if (isoMatch) {
|
||||
const d = new Date(isoMatch[0])
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
const date = new Date(isoMatch[0])
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/)
|
||||
if (parts) {
|
||||
const d = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
|
||||
if (!isNaN(d.getTime())) {
|
||||
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
}
|
||||
return value
|
||||
if (!parts) return null
|
||||
|
||||
const date = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
const date = resolveDateFromValue(value)
|
||||
return date
|
||||
? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
: value
|
||||
}
|
||||
|
||||
export function toISODate(value: string): string {
|
||||
@@ -123,11 +144,12 @@ export function toISODate(value: string): string {
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
text: Type, number: Hash, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
|
||||
58
tests/smoke/number-property.spec.ts
Normal file
58
tests/smoke/number-property.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
function alphaProjectPath(vaultPath: string): string {
|
||||
return path.join(vaultPath, 'project', 'alpha-project.md')
|
||||
}
|
||||
|
||||
test.describe('Number property editing', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
await openFixtureVaultDesktopHarness(page, tempVaultDir)
|
||||
await page.setViewportSize({ width: 1600, height: 900 })
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('properties panel saves number values across raw-mode switches and note reloads @smoke', async ({ page }) => {
|
||||
const notePath = alphaProjectPath(tempVaultDir)
|
||||
const noteList = page.getByTestId('note-list-container')
|
||||
|
||||
await noteList.getByText('Alpha Project', { exact: true }).click()
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
await expect(page.getByTestId('add-property-row')).toBeVisible()
|
||||
|
||||
await page.getByTestId('add-property-row').focus()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByTestId('add-property-form')).toBeVisible()
|
||||
|
||||
await page.keyboard.type('Estimate')
|
||||
await page.getByTestId('add-property-type-trigger').click()
|
||||
await page.getByRole('option', { name: 'Number', exact: true }).click()
|
||||
const numberInput = page.getByTestId('add-property-number-input')
|
||||
await expect(numberInput).toBeVisible()
|
||||
await numberInput.focus()
|
||||
await page.keyboard.type(' -12.5 ')
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect.poll(() => fs.readFileSync(notePath, 'utf8')).toContain('Estimate: -12.5')
|
||||
await expect(page.getByTestId('number-display')).toContainText('-12.5')
|
||||
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
const rawEditor = page.locator('.cm-content')
|
||||
await expect(rawEditor).toBeVisible()
|
||||
await expect(rawEditor).toContainText('Estimate: -12.5')
|
||||
await page.keyboard.press('Control+Backslash')
|
||||
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await noteList.getByText('Alpha Project', { exact: true }).click()
|
||||
await page.keyboard.press('Control+Shift+i')
|
||||
await expect(page.getByTestId('number-display')).toContainText('-12.5')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user