diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 9c36da13..e2c34bba 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/src/components/AddPropertyForm.tsx b/src/components/AddPropertyForm.tsx index 67d9a1ef..6d2f38bb 100644 --- a/src/components/AddPropertyForm.tsx +++ b/src/components/AddPropertyForm.tsx @@ -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 ( + 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 case 'boolean': return case 'date': return case 'status': return case 'tags': return ( - onChange(e.target.value)} onKeyDown={onKeyDown} /> ) default: return ( - 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('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 ( - setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus @@ -162,7 +196,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: { onAdd(newKey, newValue, displayMode)} - disabled={!newKey.trim()} title="Add property" + disabled={!canSubmit} title="Add property" data-testid="add-property-confirm" > diff --git a/src/components/DynamicPropertiesPanel.test.tsx b/src/components/DynamicPropertiesPanel.test.tsx index 87bccc7f..ce4180c2 100644 --- a/src/components/DynamicPropertiesPanel.test.tsx +++ b/src/components/DynamicPropertiesPanel.test.tsx @@ -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' }) diff --git a/src/components/PropertyValueCells.tsx b/src/components/PropertyValueCells.tsx index 330d144a..858a63a0 100644 --- a/src/components/PropertyValueCells.tsx +++ b/src/components/PropertyValueCells.tsx @@ -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) => { + if (event.key === 'Enter') { + commitValue() + return + } + + if (event.key === 'Escape') { + restoreValue() + onCancel() + } + }, [commitValue, onCancel, restoreValue]) + + if (isEditing) { + return ( + setEditValue(event.target.value)} + onKeyDown={handleKeyDown} + onBlur={commitValue} + autoFocus + data-testid="number-input" + /> + ) + } + + return ( + + {value || '\u2014'} + + ) +} + 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 - case 'tags': - return - case 'date': - return ( - onSave(propKey, v)} - autoOpen={isEditing} - onCancel={() => onStartEdit(null)} - /> - ) - case 'boolean': { - const boolVal = toBooleanValue(value) - return onUpdate?.(propKey, !boolVal)} /> - } - case 'url': - return - case 'color': - return - default: - return - } +} + +const SCALAR_DISPLAY_RENDERERS: Partial ReactNode>> = { + status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => ( + + ), + tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => ( + + ), + date: ({ propKey, value, isEditing, onSave, onStartEdit }) => ( + onSave(propKey, nextValue)} + autoOpen={isEditing} + onCancel={() => onStartEdit(null)} + /> + ), + number: ({ editProps }) => , + boolean: ({ propKey, value, onUpdate }) => { + const boolVal = toBooleanValue(value) + return onUpdate?.(propKey, !boolVal)} /> + }, + url: ({ editProps }) => , + color: ({ editProps }) => , +} + +function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) { + const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode] + return renderer ? renderer(props) : } function ScalarValueCell(props: SmartCellProps) { diff --git a/src/hooks/usePropertyPanelState.ts b/src/hooks/usePropertyPanelState.ts index 6c04d75f..b775f166 100644 --- a/src/hooks/usePropertyPanelState.ts +++ b/src/hooks/usePropertyPanelState.ts @@ -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 + 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 diff --git a/src/utils/propertyTypes.test.ts b/src/utils/propertyTypes.test.ts index 42786558..1f37dced 100644 --- a/src/utils/propertyTypes.test.ts +++ b/src/utils/propertyTypes.test.ts @@ -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') diff --git a/src/utils/propertyTypes.ts b/src/utils/propertyTypes.ts index 26e2948f..9b090a67 100644 --- a/src/utils/propertyTypes.ts +++ b/src/utils/propertyTypes.ts @@ -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 = { - 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' }, diff --git a/tests/smoke/number-property.spec.ts b/tests/smoke/number-property.spec.ts new file mode 100644 index 00000000..b5cafa53 --- /dev/null +++ b/tests/smoke/number-property.spec.ts @@ -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') + }) +})