feat: relations autocomplete shows note type badges (matching wiki-link UX) (#74)
* feat: show note type in relations autocomplete dropdown Replace HTML datalist in InlineAddNote and AddRelationshipForm with custom NoteAutocomplete component that displays note types as colored badges, matching the wiki-link autocomplete UX. Supports keyboard navigation, scrollable results, and graceful truncation. Also extract ref-group helpers to improve InspectorPanels code health (8.99 → 9.38). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add relations autocomplete dropdown wireframe Shows the autocomplete dropdown with note type badges (Project, Topic, Procedure, Person) matching the wiki-link autocomplete UX. Includes edge case of note with no type badge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
179
src/components/NoteAutocomplete.test.tsx
Normal file
179
src/components/NoteAutocomplete.test.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { NoteAutocomplete } from './NoteAutocomplete'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): 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: '',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries = [
|
||||
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'] }),
|
||||
]
|
||||
|
||||
const personTypeEntry = makeEntry({
|
||||
path: '/vault/type/person.md', filename: 'person.md', title: 'Person',
|
||||
isA: 'Type', color: 'yellow', icon: 'user',
|
||||
})
|
||||
const typeEntryMap: Record<string, VaultEntry> = { Person: personTypeEntry }
|
||||
|
||||
describe('NoteAutocomplete', () => {
|
||||
const onChange = vi.fn()
|
||||
const onSelect = vi.fn()
|
||||
const onEscape = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders input with placeholder', () => {
|
||||
render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="" onChange={onChange} onSelect={onSelect} placeholder="Note title" testId="test-input" />,
|
||||
)
|
||||
expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for short queries', () => {
|
||||
render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="A" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
expect(screen.queryByText('Alpha Project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows matching entries when query is long enough', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Al" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
// Simulate opening the dropdown by focusing and typing
|
||||
const input = container.querySelector('input')!
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows note type badge for typed entries', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Luca" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.focus(container.querySelector('input')!)
|
||||
expect(screen.getByText('Luca')).toBeInTheDocument()
|
||||
expect(screen.getByText('Person')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show type badge for plain notes', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Plain" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.focus(container.querySelector('input')!)
|
||||
expect(screen.getByText('Plain Note')).toBeInTheDocument()
|
||||
// Should not render any type badge
|
||||
const typeLabels = container.querySelectorAll('.wikilink-menu__type')
|
||||
expect(typeLabels.length).toBe(0)
|
||||
})
|
||||
|
||||
it('applies type color from typeEntryMap', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Luca" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.focus(container.querySelector('input')!)
|
||||
const typeLabel = container.querySelector('.wikilink-menu__type')
|
||||
expect(typeLabel).toBeTruthy()
|
||||
expect((typeLabel as HTMLElement).style.color).toBe('var(--accent-yellow)')
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking a dropdown item', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Alpha" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.focus(container.querySelector('input')!)
|
||||
fireEvent.click(screen.getByText('Alpha Project'))
|
||||
expect(onSelect).toHaveBeenCalledWith('Alpha Project')
|
||||
})
|
||||
|
||||
it('navigates dropdown with arrow keys', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Al" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
const input = container.querySelector('input')!
|
||||
fireEvent.focus(input)
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
// First item should be selected
|
||||
const selectedItem = container.querySelector('.wikilink-menu__item--selected')
|
||||
expect(selectedItem).toBeTruthy()
|
||||
})
|
||||
|
||||
it('selects highlighted item with Enter', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Al" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
const input = container.querySelector('input')!
|
||||
fireEvent.focus(input)
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSelect).toHaveBeenCalledWith('Alpha Project')
|
||||
})
|
||||
|
||||
it('calls onEscape when Escape is pressed', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="test" onChange={onChange} onSelect={onSelect} onEscape={onEscape} />,
|
||||
)
|
||||
const input = container.querySelector('input')!
|
||||
fireEvent.focus(input)
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(onEscape).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('matches on aliases', () => {
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="Alice" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.focus(container.querySelector('input')!)
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('limits results to MAX_RESULTS', () => {
|
||||
const manyEntries = Array.from({ length: 20 }, (_, i) =>
|
||||
makeEntry({ path: `/vault/note/${i}.md`, filename: `${i}.md`, title: `Note ${i}`, isA: null }),
|
||||
)
|
||||
const { container } = render(
|
||||
<NoteAutocomplete entries={manyEntries} typeEntryMap={{}} value="Note" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
fireEvent.focus(container.querySelector('input')!)
|
||||
const items = container.querySelectorAll('.wikilink-menu__item')
|
||||
expect(items.length).toBe(10) // MAX_RESULTS
|
||||
})
|
||||
|
||||
it('submits raw value with Enter when no item is selected', () => {
|
||||
render(
|
||||
<NoteAutocomplete entries={entries} typeEntryMap={typeEntryMap} value="custom text" onChange={onChange} onSelect={onSelect} />,
|
||||
)
|
||||
const input = screen.getByDisplayValue('custom text')
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSelect).toHaveBeenCalledWith('custom text')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user