refactor: split DynamicPropertiesPanel into focused sub-components — reduce 699-line catch-all (#197)
Extract PropertyValueCells, TypeSelector, and AddPropertyForm into dedicated files. Move shared display-mode constants to utils/propertyTypes to satisfy react-refresh lint rule. All tests pass, no behavior change. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
175
src/components/AddPropertyForm.tsx
Normal file
175
src/components/AddPropertyForm.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
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'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
formatDateValue,
|
||||
toISODate,
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { DISPLAY_MODE_OPTIONS, DISPLAY_MODE_ICONS } from '../utils/propertyTypes'
|
||||
|
||||
function parseDateValue(value: string): Date | undefined {
|
||||
const iso = toISODate(value)
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
|
||||
function dateToISO(day: Date): string {
|
||||
const yyyy = day.getFullYear()
|
||||
const mm = String(day.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(day.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
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 AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const boolVal = value.toLowerCase() === 'true'
|
||||
return (
|
||||
<button
|
||||
className="h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
|
||||
onClick={() => onChange(boolVal ? 'false' : 'true')}
|
||||
data-testid="add-property-boolean-toggle"
|
||||
>
|
||||
{boolVal ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selectedDate = value ? parseDateValue(value) : undefined
|
||||
const formatted = value ? formatDateValue(value) : ''
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
data-testid="add-property-date-trigger"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ' text-foreground'}`}>
|
||||
{formatted || 'Pick a date\u2026'}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" side="left">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(day) => { if (day) onChange(dateToISO(day)) }}
|
||||
defaultMonth={selectedDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) {
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
return (
|
||||
<span className="relative inline-flex min-w-[60px] flex-1 items-center">
|
||||
<button
|
||||
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
onClick={() => setShowDropdown(true)}
|
||||
data-testid="add-property-status-trigger"
|
||||
>
|
||||
{value ? <StatusPill status={value} /> : <span className="text-muted-foreground">Status{'\u2026'}</span>}
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<StatusDropdown
|
||||
value={value}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onSave={(v) => { onChange(v); setShowDropdown(false) }}
|
||||
onCancel={() => setShowDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 '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}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
default: return (
|
||||
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
|
||||
vaultStatuses: string[]
|
||||
}) {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
|
||||
|
||||
const handleModeChange = (mode: PropertyDisplayMode) => {
|
||||
setDisplayMode(mode)
|
||||
if (mode === 'boolean') setNewValue('false')
|
||||
else if (mode !== displayMode) setNewValue('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && newKey.trim()) 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
|
||||
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
|
||||
/>
|
||||
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] w-[72px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
data-testid="add-property-type-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<OptIcon className="size-3.5 text-muted-foreground" />
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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"
|
||||
data-testid="add-property-confirm"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +1,13 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import type { ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue } from '../utils/url'
|
||||
import { usePropertyPanelState } from '../hooks/usePropertyPanelState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { isValidCssColor } from '../utils/colorUtils'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
import { getEffectiveDisplayMode, detectPropertyType } from '../utils/propertyTypes'
|
||||
import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCells'
|
||||
import { TypeSelector } from './TypeSelector'
|
||||
import { AddPropertyForm } from './AddPropertyForm'
|
||||
import { countWords } from '../utils/wikilinks'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
getEffectiveDisplayMode,
|
||||
formatDateValue,
|
||||
toISODate,
|
||||
detectPropertyType,
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
import type { PropertyDisplayMode } from '../utils/propertyTypes'
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
|
||||
export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
@@ -34,7 +16,6 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
function formatDate(timestamp: number | null): string {
|
||||
if (!timestamp) return '\u2014'
|
||||
const d = new Date(timestamp * 1000)
|
||||
@@ -49,545 +30,6 @@ function formatFileSize(bytes: number): string {
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }: {
|
||||
propKey: string; value: FrontmatterValue; isEditing: boolean; vaultStatuses: string[]
|
||||
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const statusStr = String(value)
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 items-center">
|
||||
<span
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
<StatusPill status={statusStr} />
|
||||
</span>
|
||||
{isEditing && (
|
||||
<StatusDropdown
|
||||
value={statusStr}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onSave={(newValue) => onSave(propKey, newValue)}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: {
|
||||
propKey: string; value: string[]; isEditing: boolean; vaultTags: string[]
|
||||
onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const handleToggle = useCallback((tag: string) => {
|
||||
const idx = value.indexOf(tag)
|
||||
const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag]
|
||||
onSave(propKey, next)
|
||||
}, [propKey, value, onSave])
|
||||
|
||||
const handleRemove = useCallback((tag: string) => {
|
||||
onSave(propKey, value.filter(t => t !== tag))
|
||||
}, [propKey, value, onSave])
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 flex-wrap items-center gap-1">
|
||||
{value.map(tag => {
|
||||
const style = getTagStyle(tag)
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
|
||||
>
|
||||
<span
|
||||
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
|
||||
style={{
|
||||
color: style.color,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
<button
|
||||
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
|
||||
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
|
||||
onClick={() => handleRemove(tag)}
|
||||
title={`Remove ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
title="Add tag"
|
||||
data-testid="tags-add-button"
|
||||
>+</button>
|
||||
{isEditing && (
|
||||
<TagsDropdown
|
||||
selectedTags={value}
|
||||
vaultTags={vaultTags}
|
||||
onToggle={handleToggle}
|
||||
onClose={() => onStartEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<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 parseDateValue(value: string): Date | undefined {
|
||||
const iso = toISODate(value)
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
|
||||
function dateToISO(day: Date): string {
|
||||
const yyyy = day.getFullYear()
|
||||
const mm = String(day.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(day.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
function DateValue({ value, onSave }: {
|
||||
value: string; onSave: (newValue: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const formatted = formatDateValue(value)
|
||||
const selectedDate = parseDateValue(value)
|
||||
|
||||
const handleSelect = (day: Date | undefined) => {
|
||||
if (day) onSave(dateToISO(day))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onSave('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex min-w-0 cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
title={value}
|
||||
data-testid="date-display"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end" side="left" data-testid="date-picker-popover">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
defaultMonth={selectedDate}
|
||||
data-testid="date-picker-calendar"
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 border-none bg-transparent text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
data-testid="date-picker-clear"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
Clear date
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
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' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
{ value: 'color', label: 'Color' },
|
||||
]
|
||||
|
||||
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 triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const positionMenu = useCallback((node: HTMLDivElement | null) => {
|
||||
if (!node) return
|
||||
const el = triggerRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const menuW = 140
|
||||
let left = rect.right - menuW
|
||||
if (left < 8) left = 8
|
||||
node.style.top = `${rect.bottom + 4}px`
|
||||
node.style.left = `${left}px`
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mode: PropertyDisplayMode) => {
|
||||
if (mode === autoMode) {
|
||||
onSelect(propKey, null)
|
||||
} else {
|
||||
onSelect(propKey, mode)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
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 && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
ref={positionMenu}
|
||||
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
|
||||
data-testid="display-mode-menu"
|
||||
>
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
<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>
|
||||
<OptIcon className="size-3.5 text-muted-foreground" />
|
||||
{opt.label}
|
||||
{opt.value === autoMode && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
|
||||
}
|
||||
|
||||
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 AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const boolVal = value.toLowerCase() === 'true'
|
||||
return (
|
||||
<button
|
||||
className="h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-secondary-foreground transition-colors hover:bg-accent"
|
||||
onClick={() => onChange(boolVal ? 'false' : 'true')}
|
||||
data-testid="add-property-boolean-toggle"
|
||||
>
|
||||
{boolVal ? '\u2713 Yes' : '\u2717 No'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function AddDateInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selectedDate = value ? parseDateValue(value) : undefined
|
||||
const formatted = value ? formatDateValue(value) : ''
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
data-testid="add-property-date-trigger"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ' text-foreground'}`}>
|
||||
{formatted || 'Pick a date\u2026'}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start" side="left">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={(day) => { if (day) onChange(dateToISO(day)) }}
|
||||
defaultMonth={selectedDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onChange: (v: string) => void; vaultStatuses: string[] }) {
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
return (
|
||||
<span className="relative inline-flex min-w-[60px] flex-1 items-center">
|
||||
<button
|
||||
className="inline-flex h-[26px] min-w-[60px] flex-1 cursor-pointer items-center gap-1 rounded border border-border bg-muted px-1.5 text-[12px] transition-colors hover:bg-accent"
|
||||
onClick={() => setShowDropdown(true)}
|
||||
data-testid="add-property-status-trigger"
|
||||
>
|
||||
{value ? <StatusPill status={value} /> : <span className="text-muted-foreground">Status{'\u2026'}</span>}
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<StatusDropdown
|
||||
value={value}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onSave={(v) => { onChange(v); setShowDropdown(false) }}
|
||||
onCancel={() => setShowDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 '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}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
default: return (
|
||||
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
|
||||
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
|
||||
onAdd: (key: string, value: string, displayMode: PropertyDisplayMode) => void; onCancel: () => void
|
||||
vaultStatuses: string[]
|
||||
}) {
|
||||
const [newKey, setNewKey] = useState('')
|
||||
const [newValue, setNewValue] = useState('')
|
||||
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
|
||||
|
||||
const handleModeChange = (mode: PropertyDisplayMode) => {
|
||||
setDisplayMode(mode)
|
||||
if (mode === 'boolean') setNewValue('false')
|
||||
else if (mode !== displayMode) setNewValue('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && newKey.trim()) 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
|
||||
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
|
||||
/>
|
||||
<Select value={displayMode} onValueChange={(v) => handleModeChange(v as PropertyDisplayMode)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] w-[72px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
data-testid="add-property-type-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<OptIcon className="size-3.5 text-muted-foreground" />
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<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"
|
||||
data-testid="add-property-confirm"
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button size="icon-xs" variant="outline" onClick={onCancel} title="Cancel" data-testid="add-property-cancel">
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const TYPE_NONE = '__none__'
|
||||
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(`type/${isA.toLowerCase()}`)} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
type: string; typeColorKeys: Record<string, string | null>; typeIconKeys: Record<string, string | null>
|
||||
}) {
|
||||
const Icon = getTypeIcon(type, typeIconKeys[type])
|
||||
const color = getTypeColor(type, typeColorKeys[type])
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
|
||||
<Icon width={14} height={14} style={{ color }} />
|
||||
{type}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty, onNavigate }: {
|
||||
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
|
||||
typeColorKeys: Record<string, string | null>
|
||||
typeIconKeys: Record<string, string | null>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
if (!onUpdateProperty) return <ReadOnlyType isA={isA} customColorKey={customColorKey} onNavigate={onNavigate} />
|
||||
|
||||
const currentValue = isA || TYPE_NONE
|
||||
const options = isA && !availableTypes.includes(isA)
|
||||
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
|
||||
: availableTypes
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
{options.map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toBooleanValue(value: FrontmatterValue): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') return value.toLowerCase() === 'true'
|
||||
return false
|
||||
}
|
||||
|
||||
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'string' && isUrlValue(value)) return 'url'
|
||||
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
type SmartCellProps = {
|
||||
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
|
||||
vaultStatuses: string[]; vaultTags: string[]
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
}
|
||||
|
||||
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
|
||||
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
|
||||
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode
|
||||
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 value={String(value ?? '')} onSave={(v) => onSave(propKey, v)} />
|
||||
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} />
|
||||
}
|
||||
}
|
||||
|
||||
function SmartPropertyValueCell(props: SmartCellProps) {
|
||||
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
|
||||
if (Array.isArray(value)) {
|
||||
if (displayMode === 'tags') {
|
||||
return <TagsValue propKey={propKey} value={value.map(String)} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
}
|
||||
return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
}
|
||||
return <ScalarValueCell {...props} />
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
|
||||
324
src/components/PropertyValueCells.tsx
Normal file
324
src/components/PropertyValueCells.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
|
||||
import { isUrlValue } from '../utils/url'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { CalendarIcon, XIcon } from 'lucide-react'
|
||||
import { isValidCssColor } from '../utils/colorUtils'
|
||||
import {
|
||||
type PropertyDisplayMode,
|
||||
formatDateValue,
|
||||
toISODate,
|
||||
DISPLAY_MODE_OPTIONS,
|
||||
DISPLAY_MODE_ICONS,
|
||||
} from '../utils/propertyTypes'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
import { TagsDropdown } from './TagsDropdown'
|
||||
import { getTagStyle } from '../utils/tagStyles'
|
||||
import { ColorEditableValue } from './ColorInput'
|
||||
|
||||
function parseDateValue(value: string): Date | undefined {
|
||||
const iso = toISODate(value)
|
||||
const d = new Date(iso + 'T00:00:00')
|
||||
return isNaN(d.getTime()) ? undefined : d
|
||||
}
|
||||
|
||||
function dateToISO(day: Date): string {
|
||||
const yyyy = day.getFullYear()
|
||||
const mm = String(day.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(day.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }: {
|
||||
propKey: string; value: FrontmatterValue; isEditing: boolean; vaultStatuses: string[]
|
||||
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const statusStr = String(value)
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 items-center">
|
||||
<span
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
<StatusPill status={statusStr} />
|
||||
</span>
|
||||
{isEditing && (
|
||||
<StatusDropdown
|
||||
value={statusStr}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onSave={(newValue) => onSave(propKey, newValue)}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }: {
|
||||
propKey: string; value: string[]; isEditing: boolean; vaultTags: string[]
|
||||
onSave: (key: string, items: string[]) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const handleToggle = useCallback((tag: string) => {
|
||||
const idx = value.indexOf(tag)
|
||||
const next = idx >= 0 ? value.filter((_, i) => i !== idx) : [...value, tag]
|
||||
onSave(propKey, next)
|
||||
}, [propKey, value, onSave])
|
||||
|
||||
const handleRemove = useCallback((tag: string) => {
|
||||
onSave(propKey, value.filter(t => t !== tag))
|
||||
}, [propKey, value, onSave])
|
||||
|
||||
return (
|
||||
<span className="relative inline-flex min-w-0 flex-wrap items-center gap-1">
|
||||
{value.map(tag => {
|
||||
const style = getTagStyle(tag)
|
||||
return (
|
||||
<span
|
||||
key={tag}
|
||||
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
|
||||
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
|
||||
>
|
||||
<span
|
||||
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
|
||||
style={{
|
||||
color: style.color,
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0',
|
||||
textTransform: 'uppercase' as const,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap' as const,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
<button
|
||||
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
|
||||
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
|
||||
onClick={() => handleRemove(tag)}
|
||||
title={`Remove ${tag}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
className="inline-flex size-5 shrink-0 items-center justify-center rounded-full border border-dashed border-muted-foreground bg-transparent text-[10px] text-muted-foreground transition-colors hover:border-foreground hover:text-foreground"
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
title="Add tag"
|
||||
data-testid="tags-add-button"
|
||||
>+</button>
|
||||
{isEditing && (
|
||||
<TagsDropdown
|
||||
selectedTags={value}
|
||||
vaultTags={vaultTags}
|
||||
onToggle={handleToggle}
|
||||
onClose={() => onStartEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<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 [open, setOpen] = useState(false)
|
||||
const formatted = formatDateValue(value)
|
||||
const selectedDate = parseDateValue(value)
|
||||
|
||||
const handleSelect = (day: Date | undefined) => {
|
||||
if (day) onSave(dateToISO(day))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onSave('')
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="inline-flex min-w-0 cursor-pointer items-center gap-1 rounded border-none bg-transparent px-1 py-0.5 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
|
||||
title={value}
|
||||
data-testid="date-display"
|
||||
>
|
||||
<CalendarIcon className="size-3 shrink-0 text-muted-foreground" />
|
||||
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || 'Pick a date\u2026'}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end" side="left" data-testid="date-picker-popover">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
defaultMonth={selectedDate}
|
||||
data-testid="date-picker-calendar"
|
||||
/>
|
||||
{selectedDate && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1 border-none bg-transparent text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
data-testid="date-picker-clear"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
Clear date
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export 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 triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const positionMenu = useCallback((node: HTMLDivElement | null) => {
|
||||
if (!node) return
|
||||
const el = triggerRef.current
|
||||
if (!el) return
|
||||
const rect = el.getBoundingClientRect()
|
||||
const menuW = 140
|
||||
let left = rect.right - menuW
|
||||
if (left < 8) left = 8
|
||||
node.style.top = `${rect.bottom + 4}px`
|
||||
node.style.left = `${left}px`
|
||||
}, [])
|
||||
|
||||
const handleSelect = (mode: PropertyDisplayMode) => {
|
||||
if (mode === autoMode) {
|
||||
onSelect(propKey, null)
|
||||
} else {
|
||||
onSelect(propKey, mode)
|
||||
}
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
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 && createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[12000]" onClick={() => setOpen(false)} />
|
||||
<div
|
||||
ref={positionMenu}
|
||||
className="fixed z-[12001] min-w-[130px] rounded-md border border-border bg-background py-1 shadow-md"
|
||||
data-testid="display-mode-menu"
|
||||
>
|
||||
{DISPLAY_MODE_OPTIONS.map(opt => {
|
||||
const OptIcon = DISPLAY_MODE_ICONS[opt.value]
|
||||
return (
|
||||
<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>
|
||||
<OptIcon className="size-3.5 text-muted-foreground" />
|
||||
{opt.label}
|
||||
{opt.value === autoMode && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function toBooleanValue(value: FrontmatterValue): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') return value.toLowerCase() === 'true'
|
||||
return false
|
||||
}
|
||||
|
||||
function autoDetectFromValue(value: FrontmatterValue): PropertyDisplayMode {
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'string' && isUrlValue(value)) return 'url'
|
||||
if (typeof value === 'string' && isValidCssColor(value) && value.startsWith('#')) return 'color'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
type SmartCellProps = {
|
||||
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
|
||||
vaultStatuses: string[]; vaultTags: string[]
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
}
|
||||
|
||||
function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) {
|
||||
const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) }
|
||||
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(value) : displayMode
|
||||
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 value={String(value ?? '')} onSave={(v) => onSave(propKey, v)} />
|
||||
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} />
|
||||
}
|
||||
}
|
||||
|
||||
export function SmartPropertyValueCell(props: SmartCellProps) {
|
||||
const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props
|
||||
if (Array.isArray(value)) {
|
||||
if (displayMode === 'tags') {
|
||||
return <TagsValue propKey={propKey} value={value.map(String)} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
|
||||
}
|
||||
return <TagPillList items={value.map(String)} onSave={(items) => onSaveList(propKey, items)} label={propKey} />
|
||||
}
|
||||
return <ScalarValueCell {...props} />
|
||||
}
|
||||
|
||||
77
src/components/TypeSelector.tsx
Normal file
77
src/components/TypeSelector.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { FrontmatterValue } from './Inspector'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
|
||||
const TYPE_NONE = '__none__'
|
||||
|
||||
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
|
||||
type: string; typeColorKeys: Record<string, string | null>; typeIconKeys: Record<string, string | null>
|
||||
}) {
|
||||
const Icon = getTypeIcon(type, typeIconKeys[type])
|
||||
const color = getTypeColor(type, typeColorKeys[type])
|
||||
return (
|
||||
<>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
|
||||
<Icon width={14} height={14} style={{ color }} />
|
||||
{type}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
|
||||
if (!isA) return null
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
{onNavigate ? (
|
||||
<button
|
||||
className="min-w-0 truncate border-none text-right cursor-pointer hover:opacity-80"
|
||||
style={{ background: getTypeLightColor(isA, customColorKey), color: getTypeColor(isA, customColorKey), borderRadius: 6, padding: '2px 8px', fontSize: 12, fontWeight: 500 }}
|
||||
onClick={() => onNavigate(`type/${isA.toLowerCase()}`)} title={isA}
|
||||
>{isA}</button>
|
||||
) : (
|
||||
<span className="text-right text-[12px] text-secondary-foreground">{isA}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty, onNavigate }: {
|
||||
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
|
||||
typeColorKeys: Record<string, string | null>
|
||||
typeIconKeys: Record<string, string | null>
|
||||
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
|
||||
onNavigate?: (target: string) => void
|
||||
}) {
|
||||
if (!onUpdateProperty) return <ReadOnlyType isA={isA} customColorKey={customColorKey} onNavigate={onNavigate} />
|
||||
|
||||
const currentValue = isA || TYPE_NONE
|
||||
const options = isA && !availableTypes.includes(isA)
|
||||
? [...availableTypes, isA].sort((a, b) => a.localeCompare(b))
|
||||
: availableTypes
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-2 px-1.5" data-testid="type-selector">
|
||||
<span className="font-mono-overline shrink-0 text-muted-foreground">Type</span>
|
||||
<Select value={currentValue} onValueChange={v => onUpdateProperty('type', v === TYPE_NONE ? null : v)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-[26px] shrink-0 gap-1 border-border bg-muted px-1.5 py-0 shadow-none"
|
||||
style={{ fontSize: 12, borderRadius: 4 }}
|
||||
>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" side="left">
|
||||
<SelectItem value={TYPE_NONE}>None</SelectItem>
|
||||
<SelectSeparator />
|
||||
{options.map(type => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { isValidCssColor, isColorKeyName } from './colorUtils'
|
||||
import { updateVaultConfigField } from './vaultConfigStore'
|
||||
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
|
||||
|
||||
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
|
||||
|
||||
@@ -112,3 +113,17 @@ export function toISODate(value: string): string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
|
||||
text: Type, 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: 'date', label: 'Date' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'status', label: 'Status' },
|
||||
{ value: 'url', label: 'URL' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
{ value: 'color', label: 'Color' },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user