diff --git a/src/App.tsx b/src/App.tsx index d1184167..1afc43b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -632,7 +632,7 @@ function App() { - + string[] + /** Vault entries for wikilink autocomplete in filter value fields. */ + entries?: VaultEntry[] /** When provided, the dialog operates in edit mode with pre-populated fields. */ editingView?: ViewDefinition | null } -export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, editingView }: CreateViewDialogProps) { +export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, entries, editingView }: CreateViewDialogProps) { const [name, setName] = useState('') const [icon, setIcon] = useState('') const [showEmojiPicker, setShowEmojiPicker] = useState(false) @@ -106,6 +108,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val onChange={setFilters} availableFields={availableFields} valueSuggestions={valueSuggestions} + entries={entries} /> diff --git a/src/components/FilterBuilder.test.tsx b/src/components/FilterBuilder.test.tsx new file mode 100644 index 00000000..48ebf8d2 --- /dev/null +++ b/src/components/FilterBuilder.test.tsx @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { FilterBuilder } from './FilterBuilder' +import type { FilterGroup, VaultEntry } from '../types' + +const makeEntry = (overrides: Partial = {}): VaultEntry => ({ + path: '/vault/note/test.md', + filename: 'test.md', + title: 'Test Note', + isA: 'Note', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: null, + cadence: null, + archived: false, + trashed: false, + trashedAt: null, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + ...overrides, +}) + +const entries: VaultEntry[] = [ + makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha Project', isA: 'Project' }), + makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' }), + makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }), + makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }), + makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }), + makeEntry({ path: '/vault/trashed.md', filename: 'trashed.md', title: 'Trashed Note', isA: null, trashed: true }), +] + +describe('FilterBuilder wikilink autocomplete', () => { + const onChange = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + function renderWithEntries(group?: FilterGroup) { + const defaultGroup: FilterGroup = { + all: [{ field: 'title', op: 'contains', value: '' }], + } + return render( + , + ) + } + + it('renders value input with wikilink support when entries are provided', () => { + renderWithEntries() + expect(screen.getByTestId('filter-value-input')).toBeInTheDocument() + }) + + it('does not show dropdown for plain text input', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: 'hello' }], + }) + expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument() + }) + + it('shows dropdown when value starts with [[', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Al' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument() + expect(screen.getByText('Alpha Project')).toBeInTheDocument() + expect(screen.getByText('Alice Smith')).toBeInTheDocument() + }) + + it('does not show dropdown for short queries after [[', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[A' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument() + }) + + it('inserts [[note-title]] when a note is selected', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Alpha' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + fireEvent.click(screen.getByText('Alpha Project')) + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }], + }), + ) + }) + + it('navigates dropdown with arrow keys and selects with Enter', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Al' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + fireEvent.keyDown(input, { key: 'ArrowDown' }) + const selected = document.querySelector('.wikilink-menu__item--selected') + expect(selected).toBeTruthy() + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onChange).toHaveBeenCalled() + }) + + it('closes dropdown on Escape', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Al' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument() + fireEvent.keyDown(input, { key: 'Escape' }) + expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument() + }) + + it('excludes trashed notes from autocomplete', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Trashed' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + expect(screen.queryByText('Trashed Note')).not.toBeInTheDocument() + }) + + it('matches on aliases', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Alice' }], + }) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + expect(screen.getByText('Alice Smith')).toBeInTheDocument() + }) + + it('shows type badge for typed entries', () => { + const personType = makeEntry({ + path: '/vault/person.md', filename: 'person.md', title: 'Person', + isA: 'Type', color: 'yellow', icon: 'user', + }) + const entriesWithType = [...entries, personType] + render( + , + ) + const input = screen.getByTestId('filter-value-input') + fireEvent.focus(input) + expect(screen.getByText('Person')).toBeInTheDocument() + }) + + it('opens dropdown on typing [[ in input', () => { + renderWithEntries({ + all: [{ field: 'title', op: 'contains', value: '[[Al' }], + }) + const input = screen.getByTestId('filter-value-input') + // Simulate the user typing [[ — dropdown opens when value starts with [[ + fireEvent.change(input, { target: { value: '[[Al' } }) + // The internal open state is set by onChange, verified via focus re-trigger + fireEvent.focus(input) + expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument() + }) + + it('plain text without [[ still works as regular input', () => { + renderWithEntries() + const input = screen.getByTestId('filter-value-input') + fireEvent.change(input, { target: { value: 'some text' } }) + expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument() + expect(onChange).toHaveBeenCalled() + }) + + it('falls back to plain input when no entries are provided', () => { + render( + , + ) + const input = screen.getByPlaceholderText('value') + expect(input).toBeInTheDocument() + expect(input).not.toHaveAttribute('data-testid', 'filter-value-input') + }) +}) diff --git a/src/components/FilterBuilder.tsx b/src/components/FilterBuilder.tsx index 0a690505..294d02a5 100644 --- a/src/components/FilterBuilder.tsx +++ b/src/components/FilterBuilder.tsx @@ -1,8 +1,12 @@ +import { useState, useRef, useMemo, useEffect, useCallback } from 'react' import { Plus, X } from '@phosphor-icons/react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types' +import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types' +import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors' +import { getTypeIcon } from './NoteItem' +import './WikilinkSuggestionMenu.css' const OPERATORS: { value: FilterOp; label: string }[] = [ { value: 'equals', label: 'equals' }, @@ -80,10 +84,197 @@ function OperatorSelect({ value, onChange }: { ) } -function ValueInput({ value, suggestions, isDateOp, onChange }: { +const MAX_WIKILINK_RESULTS = 10 +const MIN_WIKILINK_QUERY = 2 + +function entryMatchesQuery(e: VaultEntry, lowerQuery: string): boolean { + return e.title.toLowerCase().includes(lowerQuery) || + e.aliases.some(a => a.toLowerCase().includes(lowerQuery)) +} + +function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record) { + const isA = e.isA + const te = typeEntryMap[isA ?? ''] + const noteType = isA || undefined + return { + title: e.title, + noteType, + typeColor: noteType ? getTypeColor(isA, te?.color) : undefined, + typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined, + TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined, + } +} + +function matchWikilinkEntries(entries: VaultEntry[], typeEntryMap: Record, query: string) { + if (query.length < MIN_WIKILINK_QUERY) return [] + const lowerQuery = query.toLowerCase() + return entries + .filter(e => !e.trashed && entryMatchesQuery(e, lowerQuery)) + .slice(0, MAX_WIKILINK_RESULTS) + .map(e => toWikilinkMatch(e, typeEntryMap)) +} + +type WikilinkMatch = ReturnType + +function extractWikilinkQuery(value: string): string | null { + return value.startsWith('[[') ? value.slice(2).replace(/]]$/, '') : null +} + +function useOutsideClick(refs: React.RefObject[], onClose: () => void) { + useEffect(() => { + const handler = (e: MouseEvent) => { + const target = e.target as Node + if (refs.every(r => !r.current?.contains(target))) onClose() + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, [refs, onClose]) +} + +function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }: { + matches: WikilinkMatch[] + selectedIndex: number + onSelect: (title: string) => void + onHover: (index: number) => void + menuRef: React.RefObject +}) { + return ( +
+ {matches.map((item, index) => ( +
e.preventDefault()} + onClick={() => onSelect(item.title)} + onMouseEnter={() => onHover(index)} + > + + {item.TypeIcon && } + {item.title} + + {item.noteType && ( + + {item.noteType} + + )} +
+ ))} +
+ ) +} + +function useWikilinkMatches(entries: VaultEntry[], value: string, open: boolean) { + const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) + const wikilinkQuery = extractWikilinkQuery(value) + return useMemo( + () => (open && wikilinkQuery !== null) ? matchWikilinkEntries(entries, typeEntryMap, wikilinkQuery) : [], + [entries, typeEntryMap, wikilinkQuery, open], + ) +} + +function useScrollSelectedIntoView(menuRef: React.RefObject, selectedIndex: number) { + useEffect(() => { + if (selectedIndex < 0 || !menuRef.current) return + const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined + el?.scrollIntoView?.({ block: 'nearest' }) + }, [selectedIndex, menuRef]) +} + +function useDropdownKeyboard( + matches: WikilinkMatch[], + open: boolean, + onSelect: (title: string) => void, + onClose: () => void, +) { + const [selectedIndex, setSelectedIndex] = useState(-1) + + const resetIndex = useCallback(() => setSelectedIndex(-1), []) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!open || matches.length === 0) return + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex(i => (i + 1) % matches.length) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1)) + } else if (e.key === 'Enter' && selectedIndex >= 0) { + e.preventDefault() + onSelect(matches[selectedIndex].title) + } else if (e.key === 'Escape') { + e.preventDefault() + onClose() + } + }, [open, matches, selectedIndex, onSelect, onClose]) + + return { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown } +} + +function WikilinkValueInput({ value, entries, onChange }: { + value: string + entries: VaultEntry[] + onChange: (v: string) => void +}) { + const [open, setOpen] = useState(false) + const inputRef = useRef(null) + const menuRef = useRef(null) + + const matches = useWikilinkMatches(entries, value, open) + + const handleSelect = useCallback((title: string) => { + onChange(`[[${title}]]`) + setOpen(false) + }, [onChange]) + + const closeMenu = useCallback(() => setOpen(false), []) + useOutsideClick([inputRef, menuRef], closeMenu) + + const { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown } = + useDropdownKeyboard(matches, open, handleSelect, closeMenu) + + useScrollSelectedIntoView(menuRef, selectedIndex) + + const handleChange = useCallback((e: React.ChangeEvent) => { + onChange(e.target.value) + setOpen(e.target.value.startsWith('[[')) + resetIndex() + }, [onChange, resetIndex]) + + return ( +
+ { if (value.startsWith('[[')) setOpen(true) }} + onKeyDown={handleKeyDown} + data-testid="filter-value-input" + /> + {open && matches.length > 0 && ( + + )} +
+ ) +} + +function ValueInput({ value, suggestions, isDateOp, entries, onChange }: { value: string suggestions: string[] isDateOp: boolean + entries: VaultEntry[] onChange: (v: string) => void }) { if (isDateOp) { @@ -118,6 +309,10 @@ function ValueInput({ value, suggestions, isDateOp, onChange }: { ) } + if (entries.length > 0) { + return + } + return ( string[] onUpdate: (c: FilterCondition) => void onRemove: () => void @@ -153,6 +349,7 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: value={String(condition.value ?? '')} suggestions={suggestions} isDateOp={isDateOp} + entries={entries} onChange={(v) => onUpdate({ ...condition, value: v })} /> )} @@ -170,9 +367,10 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: ) } -function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onRemove }: { +function FilterGroupView({ group, fields, entries, valueSuggestions, depth, onChange, onRemove }: { group: FilterGroup fields: string[] + entries: VaultEntry[] valueSuggestions: (field: string) => string[] depth: number onChange: (g: FilterGroup) => void @@ -241,6 +439,7 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR key={i} group={child} fields={fields} + entries={entries} valueSuggestions={valueSuggestions} depth={depth + 1} onChange={(g) => updateChild(i, g)} @@ -251,6 +450,7 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR key={i} condition={child} fields={fields} + entries={entries} valueSuggestions={valueSuggestions} onUpdate={(c) => updateChild(i, c)} onRemove={() => removeChild(i)} @@ -276,16 +476,19 @@ export interface FilterBuilderProps { availableFields: string[] /** Returns known values for a given field (for autocomplete). */ valueSuggestions?: (field: string) => string[] + /** Vault entries for wikilink autocomplete in value fields. */ + entries?: VaultEntry[] } const defaultSuggestions = () => [] as string[] -export function FilterBuilder({ group, onChange, availableFields, valueSuggestions }: FilterBuilderProps) { +export function FilterBuilder({ group, onChange, availableFields, valueSuggestions, entries }: FilterBuilderProps) { const fields = availableFields.length > 0 ? availableFields : ['type'] return (