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:
24
src/App.tsx
24
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user