Files
tolaria/src/components/PropertyValueCells.tsx
2026-05-26 10:52:53 +02:00

690 lines
22 KiB
TypeScript

import { ArrowUpRight, X as XIcon } from '@phosphor-icons/react'
import { useState, useCallback, useEffect, useRef, type ReactNode, type RefObject } from 'react'
import { createPortal } from 'react-dom'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { trackDatePropertyDirectEntrySaved } from '../lib/productAnalytics'
import { translate, type AppLocale } from '../lib/i18n'
import { isValidCssColor } from '../utils/colorUtils'
import { isUrlValue } from '../utils/url'
import {
type PropertyDisplayMode,
formatDateValue,
toISODate,
DISPLAY_MODE_OPTIONS,
DISPLAY_MODE_ICONS,
} from '../utils/propertyTypes'
import { StatusDropdown } from './StatusDropdown'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { getStatusStyle } from '../utils/statusStyles'
import { TagsDropdown } from './TagsDropdown'
import { getTagStyle } from '../utils/tagStyles'
import { ColorEditableValue } from './ColorInput'
import { IconEditableValue } from './IconEditableValue'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import { canonicalSystemMetadataKey } from '../utils/systemMetadata'
import { useDateDisplayFormat } from '../hooks/useAppPreferences'
const ISO_DATE_INPUT_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const DEFAULT_DATE_PICKER_START_YEAR = 1800
const DEFAULT_DATE_PICKER_END_YEAR = 2200
function localDate(year: number, monthIndex: number, day: number): Date {
const date = new Date(0)
date.setFullYear(year, monthIndex, day)
date.setHours(0, 0, 0, 0)
return date
}
function dateToISO(day: Date): string {
const yyyy = String(day.getFullYear()).padStart(4, '0')
const mm = String(day.getMonth() + 1).padStart(2, '0')
const dd = String(day.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
function parseDateInput(value: string): string | null {
const trimmed = value.trim()
const match = ISO_DATE_INPUT_RE.exec(trimmed)
if (!match) return null
const year = Number(match[1])
if (year < 1) return null
const date = localDate(year, Number(match[2]) - 1, Number(match[3]))
return dateToISO(date) === trimmed ? trimmed : null
}
function dateFromISO(value: string): Date | undefined {
const iso = parseDateInput(value)
if (!iso) return undefined
const match = ISO_DATE_INPUT_RE.exec(iso)
if (!match) return undefined
return localDate(Number(match[1]), Number(match[2]) - 1, Number(match[3]))
}
function parseDateValue(value: string): Date | undefined {
return dateFromISO(toISODate(value))
}
function datePickerStartMonth(selectedDate: Date | undefined): Date {
const selectedYear = selectedDate?.getFullYear()
const year = selectedYear && selectedYear < DEFAULT_DATE_PICKER_START_YEAR
? selectedYear
: DEFAULT_DATE_PICKER_START_YEAR
return localDate(year, 0, 1)
}
function datePickerEndMonth(selectedDate: Date | undefined): Date {
const selectedYear = selectedDate?.getFullYear()
const year = selectedYear && selectedYear > DEFAULT_DATE_PICKER_END_YEAR
? selectedYear
: DEFAULT_DATE_PICKER_END_YEAR
return localDate(year, 11, 31)
}
const RELATIONSHIP_PROPERTY_KEYS = new Set(['belongs_to', 'related_to', 'has'])
function normalizePropertyKey(propKey: string): string {
return propKey.trim().toLowerCase().replace(/[\s-]+/g, '_')
}
function showsRelationshipPropertyIcon(propKey: string): boolean {
return RELATIONSHIP_PROPERTY_KEYS.has(normalizePropertyKey(propKey))
}
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)
const style = getStatusStyle(statusStr)
return (
<span className="relative inline-flex min-w-0 items-center">
<button
type="button"
className="inline-flex cursor-pointer items-center gap-1.5 border-0 transition-opacity hover:opacity-80"
style={{ ...PROPERTY_CHIP_STYLE, backgroundColor: style.bg, color: style.color }}
onClick={() => onStartEdit(propKey)}
data-testid="status-badge"
>
<span className="inline-block size-1.5 shrink-0 rounded-full" style={{ backgroundColor: style.color }} />
{statusStr}
</button>
{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"
style={{ ...PROPERTY_CHIP_STYLE, backgroundColor: style.bg, 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,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,
}}
>
{tag}
</span>
<button type="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: 11, flexShrink: 0 }}
onClick={() => handleRemove(tag)}
title={`Remove ${tag}`}
>
&times;
</button>
</span>
)
})}
<button type="button"
className="inline-flex shrink-0 items-center justify-center border-none bg-muted text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
style={PROPERTY_CHIP_STYLE}
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 (
<label className="inline-flex h-6 cursor-pointer items-center gap-1.5" data-testid="boolean-toggle">
<input
type="checkbox"
checked={value}
onChange={onToggle}
className="size-3.5 cursor-pointer accent-primary"
/>
<span className="text-[12px] text-secondary-foreground">{value ? 'Yes' : 'No'}</span>
</label>
)
}
function NumberValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: ScalarEditProps) {
const [editValue, setEditValue] = useState(value)
const restoreValue = useCallback(() => {
setEditValue(value)
}, [value])
const commitValue = useCallback(() => {
const trimmed = editValue.trim()
if (trimmed === '') {
onSave('')
return
}
const parsed = Number(trimmed)
if (Number.isFinite(parsed)) {
onSave(trimmed)
return
}
restoreValue()
onCancel()
}, [editValue, onCancel, onSave, restoreValue])
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
commitValue()
return
}
if (event.key === 'Escape') {
restoreValue()
onCancel()
}
}, [commitValue, onCancel, restoreValue])
if (isEditing) {
return (
<Input
className="h-7 w-full border-ring bg-muted px-2 py-1 text-left font-mono text-[12px] tabular-nums"
type="text"
inputMode="decimal"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitValue}
autoFocus
data-testid="number-input"
/>
)
}
return (
<button
type="button"
className="inline-flex h-6 w-full min-w-0 items-center justify-start overflow-hidden rounded-md border-none bg-muted/60 px-2 text-left font-mono text-[12px] tabular-nums text-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="number-display"
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</button>
)
}
function DateValue({ value, onSave, locale = 'en', autoOpen = false, onCancel }: {
value: string
onSave: (newValue: string) => void
locale?: AppLocale
autoOpen?: boolean
onCancel?: () => void
}) {
const dateDisplayFormat = useDateDisplayFormat()
const [open, setOpen] = useState(autoOpen)
const formatted = formatDateValue(value, dateDisplayFormat)
const pickDateLabel = translate(locale, 'inspector.properties.pickDate')
const selectedDate = parseDateValue(value)
const selectedIso = selectedDate ? dateToISO(selectedDate) : ''
const [draftValue, setDraftValue] = useState(selectedIso)
const [invalidDraft, setInvalidDraft] = useState(false)
const resetDraft = () => {
setDraftValue(selectedIso)
setInvalidDraft(false)
}
const closeWithoutSave = () => {
resetDraft()
setOpen(false)
onCancel?.()
}
const commitDraftValue = () => {
if (draftValue.trim() === '') {
if (value) onSave('')
else closeWithoutSave()
setOpen(false)
return
}
const parsed = parseDateInput(draftValue)
if (!parsed) {
setInvalidDraft(true)
return
}
onSave(parsed)
trackDatePropertyDirectEntrySaved()
setOpen(false)
}
const handleSelect = (day: Date | undefined) => {
if (day) onSave(dateToISO(day))
setOpen(false)
}
const handleClear = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
onSave('')
setOpen(false)
}
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setDraftValue(event.target.value)
if (invalidDraft) setInvalidDraft(false)
}
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault()
commitDraftValue()
return
}
if (event.key === 'Escape') {
event.preventDefault()
closeWithoutSave()
}
}
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen) resetDraft()
setOpen(nextOpen)
if (!nextOpen) onCancel?.()
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="xs"
className={`min-w-0 justify-start border-none text-left transition-colors hover:opacity-80${formatted ? ' bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
style={PROPERTY_CHIP_STYLE}
title={value}
data-testid="date-display"
>
<span className={`min-w-0 truncate${!formatted ? ' text-muted-foreground' : ''}`}>{formatted || pickDateLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto overflow-hidden p-0" align="end" side="bottom" data-testid="date-picker-popover">
<div className="border-b p-2">
<Input
className="h-8 w-full min-w-[8.75rem] border-ring bg-background px-2 py-1 text-left font-mono text-[13px] tabular-nums"
type="text"
inputMode="numeric"
pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"
placeholder="YYYY-MM-DD"
aria-label={pickDateLabel}
aria-invalid={invalidDraft ? 'true' : undefined}
value={draftValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
autoFocus
data-testid="date-picker-input"
/>
</div>
<div className="pb-8">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleSelect}
defaultMonth={selectedDate}
captionLayout="dropdown"
navLayout="after"
startMonth={datePickerStartMonth(selectedDate)}
endMonth={datePickerEndMonth(selectedDate)}
data-testid="date-picker-calendar"
/>
</div>
{selectedDate && (
<div className="relative bg-popover border-t px-3 py-2">
<Button
type="button"
variant="ghost"
size="xs"
className="h-7 px-2 text-xs text-muted-foreground hover:bg-transparent 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 CurrentIcon = Reflect.get(DISPLAY_MODE_ICONS, currentMode) as typeof DISPLAY_MODE_ICONS.text
const showRelationshipIcon = showsRelationshipPropertyIcon(propKey)
const handleSelect = (mode: PropertyDisplayMode) => {
onSelect(propKey, mode === autoMode ? null : mode)
setOpen(false)
}
return (
<div className="relative">
<button
ref={triggerRef}
type="button"
className="inline-flex size-5 shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:bg-background hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary"
onClick={() => setOpen(!open)}
title={`Change ${propKey} type`}
aria-label={`Change ${propKey} type`}
data-testid="display-mode-trigger"
>
{showRelationshipIcon ? (
<ArrowUpRight className="size-3.5" data-testid="display-mode-icon-relationship" />
) : (
<CurrentIcon className="size-3.5" data-testid={`display-mode-icon-${currentMode}`} />
)}
</button>
{open && (
<DisplayModeMenu
autoMode={autoMode}
currentMode={currentMode}
onClose={() => setOpen(false)}
onSelect={handleSelect}
triggerRef={triggerRef}
/>
)}
</div>
)
}
function DisplayModeMenu({
autoMode,
currentMode,
onClose,
onSelect,
triggerRef,
}: {
autoMode: PropertyDisplayMode
currentMode: PropertyDisplayMode
onClose: () => void
onSelect: (mode: PropertyDisplayMode) => void
triggerRef: RefObject<HTMLButtonElement | null>
}) {
const backdropRef = useRef<HTMLDivElement>(null)
const positionMenu = useCallback((node: HTMLDivElement | null) => {
if (!node) return
const el = triggerRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const menuW = 140
const left = Math.max(rect.right - menuW, 8)
node.style.top = `${rect.bottom + 4}px`
node.style.left = `${left}px`
}, [triggerRef])
useEffect(() => {
const backdrop = backdropRef.current
if (!backdrop) return
backdrop.addEventListener('click', onClose)
return () => backdrop.removeEventListener('click', onClose)
}, [onClose])
return createPortal(
<>
<div
ref={backdropRef}
className="fixed inset-0 z-[12000]"
/>
<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) => (
<DisplayModeOption
key={opt.value}
autoMode={autoMode}
currentMode={currentMode}
option={opt}
onSelect={onSelect}
/>
))}
</div>
</>,
document.body,
)
}
function DisplayModeOption({
autoMode,
currentMode,
onSelect,
option,
}: {
autoMode: PropertyDisplayMode
currentMode: PropertyDisplayMode
onSelect: (mode: PropertyDisplayMode) => void
option: typeof DISPLAY_MODE_OPTIONS[number]
}) {
const OptIcon = DISPLAY_MODE_ICONS[option.value]
return (
<button
type="button"
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={() => onSelect(option.value)}
data-testid={`display-mode-option-${option.value}`}
>
<span className="w-3 text-center text-[10px]">
{currentMode === option.value ? '\u2713' : ''}
</span>
<OptIcon className="size-3.5 text-muted-foreground" />
{option.label}
{option.value === autoMode && (
<span className="ml-auto text-[10px] text-muted-foreground">auto</span>
)}
</button>
)
}
function toBooleanValue(value: FrontmatterValue): boolean {
if (typeof value === 'boolean') return value
if (typeof value === 'string') return value.toLowerCase() === 'true'
return false
}
function autoDetectFromValue(propKey: string, value: FrontmatterValue): PropertyDisplayMode {
if (canonicalSystemMetadataKey(propKey) === '_icon') return 'text'
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
locale?: AppLocale
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
}
interface ScalarEditProps {
value: string
isEditing: boolean
onStartEdit: () => void
onSave: (nextValue: string) => void
onCancel: () => void
}
function createScalarEditProps({
propKey,
value,
isEditing,
onStartEdit,
onSave,
}: {
propKey: string
value: FrontmatterValue
isEditing: boolean
onStartEdit: (key: string | null) => void
onSave: (key: string, value: string) => void
}): ScalarEditProps {
return {
value: String(value ?? ''),
isEditing,
onStartEdit: () => onStartEdit(propKey),
onSave: (nextValue: string) => onSave(propKey, nextValue),
onCancel: () => onStartEdit(null),
}
}
type ScalarRendererProps = SmartCellProps & {
editProps: ScalarEditProps
}
type ScalarDisplayRenderer = (props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) => ReactNode
const SCALAR_DISPLAY_RENDERERS: readonly [PropertyDisplayMode, ScalarDisplayRenderer][] = [
['status', (props) => (
<StatusValue
propKey={props.propKey}
value={props.value ?? ''}
isEditing={props.isEditing}
vaultStatuses={props.vaultStatuses}
onSave={props.onSave}
onStartEdit={props.onStartEdit}
/>
)],
['tags', (props) => (
<TagsValue
propKey={props.propKey}
value={props.value ? [String(props.value)] : []}
isEditing={props.isEditing}
vaultTags={props.vaultTags}
onSave={props.onSaveList}
onStartEdit={props.onStartEdit}
/>
)],
['date', (props) => (
<DateValue
key={`${props.propKey}:${props.isEditing ? 'editing' : 'view'}`}
value={String(props.value ?? '')}
locale={props.locale}
onSave={(nextValue) => props.onSave(props.propKey, nextValue)}
autoOpen={props.isEditing}
onCancel={() => props.onStartEdit(null)}
/>
)],
['number', (props) => <NumberValue {...props.editProps} />],
['boolean', (props) => {
const boolVal = toBooleanValue(props.value)
return <BooleanToggle value={boolVal} onToggle={() => props.onUpdate?.(props.propKey, !boolVal)} />
}],
['url', (props) => <UrlValue {...props.editProps} />],
['color', (props) => <ColorEditableValue {...props.editProps} />],
]
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
const renderer = SCALAR_DISPLAY_RENDERERS.find(([mode]) => mode === props.resolvedMode)?.[1]
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
}
function ScalarValueCell(props: SmartCellProps) {
const { propKey, value, displayMode, isEditing, onStartEdit, onSave } = props
const editProps = createScalarEditProps({
propKey,
value,
isEditing,
onStartEdit,
onSave,
})
if (canonicalSystemMetadataKey(propKey) === '_icon') {
return <IconEditableValue {...editProps} />
}
const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode
return renderScalarDisplayMode({ ...props, resolvedMode, 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} />
}