feat: smart property display — type-aware rendering with display mode override (#83)
* design: add smart property display wireframes Frames: date picker, boolean toggle, status badge, display mode override dropdown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: smart property display — type-aware rendering with display mode override - Add property type auto-detection (date, boolean, status, url, text) based on value content and property name - Date properties (ISO strings) show as friendly format (e.g. "Mar 31, 2026") with native date picker on click - Boolean properties show as toggle buttons - Status properties auto-detected from key name or known status values, rendered as colored badges - Each property gets a display mode override dropdown (visible on hover) to force a specific display type - Display mode overrides persist across sessions via localStorage - Add mock data with date/boolean fields for testing - 36 new tests covering type detection, date formatting, localStorage persistence, and UI rendering Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
524
design/smart-property-display.pen
Normal file
524
design/smart-property-display.pen
Normal file
@@ -0,0 +1,524 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-date",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Smart Properties — Date picker open",
|
||||
"width": 280,
|
||||
"height": 300,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"padding": 12,
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-date-title",
|
||||
"content": "Date Property Display",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1A1A1A"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-date-row",
|
||||
"name": "DateRow",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-date-label",
|
||||
"content": "DEADLINE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2,
|
||||
"textTransform": "uppercase"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-date-value",
|
||||
"content": "Feb 25, 2026",
|
||||
"fontSize": 12,
|
||||
"fill": "#1A1A1A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-date-picker",
|
||||
"name": "DatePickerOpen",
|
||||
"width": "fill_container",
|
||||
"height": 200,
|
||||
"fill": "#F5F5F5",
|
||||
"cornerRadius": 8,
|
||||
"stroke": { "fill": "#E0E0E0", "thickness": 1 },
|
||||
"layout": "vertical",
|
||||
"padding": 12,
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-picker-month",
|
||||
"content": "February 2026",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1A1A1A"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-picker-hint",
|
||||
"content": "Native <input type='date'> opens on click.\nDisplays as 'Feb 25, 2026' when closed.",
|
||||
"fontSize": 11,
|
||||
"fill": "#888888"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-bool",
|
||||
"x": 300,
|
||||
"y": 0,
|
||||
"name": "Smart Properties — Boolean toggle",
|
||||
"width": 280,
|
||||
"height": 200,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"padding": 12,
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-bool-title",
|
||||
"content": "Boolean Property Display",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1A1A1A"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-bool-row-on",
|
||||
"name": "BooleanRowTrue",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-bool-label-on",
|
||||
"content": "PUBLISHED",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-bool-toggle-on",
|
||||
"name": "ToggleOn",
|
||||
"height": 22,
|
||||
"cornerRadius": 4,
|
||||
"fill": "#E8F5E9",
|
||||
"padding": [2, 8],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-bool-val-on",
|
||||
"content": "\u2713 Yes",
|
||||
"fontSize": 12,
|
||||
"fill": "#2E7D32"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-bool-row-off",
|
||||
"name": "BooleanRowFalse",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-bool-label-off",
|
||||
"content": "ARCHIVED",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-bool-toggle-off",
|
||||
"name": "ToggleOff",
|
||||
"height": 22,
|
||||
"cornerRadius": 4,
|
||||
"stroke": { "fill": "#E0E0E0", "thickness": 1 },
|
||||
"padding": [2, 8],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-bool-val-off",
|
||||
"content": "\u2717 No",
|
||||
"fontSize": 12,
|
||||
"fill": "#888888"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-status",
|
||||
"x": 600,
|
||||
"y": 0,
|
||||
"name": "Smart Properties — Status badge",
|
||||
"width": 280,
|
||||
"height": 260,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"padding": 12,
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-status-title",
|
||||
"content": "Status Property Display",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1A1A1A"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-status-row-active",
|
||||
"name": "StatusActive",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-status-label-a",
|
||||
"content": "STATUS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-badge-active",
|
||||
"name": "BadgeActive",
|
||||
"cornerRadius": 16,
|
||||
"fill": "#E8F5E9",
|
||||
"padding": [1, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-badge-active-text",
|
||||
"content": "ACTIVE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"fill": "#2E7D32",
|
||||
"letterSpacing": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-status-row-draft",
|
||||
"name": "StatusDraft",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-status-label-d",
|
||||
"content": "STATUS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-badge-draft",
|
||||
"name": "BadgeDraft",
|
||||
"cornerRadius": 16,
|
||||
"fill": "#FFF8E1",
|
||||
"padding": [1, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-badge-draft-text",
|
||||
"content": "DRAFT",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"fill": "#F9A825",
|
||||
"letterSpacing": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-status-row-done",
|
||||
"name": "StatusDone",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-status-label-dn",
|
||||
"content": "STATUS",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-badge-done",
|
||||
"name": "BadgeDone",
|
||||
"cornerRadius": 16,
|
||||
"fill": "#E3F2FD",
|
||||
"padding": [1, 6],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-badge-done-text",
|
||||
"content": "DONE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1565C0",
|
||||
"letterSpacing": 1.2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-override",
|
||||
"x": 900,
|
||||
"y": 0,
|
||||
"name": "Smart Properties — Display mode override dropdown",
|
||||
"width": 280,
|
||||
"height": 300,
|
||||
"fill": "#FFFFFF",
|
||||
"layout": "vertical",
|
||||
"padding": 12,
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-override-title",
|
||||
"content": "Display Mode Override",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"fill": "#1A1A1A"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-override-row",
|
||||
"name": "PropertyWithOverrideHandle",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"alignItems": "center",
|
||||
"justifyContent": "space-between",
|
||||
"padding": [0, 6],
|
||||
"fill": "#F5F5F5",
|
||||
"cornerRadius": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-override-label",
|
||||
"content": "DEADLINE",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500",
|
||||
"fill": "#888888",
|
||||
"letterSpacing": 1.2
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-override-actions",
|
||||
"gap": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-override-val",
|
||||
"content": "Feb 25, 2026",
|
||||
"fontSize": 12,
|
||||
"fill": "#1A1A1A"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-override-handle",
|
||||
"name": "OverrideHandle",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"cornerRadius": 3,
|
||||
"fill": "#E0E0E0",
|
||||
"alignItems": "center",
|
||||
"justifyContent": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-override-icon",
|
||||
"content": "\u25BE",
|
||||
"fontSize": 10,
|
||||
"fill": "#666666"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-dropdown",
|
||||
"name": "DisplayModeDropdown",
|
||||
"width": 160,
|
||||
"fill": "#FFFFFF",
|
||||
"cornerRadius": 8,
|
||||
"stroke": { "fill": "#E0E0E0", "thickness": 1 },
|
||||
"layout": "vertical",
|
||||
"padding": 4,
|
||||
"gap": 2,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-opt-text",
|
||||
"name": "OptionText",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [4, 8],
|
||||
"cornerRadius": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-opt-text-label",
|
||||
"content": "Text",
|
||||
"fontSize": 12,
|
||||
"fill": "#1A1A1A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-opt-date",
|
||||
"name": "OptionDate",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [4, 8],
|
||||
"cornerRadius": 4,
|
||||
"fill": "#E3F2FD",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-opt-date-label",
|
||||
"content": "\u2713 Date",
|
||||
"fontSize": 12,
|
||||
"fill": "#1565C0"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-opt-bool",
|
||||
"name": "OptionBoolean",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [4, 8],
|
||||
"cornerRadius": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-opt-bool-label",
|
||||
"content": "Boolean",
|
||||
"fontSize": 12,
|
||||
"fill": "#1A1A1A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-opt-status",
|
||||
"name": "OptionStatus",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [4, 8],
|
||||
"cornerRadius": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-opt-status-label",
|
||||
"content": "Status",
|
||||
"fontSize": 12,
|
||||
"fill": "#1A1A1A"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "spd-opt-url",
|
||||
"name": "OptionURL",
|
||||
"width": "fill_container",
|
||||
"height": 28,
|
||||
"padding": [4, 8],
|
||||
"cornerRadius": 4,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "spd-opt-url-label",
|
||||
"content": "URL",
|
||||
"fontSize": 12,
|
||||
"fill": "#1A1A1A"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -3,6 +3,20 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { DynamicPropertiesPanel, containsWikilinks } from './DynamicPropertiesPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Mock localStorage (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
get length() { return Object.keys(store).length },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
// Radix Select needs ResizeObserver and pointer/scroll APIs in JSDOM
|
||||
beforeAll(() => {
|
||||
global.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -10,6 +24,8 @@ beforeAll(() => {
|
||||
Element.prototype.hasPointerCapture = () => false
|
||||
Element.prototype.setPointerCapture = vi.fn()
|
||||
Element.prototype.releasePointerCapture = vi.fn()
|
||||
// showPicker is not available in JSDOM
|
||||
HTMLInputElement.prototype.showPicker = vi.fn()
|
||||
})
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
@@ -658,4 +674,163 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByDisplayValue('https://example.com')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart property display — date', () => {
|
||||
it('renders date property with friendly format', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ deadline: '2026-03-31' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('date-display')).toBeInTheDocument()
|
||||
expect(screen.getByText('Mar 31, 2026')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders hidden date picker input', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ deadline: '2026-03-31' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('date-picker-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onUpdateProperty when date picker value changes', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ deadline: '2026-03-31' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
const dateInput = screen.getByTestId('date-picker-input')
|
||||
fireEvent.change(dateInput, { target: { value: '2026-04-15' } })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('deadline', '2026-04-15')
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart property display — status auto-detection', () => {
|
||||
it('renders status badge for property named Status', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status badge for known status values', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ phase: 'Draft' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart property display — boolean', () => {
|
||||
it('renders boolean toggle for true values', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ published: true }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u2713 Yes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders boolean toggle for false values', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ archived: false }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('boolean-toggle')).toBeInTheDocument()
|
||||
expect(screen.getByText('\u2717 No')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('display mode override', () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
})
|
||||
|
||||
it('renders display mode trigger on property rows', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('display-mode-trigger')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens display mode menu on trigger click', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
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-date')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('display-mode-option-url')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('persists override to localStorage when mode selected', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('display-mode-trigger'))
|
||||
fireEvent.click(screen.getByTestId('display-mode-option-status'))
|
||||
const stored = JSON.parse(localStorageMock.getItem('laputa:display-mode-overrides') ?? '{}')
|
||||
expect(stored.cadence).toBe('status')
|
||||
})
|
||||
|
||||
it('overrides rendering to status badge when status mode selected', () => {
|
||||
localStorageMock.setItem('laputa:display-mode-overrides', JSON.stringify({ cadence: 'status' }))
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ cadence: 'Weekly' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByTestId('status-badge')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import { useMemo, useState, useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
@@ -8,6 +8,16 @@ 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 {
|
||||
type PropertyDisplayMode,
|
||||
getEffectiveDisplayMode,
|
||||
formatDateValue,
|
||||
toISODate,
|
||||
loadDisplayModeOverrides,
|
||||
saveDisplayModeOverride,
|
||||
removeDisplayModeOverride,
|
||||
detectPropertyType,
|
||||
} from '../utils/propertyTypes'
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
@@ -20,6 +30,11 @@ const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Published: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
'In progress': { bg: 'var(--accent-purple-light)', color: 'var(--accent-purple)' },
|
||||
Blocked: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Cancelled: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Pending: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS_STYLE = { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }
|
||||
@@ -68,14 +83,6 @@ function formatFileSize(bytes: number): string {
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function isStatusKey(key: string): boolean {
|
||||
return key === 'Status' || key.includes('Status')
|
||||
}
|
||||
|
||||
function isDateKey(key: string): boolean {
|
||||
return key.includes('Created') || key.includes('Modified') || key.includes('time') || key.includes('Date')
|
||||
}
|
||||
|
||||
function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: {
|
||||
propKey: string; value: FrontmatterValue; isEditing: boolean
|
||||
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
|
||||
@@ -101,6 +108,7 @@ function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: {
|
||||
className="inline-block min-w-0 cursor-pointer truncate transition-opacity hover:opacity-80"
|
||||
style={{ backgroundColor: style.bg, color: style.color, borderRadius: 16, padding: '1px 6px', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' as const }}
|
||||
onClick={() => onStartEdit(propKey)} title={statusStr}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
{statusStr}
|
||||
</span>
|
||||
@@ -112,12 +120,114 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
|
||||
<button
|
||||
className="rounded border border-border bg-transparent px-2 py-0.5 text-xs text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={onToggle}
|
||||
data-testid="boolean-toggle"
|
||||
>
|
||||
{value ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function DateValue({ value, onSave }: {
|
||||
value: string; onSave: (newValue: string) => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const formatted = formatDateValue(value)
|
||||
const isoValue = toISODate(value)
|
||||
|
||||
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value) onSave(e.target.value)
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
inputRef.current?.showPicker()
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 items-center" data-testid="date-display">
|
||||
<span
|
||||
className="min-w-0 cursor-pointer truncate rounded px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
onClick={handleClick}
|
||||
title={value}
|
||||
>
|
||||
{formatted}
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="date"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||
value={isoValue}
|
||||
onChange={handleDateChange}
|
||||
tabIndex={-1}
|
||||
data-testid="date-picker-input"
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'date', label: 'Date' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
]
|
||||
|
||||
function DisplayModeSelector({ propKey, currentMode, autoMode, onSelect }: {
|
||||
propKey: string; currentMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
onSelect: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleSelect = (mode: PropertyDisplayMode) => {
|
||||
if (mode === autoMode) {
|
||||
onSelect(propKey, null)
|
||||
} else {
|
||||
onSelect(propKey, mode)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
className="flex h-4 w-4 items-center justify-center rounded border-none bg-transparent p-0 text-[10px] leading-none text-muted-foreground opacity-0 transition-all hover:bg-muted hover:text-foreground group-hover/prop:opacity-100"
|
||||
onClick={() => setOpen(!open)}
|
||||
title="Change display mode"
|
||||
data-testid="display-mode-trigger"
|
||||
>
|
||||
{'\u25BE'}
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
|
||||
data-testid="display-mode-menu"
|
||||
>
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className="flex w-full items-center gap-2 border-none bg-transparent px-3 py-1.5 text-left text-[12px] text-foreground transition-colors hover:bg-muted"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
data-testid={`display-mode-option-${opt.value}`}
|
||||
>
|
||||
<span className="w-3 text-center text-[10px]">
|
||||
{currentMode === opt.value ? '\u2713' : ''}
|
||||
</span>
|
||||
{opt.label}
|
||||
{opt.value === autoMode && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddPropertyForm({ onAdd, onCancel }: { onAdd: (key: string, value: string) => void; onCancel: () => void }) {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
@@ -206,11 +316,52 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveList, onUpdate, onDelete }: {
|
||||
function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: {
|
||||
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
|
||||
|
||||
if (value === null || value === undefined) return <EditableValue {...editProps} />
|
||||
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
|
||||
switch (displayMode) {
|
||||
case 'status':
|
||||
return <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
case 'date':
|
||||
if (typeof value === 'string') {
|
||||
return <DateValue value={value} onSave={(v) => onSave(propKey, v)} />
|
||||
}
|
||||
return <EditableValue {...editProps} />
|
||||
case 'boolean':
|
||||
if (typeof value === 'boolean') {
|
||||
return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
|
||||
}
|
||||
return <EditableValue {...editProps} />
|
||||
case 'url':
|
||||
if (typeof value === 'string' && isUrlValue(value)) {
|
||||
return <UrlValue {...editProps} />
|
||||
}
|
||||
return <EditableValue {...editProps} />
|
||||
default:
|
||||
if (typeof value === 'boolean') {
|
||||
return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
|
||||
}
|
||||
if (typeof value === 'string' && isUrlValue(value)) {
|
||||
return <UrlValue {...editProps} />
|
||||
}
|
||||
return <EditableValue {...editProps} />
|
||||
}
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
||||
onDisplayModeChange: (key: string, mode: PropertyDisplayMode | null) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="group/prop flex items-center justify-between rounded px-1.5 py-0.5 transition-colors hover:bg-muted" data-testid="editable-property">
|
||||
@@ -219,27 +370,13 @@ function PropertyRow({ propKey, value, editingKey, onStartEdit, onSave, onSaveLi
|
||||
{onDelete && (
|
||||
<button className="border-none bg-transparent p-0 text-sm leading-none text-muted-foreground opacity-0 transition-all hover:text-destructive group-hover/prop:opacity-100" onClick={() => onDelete(propKey)} title="Delete property">×</button>
|
||||
)}
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
</span>
|
||||
<PropertyValueCell propKey={propKey} value={value} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PropertyValueCell({ propKey, value, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: {
|
||||
propKey: string; value: FrontmatterValue; isEditing: boolean
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
|
||||
if (value === null || value === undefined) return <EditableValue {...editProps} />
|
||||
if (isStatusKey(propKey)) return <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
if (Array.isArray(value)) return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
if (isDateKey(propKey)) return <EditableValue {...editProps} />
|
||||
if (typeof value === 'boolean') return <BooleanToggle value={value} onToggle={() => onUpdate?.(propKey, !value)} />
|
||||
if (typeof value === 'string' && isUrlValue(value)) return <UrlValue {...editProps} />
|
||||
return <EditableValue {...editProps} />
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-1.5" data-testid="readonly-property">
|
||||
@@ -305,6 +442,7 @@ export function DynamicPropertiesPanel({
|
||||
}) {
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [showAddDialog, setShowAddDialog] = useState(false)
|
||||
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
|
||||
|
||||
const wordCount = countWords(content ?? '')
|
||||
|
||||
@@ -337,14 +475,34 @@ export function DynamicPropertiesPanel({
|
||||
setShowAddDialog(false)
|
||||
}, [onAddProperty])
|
||||
|
||||
const handleDisplayModeChange = useCallback((key: string, mode: PropertyDisplayMode | null) => {
|
||||
if (mode === null) {
|
||||
removeDisplayModeOverride(key)
|
||||
} else {
|
||||
saveDisplayModeOverride(key, mode)
|
||||
}
|
||||
setDisplayOverrides(loadDisplayModeOverrides())
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Editable properties section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
|
||||
{propertyEntries.map(([key, value]) => (
|
||||
<PropertyRow key={key} propKey={key} value={value} editingKey={editingKey} onStartEdit={setEditingKey} onSave={handleSaveValue} onSaveList={handleSaveList} onUpdate={onUpdateProperty} onDelete={onDeleteProperty} />
|
||||
))}
|
||||
{propertyEntries.map(([key, value]) => {
|
||||
const autoMode = detectPropertyType(key, value)
|
||||
const effectiveMode = getEffectiveDisplayMode(key, value, displayOverrides)
|
||||
return (
|
||||
<PropertyRow
|
||||
key={key} propKey={key} value={value}
|
||||
editingKey={editingKey} displayMode={effectiveMode} autoMode={autoMode}
|
||||
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
onDisplayModeChange={handleDisplayModeChange}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{showAddDialog
|
||||
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} />
|
||||
|
||||
@@ -9,6 +9,9 @@ title: Build Laputa App
|
||||
type: Project
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
deadline: 2026-03-31
|
||||
published: true
|
||||
archived: false
|
||||
tags: [Tauri, React, TypeScript, CodeMirror]
|
||||
tools: [Vite, Vitest, Playwright]
|
||||
url: https://github.com/lucaong/laputa-app
|
||||
|
||||
155
src/utils/propertyTypes.test.ts
Normal file
155
src/utils/propertyTypes.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
detectPropertyType,
|
||||
formatDateValue,
|
||||
toISODate,
|
||||
getEffectiveDisplayMode,
|
||||
loadDisplayModeOverrides,
|
||||
saveDisplayModeOverride,
|
||||
removeDisplayModeOverride,
|
||||
} from './propertyTypes'
|
||||
|
||||
// Mock localStorage (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { store = {} },
|
||||
get length() { return Object.keys(store).length },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
||||
|
||||
describe('detectPropertyType', () => {
|
||||
it('detects boolean from value type', () => {
|
||||
expect(detectPropertyType('archived', true)).toBe('boolean')
|
||||
expect(detectPropertyType('published', false)).toBe('boolean')
|
||||
})
|
||||
|
||||
it('detects status from key name', () => {
|
||||
expect(detectPropertyType('status', 'Active')).toBe('status')
|
||||
expect(detectPropertyType('Status', 'Draft')).toBe('status')
|
||||
})
|
||||
|
||||
it('detects status from known status values', () => {
|
||||
expect(detectPropertyType('phase', 'active')).toBe('status')
|
||||
expect(detectPropertyType('state', 'done')).toBe('status')
|
||||
expect(detectPropertyType('progress', 'in progress')).toBe('status')
|
||||
expect(detectPropertyType('result', 'published')).toBe('status')
|
||||
})
|
||||
|
||||
it('detects date from ISO string', () => {
|
||||
expect(detectPropertyType('deadline', '2026-03-31')).toBe('date')
|
||||
expect(detectPropertyType('due_date', '2026-01-15T10:00')).toBe('date')
|
||||
})
|
||||
|
||||
it('detects date from date-like key names with date value', () => {
|
||||
expect(detectPropertyType('start_date', '2026-06-01')).toBe('date')
|
||||
expect(detectPropertyType('scheduled', '2026-02-25')).toBe('date')
|
||||
})
|
||||
|
||||
it('detects date from ISO string even without date key', () => {
|
||||
expect(detectPropertyType('custom_field', '2026-03-31')).toBe('date')
|
||||
})
|
||||
|
||||
it('returns text for plain strings', () => {
|
||||
expect(detectPropertyType('owner', 'Luca Rossi')).toBe('text')
|
||||
expect(detectPropertyType('cadence', 'Weekly')).toBe('text')
|
||||
})
|
||||
|
||||
it('returns text for null/undefined', () => {
|
||||
expect(detectPropertyType('anything', null)).toBe('text')
|
||||
expect(detectPropertyType('anything', undefined as never)).toBe('text')
|
||||
})
|
||||
|
||||
it('returns text for arrays', () => {
|
||||
expect(detectPropertyType('tags', ['a', 'b'])).toBe('text')
|
||||
})
|
||||
|
||||
it('treats date-keyed fields with non-date values as text', () => {
|
||||
expect(detectPropertyType('deadline', 'active')).toBe('text')
|
||||
})
|
||||
|
||||
it('detects common date format MM/DD/YYYY', () => {
|
||||
expect(detectPropertyType('due', '02/25/2026')).toBe('date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDateValue', () => {
|
||||
it('formats ISO date to friendly format', () => {
|
||||
const result = formatDateValue('2026-03-31')
|
||||
expect(result).toBe('Mar 31, 2026')
|
||||
})
|
||||
|
||||
it('formats ISO datetime', () => {
|
||||
const result = formatDateValue('2026-02-25T10:00')
|
||||
expect(result).toBe('Feb 25, 2026')
|
||||
})
|
||||
|
||||
it('formats MM/DD/YYYY', () => {
|
||||
const result = formatDateValue('02/25/2026')
|
||||
expect(result).toBe('Feb 25, 2026')
|
||||
})
|
||||
|
||||
it('returns original value for non-date strings', () => {
|
||||
expect(formatDateValue('not a date')).toBe('not a date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toISODate', () => {
|
||||
it('converts ISO date to YYYY-MM-DD', () => {
|
||||
expect(toISODate('2026-03-31')).toBe('2026-03-31')
|
||||
})
|
||||
|
||||
it('extracts date from ISO datetime', () => {
|
||||
expect(toISODate('2026-02-25T10:00:00')).toBe('2026-02-25')
|
||||
})
|
||||
|
||||
it('returns original value for non-date strings', () => {
|
||||
expect(toISODate('not a date')).toBe('not a date')
|
||||
})
|
||||
})
|
||||
|
||||
describe('display mode overrides (localStorage)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns empty object when no overrides saved', () => {
|
||||
expect(loadDisplayModeOverrides()).toEqual({})
|
||||
})
|
||||
|
||||
it('saves and loads an override', () => {
|
||||
saveDisplayModeOverride('deadline', 'date')
|
||||
const overrides = loadDisplayModeOverrides()
|
||||
expect(overrides.deadline).toBe('date')
|
||||
})
|
||||
|
||||
it('removes an override', () => {
|
||||
saveDisplayModeOverride('deadline', 'date')
|
||||
removeDisplayModeOverride('deadline')
|
||||
expect(loadDisplayModeOverrides()).toEqual({})
|
||||
})
|
||||
|
||||
it('handles corrupted localStorage gracefully', () => {
|
||||
localStorage.setItem('laputa:display-mode-overrides', 'not valid json')
|
||||
expect(loadDisplayModeOverrides()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEffectiveDisplayMode', () => {
|
||||
it('uses auto-detected mode when no override', () => {
|
||||
expect(getEffectiveDisplayMode('status', 'Active', {})).toBe('status')
|
||||
})
|
||||
|
||||
it('uses override when present', () => {
|
||||
expect(getEffectiveDisplayMode('status', 'Active', { status: 'text' })).toBe('text')
|
||||
})
|
||||
|
||||
it('prefers override over auto-detection', () => {
|
||||
expect(getEffectiveDisplayMode('deadline', '2026-03-31', { deadline: 'text' })).toBe('text')
|
||||
})
|
||||
})
|
||||
97
src/utils/propertyTypes.ts
Normal file
97
src/utils/propertyTypes.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url'
|
||||
|
||||
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}$/
|
||||
|
||||
const STATUS_VALUES = new Set([
|
||||
'active', 'done', 'paused', 'archived', 'dropped',
|
||||
'open', 'closed', 'not started', 'draft', 'mixed',
|
||||
'published', 'in progress', 'blocked', 'cancelled', 'pending',
|
||||
])
|
||||
|
||||
const STATUS_KEY_PATTERNS = ['status']
|
||||
const DATE_KEY_PATTERNS = ['date', 'deadline', 'due', 'start', 'end', 'scheduled']
|
||||
|
||||
function keyMatchesPatterns(key: string, patterns: string[]): boolean {
|
||||
const lower = key.toLowerCase()
|
||||
return patterns.some(p => lower === p || lower.includes(p))
|
||||
}
|
||||
|
||||
function isDateString(value: string): boolean {
|
||||
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
|
||||
}
|
||||
|
||||
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (value === null || value === undefined) return 'text'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (Array.isArray(value)) return 'text'
|
||||
|
||||
const strValue = String(value)
|
||||
|
||||
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
|
||||
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
|
||||
if (keyMatchesPatterns(key, DATE_KEY_PATTERNS) && isDateString(strValue)) return 'date'
|
||||
if (isDateString(strValue)) return 'date'
|
||||
|
||||
return 'text'
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'laputa:display-mode-overrides'
|
||||
|
||||
export function loadDisplayModeOverrides(): Record<string, PropertyDisplayMode> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveDisplayModeOverride(propertyName: string, mode: PropertyDisplayMode): void {
|
||||
const overrides = loadDisplayModeOverrides()
|
||||
overrides[propertyName] = mode
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
|
||||
}
|
||||
|
||||
export function removeDisplayModeOverride(propertyName: string): void {
|
||||
const overrides = loadDisplayModeOverrides()
|
||||
delete overrides[propertyName]
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
|
||||
}
|
||||
|
||||
export function getEffectiveDisplayMode(
|
||||
key: string,
|
||||
value: FrontmatterValue,
|
||||
overrides: Record<string, PropertyDisplayMode>,
|
||||
): PropertyDisplayMode {
|
||||
return overrides[key] ?? detectPropertyType(key, value)
|
||||
}
|
||||
|
||||
export function formatDateValue(value: string): string {
|
||||
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 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
|
||||
}
|
||||
|
||||
export function toISODate(value: string): string {
|
||||
const isoMatch = value.match(ISO_DATE_RE)
|
||||
if (isoMatch) {
|
||||
const d = new Date(isoMatch[0])
|
||||
if (!isNaN(d.getTime())) return d.toISOString().split('T')[0]
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user