From 248ec02deec9eee97087740cdd0769312c3fca60 Mon Sep 17 00:00:00 2001 From: Test Date: Fri, 3 Apr 2026 16:52:33 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20rework=20Custom=20Views=20=E2=80=94=205?= =?UTF-8?q?=20QA=20bugs=20fixed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/App.tsx | 24 ++- src/components/CreateViewDialog.tsx | 56 ++++--- src/components/FilterBuilder.tsx | 228 ++++++++++++++++++++++------ 3 files changed, 238 insertions(+), 70 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2356fe38..1534e62e 100644 --- a/src/App.tsx +++ b/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() + const customFields = new Set() 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() + 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() { - + 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([ - { field: 'type', op: 'equals', value: '' }, - ]) + const [showEmojiPicker, setShowEmojiPicker] = useState(false) + const [filters, setFilters] = useState({ + all: [{ field: 'type', op: 'equals', value: '' }], + }) const inputRef = useRef(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 ( { if (!isOpen) onClose() }}> @@ -53,15 +66,19 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
-
+
- setIcon(e.target.value)} - className="text-center" - maxLength={2} - /> + + {showEmojiPicker && ( + + )}
@@ -76,9 +93,10 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields }: C
diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index e45f1501..2c9c6e13 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -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(['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 ( + <> + onChange(e.target.value)} + placeholder="field" + /> + + {fields.map((f) => + + ) +} + +/** 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 ( + <> + onChange(e.target.value)} + /> + + {suggestions.map((s) => + + ) +} + +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 (
- + fields={fields} + onChange={(v) => onUpdate({ ...condition, field: v })} + /> {!NO_VALUE_OPS.has(condition.op) && ( - onUpdate({ ...condition, value: e.target.value })} + suggestions={suggestions} + onChange={(v) => onUpdate({ ...condition, value: v })} /> )} +
0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}> +
+ + + {mode === 'all' ? 'Match all conditions' : 'Match any condition'} + + {onRemove && ( + + )} +
+
+ {children.map((child, i) => + isFilterGroup(child) ? ( + updateChild(i, g)} + onRemove={() => removeChild(i)} + /> + ) : ( + updateChild(i, c)} + onRemove={() => removeChild(i)} + /> + ) + )} +
+
+ + +
) } + +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 ( + + ) +}