Compare commits
9 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cdb3eeea4c | ||
|
|
f68436c2e0 | ||
|
|
7c03c50e25 | ||
|
|
24911b6bb7 | ||
|
|
51914db534 | ||
|
|
e4ffeeccc0 | ||
|
|
8ec33b99b5 | ||
|
|
638678184e | ||
|
|
62af7a3d6e |
21
CLAUDE.md
21
CLAUDE.md
@@ -107,6 +107,27 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
2. Design in light mode. Create `design/<slug>.pen` for the task
|
||||
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Text input | `Input` from shadcn/ui |
|
||||
| Dropdown/select | `Select` from shadcn/ui |
|
||||
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
|
||||
| Button | `Button` from shadcn/ui |
|
||||
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
|
||||
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
|
||||
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
|
||||
| Color picker | Reuse the color swatch picker used for type customization |
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
|
||||
|
||||
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference
|
||||
|
||||
@@ -714,6 +714,30 @@ filters:
|
||||
assert_eq!(views.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view_with_emoji_icon() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let def = ViewDefinition {
|
||||
name: "Monday".to_string(),
|
||||
icon: Some("🗂️".to_string()),
|
||||
color: None,
|
||||
sort: None,
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
})]),
|
||||
};
|
||||
|
||||
save_view(dir.path(), "monday.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Monday");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wikilink_stem_matching() {
|
||||
let yaml = r#"
|
||||
|
||||
17
src/App.tsx
17
src/App.tsx
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { VaultEntry } from './types'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
@@ -76,7 +77,7 @@ function App() {
|
||||
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
const inboxPeriod: InboxPeriod = 'all'
|
||||
const handleSetSelection = useCallback((sel: SidebarSelection) => {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
@@ -345,6 +346,13 @@ function App() {
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
trackEvent(editing ? 'view_updated' : 'view_created')
|
||||
await vault.reloadViews()
|
||||
// Update vault entries so the .yml file appears in FOLDERS immediately
|
||||
const filePath = resolvedPath + '/views/' + filename
|
||||
try {
|
||||
const entry = await target<VaultEntry>('reload_vault_entry', { path: filePath })
|
||||
if (editing) { vault.updateEntry(filePath, entry) } else { vault.addEntry(entry) }
|
||||
} catch { /* non-critical — will appear after next vault reload */ }
|
||||
vault.reloadFolders()
|
||||
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
|
||||
@@ -358,6 +366,9 @@ function App() {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
// Remove the .yml file from vault entries so it disappears from FOLDERS immediately
|
||||
vault.removeEntry(resolvedPath + '/views/' + filename)
|
||||
vault.reloadFolders()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
@@ -564,7 +575,7 @@ function App() {
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} onInboxPeriodChange={setInboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -632,7 +643,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={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} entries={vault.entries} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateViewDialog } from './CreateViewDialog'
|
||||
import type { ViewDefinition } from '../types'
|
||||
@@ -42,4 +42,51 @@ describe('CreateViewDialog', () => {
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes selected emoji icon when creating a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'Test View' } })
|
||||
// Open emoji picker and select an emoji
|
||||
fireEvent.click(screen.getByTitle('Pick icon'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
const emojiButtons = screen.getAllByTestId('emoji-option')
|
||||
fireEvent.click(emojiButtons[0])
|
||||
// Submit the form
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
const definition = onCreate.mock.calls[0][0] as ViewDefinition
|
||||
expect(definition.icon).not.toBeNull()
|
||||
expect(typeof definition.icon).toBe('string')
|
||||
expect(definition.icon!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes null icon when no emoji is selected', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'No Icon View' } })
|
||||
fireEvent.submit(screen.getByText('Create').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: null })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition } from '../types'
|
||||
import type { FilterGroup, ViewDefinition, VaultEntry } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
@@ -13,11 +13,13 @@ interface CreateViewDialogProps {
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => 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}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -71,6 +71,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{searchResults.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
@@ -98,6 +99,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{emojis.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
|
||||
201
src/components/FilterBuilder.test.tsx
Normal file
201
src/components/FilterBuilder.test.tsx
Normal file
@@ -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> = {}): 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(
|
||||
<FilterBuilder
|
||||
group={group ?? defaultGroup}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entries}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '[[Luca' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entriesWithType}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByPlaceholderText('value')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
|
||||
})
|
||||
})
|
||||
@@ -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<string, VaultEntry>) {
|
||||
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<string, VaultEntry>, 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<typeof toWikilinkMatch>
|
||||
|
||||
function extractWikilinkQuery(value: string): string | null {
|
||||
return value.startsWith('[[') ? value.slice(2).replace(/]]$/, '') : null
|
||||
}
|
||||
|
||||
function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], 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<HTMLDivElement | null>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="wikilink-menu"
|
||||
ref={menuRef}
|
||||
style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, zIndex: 50 }}
|
||||
data-testid="wikilink-dropdown"
|
||||
>
|
||||
{matches.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onSelect(item.title)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<HTMLDivElement | null>, 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<HTMLInputElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
onChange(e.target.value)
|
||||
setOpen(e.target.value.startsWith('[['))
|
||||
resetIndex()
|
||||
}, [onChange, resetIndex])
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }} className="flex-1 min-w-0">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8 w-full text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onFocus={() => { if (value.startsWith('[[')) setOpen(true) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="filter-value-input"
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
<WikilinkDropdown
|
||||
matches={matches}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={setSelectedIndex}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
@@ -128,9 +323,10 @@ function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
|
||||
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => 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 (
|
||||
<FilterGroupView
|
||||
group={group}
|
||||
fields={fields}
|
||||
entries={entries ?? []}
|
||||
valueSuggestions={valueSuggestions ?? defaultSuggestions}
|
||||
depth={0}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
@@ -9,7 +9,6 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
import { InboxFilterPills } from './note-list/InboxFilterPills'
|
||||
import { EntityView, ListView } from './note-list/NoteListViews'
|
||||
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
|
||||
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
@@ -44,7 +43,7 @@ interface NoteListProps {
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'month', onInboxPeriodChange, modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
|
||||
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
@@ -59,11 +58,6 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
() => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 },
|
||||
[entries, isInboxView],
|
||||
)
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -117,7 +111,6 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} position="bottom" />}
|
||||
</div>
|
||||
{multiSelect.isMultiSelecting && (
|
||||
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
|
||||
|
||||
@@ -11,7 +11,6 @@ interface InboxFilterPillsProps {
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'quarter', label: 'Quarter' },
|
||||
{ value: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
|
||||
@@ -353,6 +353,28 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('default vault path', () => {
|
||||
it('does not contain CI runner paths', () => {
|
||||
// Regression: production builds must never bake in the CI runner's absolute path
|
||||
expect(DEFAULT_VAULTS[0].path).not.toContain('/Users/runner/')
|
||||
expect(DEFAULT_VAULTS[0].path).not.toContain('/home/runner/')
|
||||
})
|
||||
|
||||
it('keeps persisted active vault when one exists', async () => {
|
||||
const persistedPath = '/Users/luca/MyVault'
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'My Vault', path: persistedPath }],
|
||||
active_vault: persistedPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(persistedPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGettingStartedHidden', () => {
|
||||
it('is false by default', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
@@ -10,10 +10,14 @@ export type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
export const GETTING_STARTED_LABEL = 'Getting Started'
|
||||
|
||||
declare const __DEMO_VAULT_PATH__: string
|
||||
declare const __DEMO_VAULT_PATH__: string | undefined
|
||||
|
||||
/** Build-time demo vault path (dev only). In production Tauri builds this is
|
||||
* undefined and the real path is resolved at runtime via get_default_vault_path. */
|
||||
const STATIC_DEFAULT_PATH = typeof __DEMO_VAULT_PATH__ !== 'undefined' ? __DEMO_VAULT_PATH__ : ''
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: GETTING_STARTED_LABEL, path: typeof __DEMO_VAULT_PATH__ !== 'undefined' ? __DEMO_VAULT_PATH__ : '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
{ label: GETTING_STARTED_LABEL, path: STATIC_DEFAULT_PATH },
|
||||
]
|
||||
|
||||
interface UseVaultSwitcherOptions {
|
||||
@@ -32,14 +36,20 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
|
||||
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
|
||||
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
|
||||
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
|
||||
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [defaultPath, setDefaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
|
||||
const defaultVaults: VaultOption[] = useMemo(
|
||||
() => [{ label: GETTING_STARTED_LABEL, path: defaultPath }],
|
||||
[defaultPath],
|
||||
)
|
||||
|
||||
const visibleDefaults = useMemo(
|
||||
() => DEFAULT_VAULTS.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[hiddenDefaults],
|
||||
() => defaultVaults.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[defaultVaults, hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => [...visibleDefaults, ...extraVaults],
|
||||
@@ -47,8 +57,8 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
)
|
||||
|
||||
const isGettingStartedHidden = useMemo(
|
||||
() => hiddenDefaults.includes(DEFAULT_VAULTS[0].path),
|
||||
[hiddenDefaults],
|
||||
() => hiddenDefaults.includes(defaultPath),
|
||||
[hiddenDefaults, defaultPath],
|
||||
)
|
||||
|
||||
const onSwitchRef = useRef(onSwitch)
|
||||
@@ -60,13 +70,26 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadVaultList()
|
||||
.then(({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
.then(async ({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
if (cancelled) return
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
} else if (!STATIC_DEFAULT_PATH) {
|
||||
// Production build: resolve the Getting Started path at runtime
|
||||
try {
|
||||
const runtimePath = await tauriCall<string>('get_default_vault_path', {})
|
||||
if (!cancelled && runtimePath) {
|
||||
setDefaultPath(runtimePath)
|
||||
setVaultPath(runtimePath)
|
||||
// Keep the module-level export in sync for external consumers
|
||||
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path: runtimePath }
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, command may not exist
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
@@ -116,7 +139,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
}, [addAndSwitch])
|
||||
|
||||
const removeVault = useCallback((path: string) => {
|
||||
const isDefault = DEFAULT_VAULTS.some(v => v.path === path)
|
||||
const isDefault = defaultVaults.some(v => v.path === path)
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path])
|
||||
} else {
|
||||
@@ -127,7 +150,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
setVaultPath(currentPath => {
|
||||
if (currentPath !== path) return currentPath
|
||||
const remaining = [
|
||||
...DEFAULT_VAULTS.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...defaultVaults.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...extraVaults.filter(v => v.path !== path),
|
||||
]
|
||||
if (remaining.length > 0) {
|
||||
@@ -137,26 +160,26 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
return currentPath
|
||||
})
|
||||
|
||||
const vault = [...DEFAULT_VAULTS, ...extraVaults].find(v => v.path === path)
|
||||
const vault = [...defaultVaults, ...extraVaults].find(v => v.path === path)
|
||||
onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`)
|
||||
}, [extraVaults, hiddenDefaults])
|
||||
}, [defaultVaults, extraVaults, hiddenDefaults])
|
||||
|
||||
const restoreGettingStarted = useCallback(async () => {
|
||||
const defaultPath = DEFAULT_VAULTS[0].path
|
||||
const gsPath = defaultPath
|
||||
// Un-hide the Getting Started vault
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== defaultPath))
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== gsPath))
|
||||
// Try to create the vault if it doesn't exist on disk
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: defaultPath })
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: gsPath })
|
||||
if (!exists) {
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: defaultPath })
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: gsPath })
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, creation may fail — that's fine
|
||||
}
|
||||
switchVault(defaultPath)
|
||||
switchVault(gsPath)
|
||||
onToastRef.current('Getting Started vault restored')
|
||||
}, [switchVault])
|
||||
}, [defaultPath, switchVault])
|
||||
|
||||
return {
|
||||
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
|
||||
|
||||
@@ -190,4 +190,58 @@ describe('evaluateView', () => {
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Exact'])
|
||||
})
|
||||
|
||||
it('before operator works with ISO date strings in properties', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Before', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'before', value: '2024-06-01' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
|
||||
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
|
||||
makeEntry({ title: 'NoDate', properties: {} }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Early'])
|
||||
})
|
||||
|
||||
it('after operator works with ISO date strings in properties', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'After', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '2024-06-01' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
|
||||
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Late'])
|
||||
})
|
||||
|
||||
it('before/after works with ISO datetime strings', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Before datetime', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'before', value: '2024-03-15T12:00:00' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Morning', properties: { Date: '2024-03-15T08:00:00' } }),
|
||||
makeEntry({ title: 'Evening', properties: { Date: '2024-03-15T18:00:00' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Morning'])
|
||||
})
|
||||
|
||||
it('before/after works with numeric Unix timestamps', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'After ts', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '2024-01-01' }] },
|
||||
}
|
||||
// Unix timestamp for 2024-06-15 in seconds
|
||||
const ts = Math.floor(new Date('2024-06-15').getTime() / 1000)
|
||||
const entries = [
|
||||
makeEntry({ title: 'Match', properties: { Date: ts } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Match'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,11 +104,17 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
||||
|
||||
// Date comparisons
|
||||
if (op === 'before' || op === 'after') {
|
||||
const ts = typeof resolved.scalar === 'number' ? resolved.scalar : null
|
||||
if (!ts) return false
|
||||
const target = Date.parse(condVal) / 1000
|
||||
let tsMs: number | null = null
|
||||
if (typeof resolved.scalar === 'number') {
|
||||
tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds
|
||||
} else if (typeof resolved.scalar === 'string') {
|
||||
const parsed = Date.parse(resolved.scalar)
|
||||
tsMs = isNaN(parsed) ? null : parsed
|
||||
}
|
||||
if (tsMs == null) return false
|
||||
const target = Date.parse(condVal)
|
||||
if (isNaN(target)) return false
|
||||
return op === 'before' ? ts < target : ts > target
|
||||
return op === 'before' ? tsMs < target : tsMs > target
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
75
tests/smoke/filter-wikilink-autocomplete.spec.ts
Normal file
75
tests/smoke/filter-wikilink-autocomplete.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function openCreateViewDialog(page: Page) {
|
||||
// The VIEWS header has a small + icon. Find the header button containing "VIEWS" text
|
||||
// and the + SVG icon next to it
|
||||
const viewsHeader = page.locator('button:has(span:text("VIEWS"))')
|
||||
await viewsHeader.waitFor({ timeout: 5000 })
|
||||
// The Plus icon is rendered as an SVG inside the same container
|
||||
// Click the SVG child of the VIEWS button container (the + icon)
|
||||
const plusSvg = viewsHeader.locator('svg').last()
|
||||
await plusSvg.click({ force: true })
|
||||
await expect(page.locator('text=Create View')).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
test.describe('Filter wikilink autocomplete', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('typing [[ in filter value field shows wikilink autocomplete', async ({ page }) => {
|
||||
await openCreateViewDialog(page)
|
||||
|
||||
// Default field is 'type' which has valueSuggestions (shows Select, not text input).
|
||||
// Change to 'title' field which has no suggestions → renders WikilinkValueInput.
|
||||
const fieldSelect = page.locator('button:has-text("type")').first()
|
||||
await fieldSelect.click()
|
||||
await page.locator('[role="option"]:has-text("title")').click()
|
||||
|
||||
// The filter value input has data-testid="filter-value-input"
|
||||
const valueInput = page.getByTestId('filter-value-input')
|
||||
await expect(valueInput).toBeVisible()
|
||||
|
||||
// Type [[ without enough chars - dropdown should not appear
|
||||
await valueInput.fill('[[')
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
|
||||
// Type enough characters to trigger the dropdown
|
||||
await valueInput.fill('[[un')
|
||||
await expect(page.getByTestId('wikilink-dropdown')).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Verify dropdown contains note suggestions
|
||||
const dropdownItems = page.locator('.wikilink-menu__item')
|
||||
const count = await dropdownItems.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
// Click a suggestion to select it
|
||||
const firstItem = dropdownItems.first()
|
||||
const itemText = await firstItem.locator('.wikilink-menu__title').textContent()
|
||||
await firstItem.click()
|
||||
|
||||
// Verify the value was set to [[note-title]]
|
||||
const inputValue = await valueInput.inputValue()
|
||||
expect(inputValue).toMatch(/^\[\[.+\]\]$/)
|
||||
expect(inputValue).toContain(itemText?.trim() ?? '')
|
||||
|
||||
// Verify dropdown closed after selection
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('plain text in filter value does not trigger autocomplete', async ({ page }) => {
|
||||
await openCreateViewDialog(page)
|
||||
|
||||
// Change field to 'title' (no suggestions → WikilinkValueInput)
|
||||
const fieldSelect = page.locator('button:has-text("type")').first()
|
||||
await fieldSelect.click()
|
||||
await page.locator('[role="option"]:has-text("title")').click()
|
||||
|
||||
const valueInput = page.getByTestId('filter-value-input')
|
||||
await valueInput.fill('some plain text')
|
||||
|
||||
// No dropdown should appear
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -414,9 +414,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// Inject the demo-vault-v2 path so browser code resolves it relative to the project root
|
||||
// Inject the demo-vault-v2 path in dev mode only — production Tauri builds must
|
||||
// resolve the default vault path at runtime via the backend to avoid baking
|
||||
// the CI runner's absolute path into the distributed bundle.
|
||||
define: {
|
||||
__DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')),
|
||||
...(process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG
|
||||
? {}
|
||||
: { __DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')) }),
|
||||
},
|
||||
|
||||
// Prevent vite from obscuring Rust errors
|
||||
|
||||
Reference in New Issue
Block a user