feat: status property — Notion-style dropdown with color chips (#97)
* fix: resolve search panel freeze by making search_vault async The search_vault Tauri command was synchronous (fn, not async fn), blocking the main thread for 30+ seconds during hybrid/semantic search on large vaults (9200+ files). This caused the macOS beachball. Changes: - Make search_vault async with tokio::spawn_blocking (runs qmd off main thread) - Cache collection name per vault path (avoid repeated qmd collection list calls) - Cancel inflight searches and debounce timers when panel closes - Add regression test for stale result cancellation on panel close Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * design: add status property dropdown wireframes Three frames: closed pill state, open dropdown with suggested/vault statuses, and custom status creation flow. Also bump flaky NoteList 9000-entry test timeout from 15s to 30s. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: replace status text edit with Notion-style dropdown - Extract STATUS_STYLES to shared statusStyles.ts utility - New StatusDropdown component: filterable popover with colored pills - StatusPill reusable component for consistent status chip rendering - Vault-wide status aggregation from entries prop - Dropdown shows "From vault" and "Suggested" sections - Custom status creation via type-and-Enter - Escape/backdrop click cancels without saving - Keyboard navigation (ArrowUp/Down + Enter) - 22 new tests covering dropdown behavior + integration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: cargo fmt --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1
design/status-property-dropdown.pen
Normal file
1
design/status-property-dropdown.pen
Normal file
File diff suppressed because one or more lines are too long
@@ -165,13 +165,16 @@ async fn github_get_user(token: String) -> Result<GitHubUser, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn search_vault(
|
||||
async fn search_vault(
|
||||
vault_path: String,
|
||||
query: String,
|
||||
mode: String,
|
||||
limit: Option<usize>,
|
||||
) -> Result<SearchResponse, String> {
|
||||
search::search_vault(&vault_path, &query, &mode, limit.unwrap_or(20))
|
||||
let limit = limit.unwrap_or(20);
|
||||
tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit))
|
||||
.await
|
||||
.map_err(|e| format!("Search task failed: {}", e))?
|
||||
}
|
||||
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -82,8 +84,30 @@ fn extract_clean_snippet(raw_snippet: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
static COLLECTION_CACHE: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
|
||||
|
||||
fn detect_collection_name(vault_path: &str) -> String {
|
||||
// Try to find which qmd collection maps to this vault path
|
||||
// Check cache first
|
||||
if let Ok(guard) = COLLECTION_CACHE.lock() {
|
||||
if let Some(ref cache) = *guard {
|
||||
if let Some(name) = cache.get(vault_path) {
|
||||
return name.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = detect_collection_name_uncached(vault_path);
|
||||
|
||||
// Store in cache
|
||||
if let Ok(mut guard) = COLLECTION_CACHE.lock() {
|
||||
let cache = guard.get_or_insert_with(HashMap::new);
|
||||
cache.insert(vault_path.to_string(), result.clone());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn detect_collection_name_uncached(vault_path: &str) -> String {
|
||||
let qmd_bin = match find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => return "laputa".to_string(),
|
||||
@@ -99,11 +123,9 @@ fn detect_collection_name(vault_path: &str) -> String {
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("laputa")
|
||||
.to_lowercase();
|
||||
// Look for collection that matches vault directory name
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains(&vault_name) && trimmed.contains("qmd://") {
|
||||
// Extract collection name from "name (qmd://name/)"
|
||||
if let Some(name) = trimmed.split_whitespace().next() {
|
||||
return name.to_string();
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ describe('DynamicPropertiesPanel', () => {
|
||||
expect(screen.getByText('Modified')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles editing status', () => {
|
||||
it('opens status dropdown on click and selects a status', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
@@ -359,12 +359,13 @@ describe('DynamicPropertiesPanel', () => {
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
// Click status pill to start editing (rendered with CSS uppercase, DOM text is "Active")
|
||||
// Click status pill to open dropdown
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
// Should show edit input
|
||||
const input = screen.getByDisplayValue('Active')
|
||||
fireEvent.change(input, { target: { value: 'Done' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
// Should show dropdown with search input
|
||||
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-search-input')).toBeInTheDocument()
|
||||
// Click on "Done" option in the suggested list
|
||||
fireEvent.click(screen.getByTestId('status-option-Done'))
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Status', 'Done')
|
||||
})
|
||||
|
||||
@@ -742,6 +743,74 @@ describe('DynamicPropertiesPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('status dropdown interaction', () => {
|
||||
it('closes dropdown on Escape without saving', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
|
||||
fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' })
|
||||
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dropdown on backdrop click without saving', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
fireEvent.click(screen.getByTestId('status-dropdown-backdrop'))
|
||||
expect(screen.queryByTestId('status-dropdown')).not.toBeInTheDocument()
|
||||
expect(onUpdateProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates custom status by typing and pressing Enter', () => {
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Needs Review' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onUpdateProperty).toHaveBeenCalledWith('Status', 'Needs Review')
|
||||
})
|
||||
|
||||
it('shows vault statuses from entries', () => {
|
||||
const entriesWithStatuses = [
|
||||
makeEntry({ path: '/vault/a.md', status: 'Reviewing' }),
|
||||
makeEntry({ path: '/vault/b.md', status: 'Shipped' }),
|
||||
]
|
||||
render(
|
||||
<DynamicPropertiesPanel
|
||||
entry={makeEntry()}
|
||||
content=""
|
||||
frontmatter={{ Status: 'Active' }}
|
||||
entries={entriesWithStatuses}
|
||||
onUpdateProperty={onUpdateProperty}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Active'))
|
||||
expect(screen.getByTestId('status-option-Reviewing')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Shipped')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('smart property display — boolean', () => {
|
||||
it('renders boolean toggle for true values', () => {
|
||||
render(
|
||||
|
||||
@@ -18,26 +18,7 @@ import {
|
||||
removeDisplayModeOverride,
|
||||
detectPropertyType,
|
||||
} from '../utils/propertyTypes'
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; color: string }> = {
|
||||
Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
Done: { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' },
|
||||
Paused: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Archived: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
Dropped: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Open: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
Closed: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Published: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
'In progress': { bg: 'var(--accent-purple-light)', color: 'var(--accent-purple)' },
|
||||
Blocked: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Cancelled: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Pending: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
}
|
||||
|
||||
const DEFAULT_STATUS_STYLE = { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' }
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
|
||||
// Keys that are relationships (contain wikilinks)
|
||||
export const RELATIONSHIP_KEYS = new Set([
|
||||
@@ -83,34 +64,28 @@ function formatFileSize(bytes: number): string {
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
function StatusValue({ propKey, value, isEditing, onSave, onStartEdit }: {
|
||||
propKey: string; value: FrontmatterValue; isEditing: boolean
|
||||
function StatusValue({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }: {
|
||||
propKey: string; value: FrontmatterValue; isEditing: boolean; vaultStatuses: string[]
|
||||
onSave: (key: string, value: string) => void; onStartEdit: (key: string | null) => void
|
||||
}) {
|
||||
const statusStr = String(value)
|
||||
const style = STATUS_STYLES[statusStr] ?? DEFAULT_STATUS_STYLE
|
||||
if (isEditing) {
|
||||
return (
|
||||
<input
|
||||
className="w-full rounded border border-ring bg-muted px-2 py-1 text-[12px] text-foreground outline-none focus:border-primary"
|
||||
type="text" defaultValue={statusStr}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') onSave(propKey, (e.target as HTMLInputElement).value)
|
||||
if (e.key === 'Escape') onStartEdit(null)
|
||||
}}
|
||||
onBlur={(e) => onSave(propKey, e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="inline-block min-w-0 cursor-pointer truncate transition-opacity hover:opacity-80"
|
||||
style={{ backgroundColor: style.bg, color: style.color, borderRadius: 16, padding: '1px 6px', fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' as const }}
|
||||
onClick={() => onStartEdit(propKey)} title={statusStr}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
{statusStr}
|
||||
<span className="relative inline-flex min-w-0 items-center">
|
||||
<span
|
||||
className="cursor-pointer transition-opacity hover:opacity-80"
|
||||
onClick={() => onStartEdit(propKey)}
|
||||
data-testid="status-badge"
|
||||
>
|
||||
<StatusPill status={statusStr} />
|
||||
</span>
|
||||
{isEditing && (
|
||||
<StatusDropdown
|
||||
value={statusStr}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onSave={(newValue) => onSave(propKey, newValue)}
|
||||
onCancel={() => onStartEdit(null)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -316,8 +291,9 @@ function TypeSelector({ isA, customColorKey, availableTypes, onUpdateProperty, o
|
||||
)
|
||||
}
|
||||
|
||||
function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStartEdit, onSave, onSaveList, onUpdate }: {
|
||||
function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate }: {
|
||||
propKey: string; value: FrontmatterValue; displayMode: PropertyDisplayMode; isEditing: boolean
|
||||
vaultStatuses: string[]
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void
|
||||
}) {
|
||||
@@ -328,7 +304,7 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStar
|
||||
|
||||
switch (displayMode) {
|
||||
case 'status':
|
||||
return <StatusValue propKey={propKey} value={value} isEditing={isEditing} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
return <StatusValue propKey={propKey} value={value} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
|
||||
case 'date':
|
||||
if (typeof value === 'string') {
|
||||
return <DateValue value={value} onSave={(v) => onSave(propKey, v)} />
|
||||
@@ -355,9 +331,10 @@ function SmartPropertyValueCell({ propKey, value, displayMode, isEditing, onStar
|
||||
}
|
||||
}
|
||||
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
|
||||
propKey: string; value: FrontmatterValue; editingKey: string | null
|
||||
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
|
||||
vaultStatuses: string[]
|
||||
onStartEdit: (key: string | null) => void; onSave: (key: string, value: string) => void
|
||||
onSaveList: (key: string, items: string[]) => void
|
||||
onUpdate?: (key: string, value: FrontmatterValue) => void; onDelete?: (key: string) => void
|
||||
@@ -372,7 +349,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, onStar
|
||||
)}
|
||||
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
|
||||
</span>
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
<SmartPropertyValueCell propKey={propKey} value={value} displayMode={displayMode} isEditing={editingKey === propKey} vaultStatuses={vaultStatuses} onStartEdit={onStartEdit} onSave={onSave} onSaveList={onSaveList} onUpdate={onUpdate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -454,6 +431,14 @@ export function DynamicPropertiesPanel({
|
||||
}
|
||||
}, [entries, entry.isA])
|
||||
|
||||
const vaultStatuses = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const e of entries ?? []) {
|
||||
if (e.status) seen.add(e.status)
|
||||
}
|
||||
return Array.from(seen).sort((a, b) => a.localeCompare(b))
|
||||
}, [entries])
|
||||
|
||||
const propertyEntries = useMemo(() => {
|
||||
return Object.entries(frontmatter)
|
||||
.filter(([key, value]) => !SKIP_KEYS.has(key) && !RELATIONSHIP_KEYS.has(key) && !containsWikilinks(value))
|
||||
@@ -496,6 +481,7 @@ export function DynamicPropertiesPanel({
|
||||
<PropertyRow
|
||||
key={key} propKey={key} value={value}
|
||||
editingKey={editingKey} displayMode={effectiveMode} autoMode={autoMode}
|
||||
vaultStatuses={vaultStatuses}
|
||||
onStartEdit={setEditingKey} onSave={handleSaveValue}
|
||||
onSaveList={handleSaveList} onUpdate={onUpdateProperty}
|
||||
onDelete={onDeleteProperty}
|
||||
|
||||
@@ -835,7 +835,7 @@ describe('NoteList — virtual list with large datasets', () => {
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('renders 9000 entries without crashing', { timeout: 15000 }, () => {
|
||||
it('renders 9000 entries without crashing', { timeout: 30000 }, () => {
|
||||
const largeDataset = Array.from({ length: 9000 }, (_, i) => makeEntry(i))
|
||||
const { container } = render(
|
||||
<NoteList entries={largeDataset} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
|
||||
|
||||
@@ -368,4 +368,42 @@ describe('SearchPanel', () => {
|
||||
// Keyword results remain
|
||||
expect(screen.getByText('Keyword Only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('cancels inflight searches when panel closes', async () => {
|
||||
const resolvers: ((v: unknown) => void)[] = []
|
||||
mockInvokeFn.mockImplementation(
|
||||
() => new Promise(resolve => { resolvers.push(resolve) }),
|
||||
)
|
||||
|
||||
const { rerender } = render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Search in all notes...'), { target: { value: 'slow query' } })
|
||||
|
||||
// Wait for keyword search to start
|
||||
await waitFor(() => {
|
||||
expect(resolvers).toHaveLength(1)
|
||||
})
|
||||
|
||||
// Close the panel while search is inflight
|
||||
rerender(
|
||||
<SearchPanel open={false} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Resolve the inflight keyword search — should be discarded (stale generation)
|
||||
resolvers[0]({
|
||||
results: [{ title: 'Stale Result', path: '/vault/essay/ai-apis.md', snippet: '', score: 0.9, note_type: null }],
|
||||
elapsed_ms: 30,
|
||||
})
|
||||
|
||||
// Reopen panel
|
||||
rerender(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
// Should NOT show the stale result — panel was reset
|
||||
expect(screen.queryByText('Stale Result')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Search across all note contents')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
161
src/components/StatusDropdown.test.tsx
Normal file
161
src/components/StatusDropdown.test.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { StatusPill, StatusDropdown } from './StatusDropdown'
|
||||
|
||||
describe('StatusPill', () => {
|
||||
it('renders with known status style', () => {
|
||||
render(<StatusPill status="Active" />)
|
||||
const pill = screen.getByTitle('Active')
|
||||
expect(pill).toBeInTheDocument()
|
||||
expect(pill.textContent).toBe('Active')
|
||||
expect(pill.style.backgroundColor).toBe('var(--accent-green-light)')
|
||||
expect(pill.style.color).toBe('var(--accent-green)')
|
||||
})
|
||||
|
||||
it('renders with default style for unknown status', () => {
|
||||
render(<StatusPill status="Custom Thing" />)
|
||||
const pill = screen.getByTitle('Custom Thing')
|
||||
expect(pill.style.backgroundColor).toBe('var(--accent-blue-light)')
|
||||
expect(pill.style.color).toBe('var(--muted-foreground)')
|
||||
})
|
||||
|
||||
it('applies truncate class for long names', () => {
|
||||
render(<StatusPill status="Very Long Status Name That Should Truncate" />)
|
||||
const pill = screen.getByTitle('Very Long Status Name That Should Truncate')
|
||||
expect(pill.className).toContain('truncate')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatusDropdown', () => {
|
||||
const onSave = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
value: 'Active',
|
||||
vaultStatuses: ['Draft', 'Published'],
|
||||
onSave,
|
||||
onCancel,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders dropdown with search input', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
expect(screen.getByTestId('status-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-search-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows suggested statuses', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
expect(screen.getByTestId('status-option-Not started')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-In progress')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Active')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Done')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Blocked')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows vault statuses separately from suggested', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
expect(screen.getByTestId('status-option-Draft')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Published')).toBeInTheDocument()
|
||||
expect(screen.getByText('From vault')).toBeInTheDocument()
|
||||
expect(screen.getByText('Suggested')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not duplicate vault statuses in suggested list', () => {
|
||||
render(<StatusDropdown {...defaultProps} vaultStatuses={['Active', 'Draft']} />)
|
||||
// Active should appear in vault section, not suggested
|
||||
const activeOptions = screen.getAllByTestId('status-option-Active')
|
||||
expect(activeOptions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('calls onSave when a status option is clicked', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('status-option-Done'))
|
||||
expect(onSave).toHaveBeenCalledWith('Done')
|
||||
})
|
||||
|
||||
it('calls onCancel when backdrop is clicked', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
fireEvent.click(screen.getByTestId('status-dropdown-backdrop'))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onCancel when Escape is pressed', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
fireEvent.keyDown(screen.getByTestId('status-search-input'), { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters options when typing in search', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'blo' } })
|
||||
expect(screen.getByTestId('status-option-Blocked')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('status-option-Done')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows create option when typing a new status name', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Needs Review' } })
|
||||
expect(screen.getByTestId('status-create-option')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show create option when query matches existing status', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Active' } })
|
||||
expect(screen.queryByTestId('status-create-option')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates custom status on Enter when no match', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'Needs Review' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('Needs Review')
|
||||
})
|
||||
|
||||
it('navigates options with arrow keys', () => {
|
||||
render(<StatusDropdown {...defaultProps} vaultStatuses={[]} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
// Arrow down to first option
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
// Arrow down again to second option
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
// Press Enter to select second option (In progress)
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('In progress')
|
||||
})
|
||||
|
||||
it('shows no statuses message when filter yields no results and no create option', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
// Type just a space (no trim match but not empty)
|
||||
fireEvent.change(input, { target: { value: ' ' } })
|
||||
// With only spaces, query.trim() is empty so no create option
|
||||
// And no options match spaces
|
||||
// But query is not empty so the filter runs... let's use something truly no-match
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
// With empty query, all options are shown - this verifies the fallback
|
||||
expect(screen.getByTestId('status-option-Active')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only default suggestions when vault has no statuses', () => {
|
||||
render(<StatusDropdown {...defaultProps} vaultStatuses={[]} />)
|
||||
expect(screen.queryByText('From vault')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Suggested')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-option-Active')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('preserves user input case — no title-casing', () => {
|
||||
render(<StatusDropdown {...defaultProps} />)
|
||||
const input = screen.getByTestId('status-search-input')
|
||||
fireEvent.change(input, { target: { value: 'wIP' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onSave).toHaveBeenCalledWith('wIP')
|
||||
})
|
||||
})
|
||||
250
src/components/StatusDropdown.tsx
Normal file
250
src/components/StatusDropdown.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
|
||||
import { getStatusStyle, SUGGESTED_STATUSES } from '../utils/statusStyles'
|
||||
|
||||
export function StatusPill({ status, className }: { status: string; className?: string }) {
|
||||
const style = getStatusStyle(status)
|
||||
return (
|
||||
<span
|
||||
className={`inline-block min-w-0 truncate${className ? ` ${className}` : ''}`}
|
||||
style={{
|
||||
backgroundColor: style.bg,
|
||||
color: style.color,
|
||||
borderRadius: 16,
|
||||
padding: '1px 6px',
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
maxWidth: 160,
|
||||
}}
|
||||
title={status}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusOption({
|
||||
status,
|
||||
highlighted,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
}: {
|
||||
status: string
|
||||
highlighted: boolean
|
||||
onSelect: (status: string) => void
|
||||
onMouseEnter: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center border-none bg-transparent px-2 py-1 text-left transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor: highlighted ? 'var(--muted)' : 'transparent',
|
||||
}}
|
||||
onClick={() => onSelect(status)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
data-testid={`status-option-${status}`}
|
||||
>
|
||||
<StatusPill status={status} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusDropdown({
|
||||
vaultStatuses,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
value: string
|
||||
vaultStatuses: string[]
|
||||
onSave: (newValue: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [highlightIndex, setHighlightIndex] = useState(-1)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const { suggestedFiltered, vaultFiltered, allFiltered } = useMemo(() => {
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const vaultSet = new Set(vaultStatuses.map(s => s.toLowerCase()))
|
||||
|
||||
const suggested = SUGGESTED_STATUSES.filter(
|
||||
s => s.toLowerCase().includes(lowerQuery) && !vaultSet.has(s.toLowerCase()),
|
||||
)
|
||||
|
||||
const vault = vaultStatuses.filter(s => s.toLowerCase().includes(lowerQuery))
|
||||
|
||||
return {
|
||||
suggestedFiltered: suggested,
|
||||
vaultFiltered: vault,
|
||||
allFiltered: [...vault, ...suggested],
|
||||
}
|
||||
}, [query, vaultStatuses])
|
||||
|
||||
const showCreateOption = useMemo(() => {
|
||||
if (!query.trim()) return false
|
||||
const lowerQuery = query.trim().toLowerCase()
|
||||
return !allFiltered.some(s => s.toLowerCase() === lowerQuery)
|
||||
}, [query, allFiltered])
|
||||
|
||||
const totalOptions = allFiltered.length + (showCreateOption ? 1 : 0)
|
||||
|
||||
const scrollHighlightedIntoView = useCallback((index: number) => {
|
||||
const list = listRef.current
|
||||
if (!list) return
|
||||
const items = list.querySelectorAll('[data-testid^="status-option-"], [data-testid="status-create-option"]')
|
||||
items[index]?.scrollIntoView({ block: 'nearest' })
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
const next = highlightIndex < totalOptions - 1 ? highlightIndex + 1 : 0
|
||||
setHighlightIndex(next)
|
||||
scrollHighlightedIntoView(next)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
const prev = highlightIndex > 0 ? highlightIndex - 1 : totalOptions - 1
|
||||
setHighlightIndex(prev)
|
||||
scrollHighlightedIntoView(prev)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
if (highlightIndex >= 0 && highlightIndex < allFiltered.length) {
|
||||
onSave(allFiltered[highlightIndex])
|
||||
} else if (showCreateOption && highlightIndex === allFiltered.length) {
|
||||
onSave(query.trim())
|
||||
} else if (query.trim()) {
|
||||
onSave(query.trim())
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
}
|
||||
},
|
||||
[highlightIndex, totalOptions, allFiltered, showCreateOption, query, onSave, onCancel, scrollHighlightedIntoView],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative" data-testid="status-dropdown">
|
||||
{/* Backdrop to close on outside click */}
|
||||
<div className="fixed inset-0 z-40" onClick={onCancel} data-testid="status-dropdown-backdrop" />
|
||||
|
||||
<div
|
||||
className="absolute right-0 top-full z-50 mt-1 w-52 overflow-hidden rounded-lg border border-border bg-background shadow-lg"
|
||||
data-testid="status-dropdown-popover"
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full border-none bg-transparent text-[12px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
placeholder="Type a status..."
|
||||
value={query}
|
||||
onChange={e => {
|
||||
setQuery(e.target.value)
|
||||
setHighlightIndex(-1)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="status-search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Options list */}
|
||||
<div ref={listRef} className="max-h-52 overflow-y-auto py-1">
|
||||
{vaultFiltered.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1">
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
}}
|
||||
>
|
||||
From vault
|
||||
</span>
|
||||
</div>
|
||||
{vaultFiltered.map((status, i) => (
|
||||
<StatusOption
|
||||
key={status}
|
||||
status={status}
|
||||
highlighted={highlightIndex === i}
|
||||
onSelect={onSave}
|
||||
onMouseEnter={() => setHighlightIndex(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{suggestedFiltered.length > 0 && (
|
||||
<div>
|
||||
{vaultFiltered.length > 0 && (
|
||||
<div className="my-1 h-px bg-border" />
|
||||
)}
|
||||
<div className="px-2 py-1">
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
style={{
|
||||
fontFamily: "'IBM Plex Mono', monospace",
|
||||
fontSize: 9,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '1.2px',
|
||||
textTransform: 'uppercase' as const,
|
||||
}}
|
||||
>
|
||||
Suggested
|
||||
</span>
|
||||
</div>
|
||||
{suggestedFiltered.map((status, i) => (
|
||||
<StatusOption
|
||||
key={status}
|
||||
status={status}
|
||||
highlighted={highlightIndex === vaultFiltered.length + i}
|
||||
onSelect={onSave}
|
||||
onMouseEnter={() => setHighlightIndex(vaultFiltered.length + i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateOption && (
|
||||
<>
|
||||
{allFiltered.length > 0 && <div className="my-1 h-px bg-border" />}
|
||||
<button
|
||||
className="flex w-full items-center gap-1.5 border-none bg-transparent px-2 py-1 text-left text-[11px] transition-colors"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
backgroundColor:
|
||||
highlightIndex === allFiltered.length ? 'var(--muted)' : 'transparent',
|
||||
color: 'var(--muted-foreground)',
|
||||
}}
|
||||
onClick={() => onSave(query.trim())}
|
||||
onMouseEnter={() => setHighlightIndex(allFiltered.length)}
|
||||
data-testid="status-create-option"
|
||||
>
|
||||
Create <StatusPill status={query.trim()} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allFiltered.length === 0 && !showCreateOption && (
|
||||
<div className="px-2 py-2 text-center text-[11px] text-muted-foreground">
|
||||
No matching statuses
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -63,21 +63,19 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
|
||||
searchGenRef.current++
|
||||
}, [])
|
||||
|
||||
useEffect(() => { if (active) reset() }, [active, reset])
|
||||
// On any active change: cancel inflight + debounce, then reset if opening
|
||||
useEffect(() => {
|
||||
searchGenRef.current++
|
||||
clearTimeout(debounceRef.current ?? undefined)
|
||||
debounceRef.current = null
|
||||
if (active) reset()
|
||||
}, [active, reset])
|
||||
|
||||
const performSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) {
|
||||
setResults([])
|
||||
setElapsedMs(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!q.trim()) { setResults([]); setElapsedMs(null); setLoading(false); return }
|
||||
searchGenRef.current++
|
||||
const gen = searchGenRef.current
|
||||
setLoading(true)
|
||||
|
||||
// Phase 1: Keyword search (fast)
|
||||
try {
|
||||
const response = await searchCall({ vaultPath, query: q, mode: 'keyword', limit: 20 })
|
||||
if (gen !== searchGenRef.current) return
|
||||
@@ -89,8 +87,6 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 2: Hybrid search — augments keyword results with semantic
|
||||
try {
|
||||
const response = await searchWithTimeout(
|
||||
{ vaultPath, query: q, mode: 'hybrid', limit: 20 },
|
||||
@@ -100,15 +96,14 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
|
||||
setResults(mapResults(response.results))
|
||||
setElapsedMs(response.elapsed_ms)
|
||||
setSelectedIndex(prev => Math.min(prev, Math.max(response.results.length - 1, 0)))
|
||||
} catch {
|
||||
// Hybrid failed or timed out — keyword results remain visible
|
||||
} finally {
|
||||
} catch { /* Hybrid timed out — keyword results remain */ } finally {
|
||||
if (gen === searchGenRef.current) setLoading(false)
|
||||
}
|
||||
}, [vaultPath])
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
clearTimeout(debounceRef.current ?? undefined)
|
||||
debounceRef.current = null
|
||||
if (!query.trim()) {
|
||||
setResults([])
|
||||
setElapsedMs(null)
|
||||
@@ -117,7 +112,10 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
|
||||
return
|
||||
}
|
||||
debounceRef.current = setTimeout(() => performSearch(query), DEBOUNCE_MS)
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
|
||||
return () => {
|
||||
clearTimeout(debounceRef.current ?? undefined)
|
||||
debounceRef.current = null
|
||||
}
|
||||
}, [query, performSearch])
|
||||
|
||||
return { query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs }
|
||||
|
||||
43
src/utils/statusStyles.ts
Normal file
43
src/utils/statusStyles.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface StatusStyle {
|
||||
bg: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export const STATUS_STYLES: Record<string, StatusStyle> = {
|
||||
Active: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
Done: { bg: 'var(--accent-blue-light)', color: 'var(--accent-blue)' },
|
||||
Paused: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Archived: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
Dropped: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Open: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
Closed: { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
'Not started': { bg: 'var(--accent-blue-light)', color: 'var(--muted-foreground)' },
|
||||
Draft: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Mixed: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
Published: { bg: 'var(--accent-green-light)', color: 'var(--accent-green)' },
|
||||
'In progress': { bg: 'var(--accent-purple-light)', color: 'var(--accent-purple)' },
|
||||
Blocked: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Cancelled: { bg: 'var(--accent-red-light)', color: 'var(--accent-red)' },
|
||||
Pending: { bg: 'var(--accent-yellow-light)', color: 'var(--accent-yellow)' },
|
||||
}
|
||||
|
||||
export const DEFAULT_STATUS_STYLE: StatusStyle = {
|
||||
bg: 'var(--accent-blue-light)',
|
||||
color: 'var(--muted-foreground)',
|
||||
}
|
||||
|
||||
/** Default suggested statuses shown in the dropdown */
|
||||
export const SUGGESTED_STATUSES = [
|
||||
'Not started',
|
||||
'In progress',
|
||||
'Active',
|
||||
'Done',
|
||||
'Blocked',
|
||||
'Paused',
|
||||
'Draft',
|
||||
'Archived',
|
||||
]
|
||||
|
||||
export function getStatusStyle(status: string): StatusStyle {
|
||||
return STATUS_STYLES[status] ?? DEFAULT_STATUS_STYLE
|
||||
}
|
||||
Reference in New Issue
Block a user