feat: add body content filter for Views (contains/does not contain)
Adds 'body' as a built-in filter field that searches note snippet text (case-insensitive). Visually separated from property fields in the FilterBuilder dropdown with a separator. Combines with other filters via AND. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -376,7 +376,7 @@ function App() {
|
||||
}, [resolvedPath, vault, selection, handleSetSelection])
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
const builtIn = ['type', 'status', 'title', 'favorite']
|
||||
const builtIn = ['type', 'status', 'title', 'favorite', 'body']
|
||||
if (!vault.entries?.length) return builtIn
|
||||
const customFields = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
|
||||
@@ -198,4 +198,16 @@ describe('FilterBuilder wikilink autocomplete', () => {
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
|
||||
})
|
||||
|
||||
it('shows body field in field dropdown separated from property fields', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'body', op: 'contains', value: 'test' }] }}
|
||||
onChange={vi.fn()}
|
||||
availableFields={['type', 'status', 'body']}
|
||||
/>,
|
||||
)
|
||||
// Body field should be selected as the current value
|
||||
expect(screen.getByText('body')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types'
|
||||
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeIcon } from './NoteItem'
|
||||
@@ -38,12 +38,16 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
function FieldSelect({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const isCustom = value !== '' && !fields.includes(value)
|
||||
const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f))
|
||||
const contentFields = fields.filter(f => CONTENT_FIELDS.has(f))
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
@@ -54,9 +58,17 @@ function FieldSelect({ value, fields, onChange }: {
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{fields.map((f) => (
|
||||
{propertyFields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
{contentFields.length > 0 && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
{contentFields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
|
||||
@@ -244,4 +244,61 @@ describe('evaluateView', () => {
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Match'])
|
||||
})
|
||||
|
||||
it('body contains filters on snippet text (case-insensitive)', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Body search', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'body', op: 'contains', value: 'quarterly' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Match', snippet: 'This is the quarterly review summary' }),
|
||||
makeEntry({ title: 'No match', snippet: 'Daily standup notes' }),
|
||||
makeEntry({ title: 'Case match', snippet: 'QUARTERLY PLANNING session' }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Match', 'Case match'])
|
||||
})
|
||||
|
||||
it('body not_contains excludes matching notes', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Body exclude', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'body', op: 'not_contains', value: 'draft' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Final', snippet: 'Final version of the document' }),
|
||||
makeEntry({ title: 'Draft', snippet: 'This is a draft version' }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Final'])
|
||||
})
|
||||
|
||||
it('body filter combines with property filters (AND)', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Combined', icon: null, color: null, sort: null,
|
||||
filters: { all: [
|
||||
{ field: 'type', op: 'equals', value: 'Note' },
|
||||
{ field: 'body', op: 'contains', value: 'important' },
|
||||
] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Yes', isA: 'Note', snippet: 'This is important content' }),
|
||||
makeEntry({ title: 'Wrong type', isA: 'Project', snippet: 'This is important content' }),
|
||||
makeEntry({ title: 'No match', isA: 'Note', snippet: 'Regular content' }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Yes'])
|
||||
})
|
||||
|
||||
it('body is_empty matches notes with empty snippet', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Empty body', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'body', op: 'is_empty' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Empty', snippet: '' }),
|
||||
makeEntry({ title: 'Has content', snippet: 'Some text here' }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Empty'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,6 +29,7 @@ function resolveField(entry: VaultEntry, field: string): { scalar?: string | num
|
||||
if (lower === 'archived') return { scalar: entry.archived }
|
||||
if (lower === 'trashed') return { scalar: entry.trashed }
|
||||
if (lower === 'favorite') return { scalar: entry.favorite }
|
||||
if (lower === 'body') return { scalar: entry.snippet }
|
||||
|
||||
// Check relationships first (returns string[])
|
||||
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === lower)
|
||||
|
||||
Reference in New Issue
Block a user