fix: rework Custom Views — 5 QA bugs fixed

1. Icon picker: replaced text Input with EmojiPicker popup
2. Create button: added missing .yml extension to filename
3. Field list: include relationships alongside properties
4. Nested filters: FilterBuilder now supports AND/OR groups
   with recursive nesting (Airtable/Notion-style)
5. Autocomplete: field and value inputs use datalist for
   type-ahead suggestions (type values, status values, etc.)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test
2026-04-03 16:52:33 +02:00
parent 9f2bd669fe
commit 248ec02dee
3 changed files with 238 additions and 70 deletions

View File

@@ -328,7 +328,7 @@ function App() {
}, [notes])
const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
await vault.reloadViews()
@@ -349,13 +349,27 @@ function App() {
const availableFields = useMemo(() => {
const builtIn = ['type', 'status', 'title', 'favorite']
if (!vault.entries?.length) return builtIn
const customProps = new Set<string>()
const customFields = new Set<string>()
for (const e of vault.entries) {
if (e.properties) {
for (const key of Object.keys(e.properties)) customProps.add(key)
for (const key of Object.keys(e.properties)) customFields.add(key)
}
if (e.relationships) {
for (const key of Object.keys(e.relationships)) customFields.add(key)
}
}
return [...builtIn, ...Array.from(customProps).sort()]
return [...builtIn, ...Array.from(customFields).sort()]
}, [vault.entries])
const valueSuggestionsForField = useCallback((field: string): string[] => {
if (!vault.entries?.length) return []
const values = new Set<string>()
for (const e of vault.entries) {
if (field === 'type' && e.isA) values.add(e.isA)
else if (field === 'status' && e.status) values.add(e.status)
else if (e.properties?.[field] != null) values.add(String(e.properties[field]))
}
return Array.from(values).sort()
}, [vault.entries])
const bulkActions = useBulkActions(entryActions, setToastMessage)
@@ -599,7 +613,7 @@ function App() {
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} />
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} />
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
<ConflictResolverModal
open={dialogs.showConflictResolver}

View File

@@ -1,30 +1,35 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { FilterBuilder } from './FilterBuilder'
import type { FilterCondition, ViewDefinition } from '../types'
import { EmojiPicker } from './EmojiPicker'
import type { FilterGroup, ViewDefinition } from '../types'
interface CreateViewDialogProps {
open: boolean
onClose: () => void
onCreate: (definition: ViewDefinition) => void
availableFields: string[]
/** Returns known values for a given field (for autocomplete). */
valueSuggestions?: (field: string) => string[]
}
export function CreateViewDialog({ open, onClose, onCreate, availableFields }: CreateViewDialogProps) {
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions }: CreateViewDialogProps) {
const [name, setName] = useState('')
const [icon, setIcon] = useState('')
const [conditions, setConditions] = useState<FilterCondition[]>([
{ field: 'type', op: 'equals', value: '' },
])
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [filters, setFilters] = useState<FilterGroup>({
all: [{ field: 'type', op: 'equals', value: '' }],
})
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setConditions([{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }]) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, availableFields])
@@ -33,18 +38,26 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
e.preventDefault()
const trimmed = name.trim()
if (!trimmed) return
const validConditions = conditions.filter((c) => c.field)
const definition: ViewDefinition = {
name: trimmed,
icon: icon || null,
color: null,
sort: null,
filters: { all: validConditions },
filters,
}
onCreate(definition)
onClose()
}
const handleSelectEmoji = useCallback((emoji: string) => {
setIcon(emoji)
setShowEmojiPicker(false)
}, [])
const handleCloseEmojiPicker = useCallback(() => {
setShowEmojiPicker(false)
}, [])
return (
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
@@ -53,15 +66,19 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex gap-2">
<div className="w-16 space-y-1.5">
<div className="w-16 space-y-1.5 relative">
<label className="text-xs font-medium text-muted-foreground">Icon</label>
<Input
placeholder="📋"
value={icon}
onChange={(e) => setIcon(e.target.value)}
className="text-center"
maxLength={2}
/>
<button
type="button"
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Pick icon"
>
{icon || <span className="text-sm text-muted-foreground">📋</span>}
</button>
{showEmojiPicker && (
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
)}
</div>
<div className="flex-1 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Name</label>
@@ -76,9 +93,10 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Filters</label>
<FilterBuilder
conditions={conditions}
onChange={setConditions}
group={filters}
onChange={setFilters}
availableFields={availableFields}
valueSuggestions={valueSuggestions}
/>
</div>
<DialogFooter>

View File

@@ -1,7 +1,7 @@
import { useId } from 'react'
import { Plus, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import type { FilterCondition, FilterOp } from '../types'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'equals', label: 'equals' },
@@ -10,35 +10,91 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
{ value: 'not_contains', label: 'does not contain' },
{ value: 'is_empty', label: 'is empty' },
{ value: 'is_not_empty', label: 'is not empty' },
{ value: 'before', label: 'before' },
{ value: 'after', label: 'after' },
]
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
interface FilterBuilderProps {
conditions: FilterCondition[]
onChange: (conditions: FilterCondition[]) => void
availableFields: string[]
function isFilterGroup(node: FilterNode): node is FilterGroup {
return 'all' in node || 'any' in node
}
function FilterRow({ condition, fields, onUpdate, onRemove }: {
function getGroupChildren(group: FilterGroup): FilterNode[] {
return 'all' in group ? group.all : group.any
}
function getGroupMode(group: FilterGroup): 'all' | 'any' {
return 'all' in group ? 'all' : 'any'
}
function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGroup {
return mode === 'all' ? { all: children } : { any: children }
}
/** Combobox-style field input with datalist autocomplete. */
function FieldInput({ value, fields, onChange }: {
value: string
fields: string[]
onChange: (v: string) => void
}) {
const id = useId()
return (
<>
<input
list={id}
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="field"
/>
<datalist id={id}>
{fields.map((f) => <option key={f} value={f} />)}
</datalist>
</>
)
}
/** Combobox-style value input with autocomplete for known values. */
function ValueInput({ value, suggestions, onChange }: {
value: string
suggestions: string[]
onChange: (v: string) => void
}) {
const id = useId()
return (
<>
<input
list={id}
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
placeholder="value"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<datalist id={id}>
{suggestions.map((s) => <option key={s} value={s} />)}
</datalist>
</>
)
}
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
condition: FilterCondition
fields: string[]
valueSuggestions: (field: string) => string[]
onUpdate: (c: FilterCondition) => void
onRemove: () => void
}) {
const suggestions = valueSuggestions(condition.field)
return (
<div className="flex items-center gap-1.5">
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
<FieldInput
value={condition.field}
onChange={(e) => onUpdate({ ...condition, field: e.target.value })}
>
{fields.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
fields={fields}
onChange={(v) => onUpdate({ ...condition, field: v })}
/>
<select
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
className="h-8 rounded-md border border-input bg-background px-2 text-sm shrink-0"
value={condition.op}
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
>
@@ -47,11 +103,10 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
))}
</select>
{!NO_VALUE_OPS.has(condition.op) && (
<Input
className="h-8 flex-1 min-w-0"
placeholder="value"
<ValueInput
value={String(condition.value ?? '')}
onChange={(e) => onUpdate({ ...condition, value: e.target.value })}
suggestions={suggestions}
onChange={(v) => onUpdate({ ...condition, value: v })}
/>
)}
<button
@@ -66,40 +121,121 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
)
}
export function FilterBuilder({ conditions, onChange, availableFields }: FilterBuilderProps) {
const fields = availableFields.length > 0 ? availableFields : ['type']
function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onRemove }: {
group: FilterGroup
fields: string[]
valueSuggestions: (field: string) => string[]
depth: number
onChange: (g: FilterGroup) => void
onRemove?: () => void
}) {
const mode = getGroupMode(group)
const children = getGroupChildren(group)
const handleUpdate = (index: number, updated: FilterCondition) => {
const next = [...conditions]
next[index] = updated
onChange(next)
const toggleMode = () => {
onChange(setGroupChildren(mode === 'all' ? 'any' : 'all', children))
}
const handleRemove = (index: number) => {
onChange(conditions.filter((_, i) => i !== index))
const updateChild = (index: number, node: FilterNode) => {
const next = [...children]
next[index] = node
onChange(setGroupChildren(mode, next))
}
const handleAdd = () => {
onChange([...conditions, { field: fields[0], op: 'equals', value: '' }])
const removeChild = (index: number) => {
const next = children.filter((_, i) => i !== index)
onChange(setGroupChildren(mode, next))
}
const addCondition = () => {
onChange(setGroupChildren(mode, [...children, { field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }]))
}
const addGroup = () => {
const nested: FilterGroup = { all: [{ field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }] }
onChange(setGroupChildren(mode, [...children, nested]))
}
return (
<div className="space-y-2">
{conditions.length > 1 && (
<span className="text-[11px] font-medium text-muted-foreground">Match all of:</span>
)}
{conditions.map((c, i) => (
<FilterRow
key={i}
condition={c}
fields={fields}
onUpdate={(updated) => handleUpdate(i, updated)}
onRemove={() => handleRemove(i)}
/>
))}
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={handleAdd}>
<Plus size={12} className="mr-1" /> Add filter
</Button>
<div className={depth > 0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}>
<div className="flex items-center gap-2 mb-2">
<button
type="button"
className="rounded-full border border-input bg-muted px-2.5 py-0.5 text-[11px] font-medium text-foreground cursor-pointer hover:bg-accent transition-colors"
onClick={toggleMode}
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
>
{mode === 'all' ? 'AND' : 'OR'}
</button>
<span className="text-[11px] text-muted-foreground">
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
</span>
{onRemove && (
<button
type="button"
className="ml-auto rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={onRemove}
title="Remove group"
>
<X size={12} />
</button>
)}
</div>
<div className="space-y-2">
{children.map((child, i) =>
isFilterGroup(child) ? (
<FilterGroupView
key={i}
group={child}
fields={fields}
valueSuggestions={valueSuggestions}
depth={depth + 1}
onChange={(g) => updateChild(i, g)}
onRemove={() => removeChild(i)}
/>
) : (
<FilterRow
key={i}
condition={child}
fields={fields}
valueSuggestions={valueSuggestions}
onUpdate={(c) => updateChild(i, c)}
onRemove={() => removeChild(i)}
/>
)
)}
</div>
<div className="flex gap-2 mt-2">
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addCondition}>
<Plus size={12} className="mr-1" /> Add filter
</Button>
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addGroup}>
<Plus size={12} className="mr-1" /> Add group
</Button>
</div>
</div>
)
}
export interface FilterBuilderProps {
group: FilterGroup
onChange: (group: FilterGroup) => void
availableFields: string[]
/** Returns known values for a given field (for autocomplete). */
valueSuggestions?: (field: string) => string[]
}
const defaultSuggestions = () => [] as string[]
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions }: FilterBuilderProps) {
const fields = availableFields.length > 0 ? availableFields : ['type']
return (
<FilterGroupView
group={group}
fields={fields}
valueSuggestions={valueSuggestions ?? defaultSuggestions}
depth={0}
onChange={onChange}
/>
)
}