Merge pull request #66 from refactoringhq/task/person-mention
feat: person mention — @mention autocomplete linking to Person entries
This commit is contained in:
309
design/person-mention.pen
Normal file
309
design/person-mention.pen
Normal file
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-auto-open",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "Person Mention — Autocomplete Open",
|
||||
"clip": true,
|
||||
"width": 600,
|
||||
"height": 340,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-desc1",
|
||||
"content": "User types @ in the editor. Autocomplete popup appears filtered to Person entries only.",
|
||||
"fontSize": 11,
|
||||
"color": "$--muted-foreground",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-editor-area1",
|
||||
"name": "Editor Area",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"padding": [24, 40],
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-line1",
|
||||
"content": "Meeting notes from today. Discussed with @mat",
|
||||
"fontSize": 15,
|
||||
"color": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-popup1",
|
||||
"name": "Mention Popup",
|
||||
"width": 280,
|
||||
"fill": "$--popover",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"cornerRadius": 6,
|
||||
"layout": "vertical",
|
||||
"padding": [4, 4],
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-item1",
|
||||
"name": "Selected Item",
|
||||
"width": "fill_container",
|
||||
"fill": "$--accent",
|
||||
"cornerRadius": 4,
|
||||
"padding": [6, 10],
|
||||
"gap": 12,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item1-icon",
|
||||
"content": "@",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"color": "$--accent-blue"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item1-title",
|
||||
"content": "Matteo Cellini",
|
||||
"fontSize": 13,
|
||||
"color": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item1-type",
|
||||
"content": "Person",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500",
|
||||
"color": "$--accent-blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-item2",
|
||||
"name": "Unselected Item",
|
||||
"width": "fill_container",
|
||||
"cornerRadius": 4,
|
||||
"padding": [6, 10],
|
||||
"gap": 12,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item2-icon",
|
||||
"content": "@",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"color": "$--muted-foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item2-title",
|
||||
"content": "Maria Bianchi",
|
||||
"fontSize": 13,
|
||||
"color": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item2-type",
|
||||
"content": "Person",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500",
|
||||
"color": "$--accent-blue"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-item3",
|
||||
"name": "Unselected Item 2",
|
||||
"width": "fill_container",
|
||||
"cornerRadius": 4,
|
||||
"padding": [6, 10],
|
||||
"gap": 12,
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item3-icon",
|
||||
"content": "@",
|
||||
"fontSize": 13,
|
||||
"fontWeight": "600",
|
||||
"color": "$--muted-foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item3-title",
|
||||
"content": "Marco Verdi",
|
||||
"fontSize": 13,
|
||||
"color": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-item3-type",
|
||||
"content": "Person",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "500",
|
||||
"color": "$--accent-blue"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-no-results",
|
||||
"x": 620,
|
||||
"y": 0,
|
||||
"name": "Person Mention — No Results",
|
||||
"clip": true,
|
||||
"width": 600,
|
||||
"height": 220,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-desc2",
|
||||
"content": "User types @xyz — no matching Person entries found.",
|
||||
"fontSize": 11,
|
||||
"color": "$--muted-foreground",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-editor-area2",
|
||||
"name": "Editor Area",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"padding": [24, 40],
|
||||
"layout": "vertical",
|
||||
"gap": 8,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-line2",
|
||||
"content": "Need to follow up with @xyz",
|
||||
"fontSize": 15,
|
||||
"color": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-popup2",
|
||||
"name": "Empty Popup",
|
||||
"width": 280,
|
||||
"fill": "$--popover",
|
||||
"stroke": { "align": "inside", "thickness": 1, "fill": "$--border" },
|
||||
"cornerRadius": 6,
|
||||
"padding": [8, 12],
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-empty-text",
|
||||
"content": "No results",
|
||||
"fontSize": 13,
|
||||
"color": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-inserted",
|
||||
"x": 0,
|
||||
"y": 360,
|
||||
"name": "Person Mention — After Selection (Wikilink Inserted)",
|
||||
"clip": true,
|
||||
"width": 600,
|
||||
"height": 180,
|
||||
"fill": "$--background",
|
||||
"layout": "vertical",
|
||||
"gap": 0,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-desc3",
|
||||
"content": "After selecting a person, a wikilink [[Person Name]] is inserted. Renders as clickable link.",
|
||||
"fontSize": 11,
|
||||
"color": "$--muted-foreground",
|
||||
"width": "fill_container",
|
||||
"padding": [8, 12]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-editor-area3",
|
||||
"name": "Editor Area",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"padding": [24, 40],
|
||||
"layout": "vertical",
|
||||
"gap": 4,
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pm-line3-wrap",
|
||||
"name": "Text Line",
|
||||
"width": "fill_container",
|
||||
"gap": 0,
|
||||
"alignItems": "baseline",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-line3a",
|
||||
"content": "Meeting notes from today. Discussed with ",
|
||||
"fontSize": 15,
|
||||
"color": "$--foreground"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-wikilink",
|
||||
"content": "Matteo Cellini",
|
||||
"fontSize": 15,
|
||||
"fontWeight": "500",
|
||||
"color": "$--accent-blue"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-line3b",
|
||||
"content": " about the sponsorship deal.",
|
||||
"fontSize": 15,
|
||||
"color": "$--foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "pm-line3-note",
|
||||
"content": "Stored in markdown as: ...Discussed with [[Matteo Cellini]] about...",
|
||||
"fontSize": 11,
|
||||
"color": "$--muted-foreground",
|
||||
"padding": [8, 0, 0, 0]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variables": {}
|
||||
}
|
||||
@@ -28,13 +28,19 @@ vi.mock('@blocknote/core/extensions', () => ({
|
||||
filterSuggestionItems: (...args: unknown[]) => mockFilterSuggestionItems(...args),
|
||||
}))
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
const capturedGetItemsByTrigger: Record<string, (query: string) => Promise<any[]>> = {}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
let capturedGetItems: ((query: string) => Promise<any[]>) | null = null
|
||||
vi.mock('@blocknote/react', () => ({
|
||||
createReactInlineContentSpec: () => ({ render: () => null }),
|
||||
useCreateBlockNote: () => mockEditor,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
SuggestionMenuController: (props: any) => { capturedGetItems = props.getItems; return null },
|
||||
SuggestionMenuController: (props: any) => {
|
||||
capturedGetItemsByTrigger[props.triggerCharacter] = props.getItems
|
||||
if (props.triggerCharacter === '[[') capturedGetItems = props.getItems
|
||||
return null
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@blocknote/mantine', () => ({
|
||||
@@ -407,3 +413,83 @@ describe('wikilink autocomplete', () => {
|
||||
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
||||
})
|
||||
})
|
||||
|
||||
describe('person @mention autocomplete', () => {
|
||||
const personEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
title: 'Matteo Cellini',
|
||||
filename: 'matteo-cellini.md',
|
||||
path: '/vault/person/matteo-cellini.md',
|
||||
isA: 'Person',
|
||||
aliases: ['Matteo'],
|
||||
}
|
||||
const nonPersonEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
title: 'Build Laputa App',
|
||||
filename: 'laputa-app.md',
|
||||
path: '/vault/project/laputa-app.md',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
}
|
||||
const entries = [personEntry, nonPersonEntry]
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
|
||||
let getPersonItems: ((query: string) => Promise<any[]>) | null = null
|
||||
|
||||
function renderForMention() {
|
||||
mockFilterSuggestionItems.mockClear()
|
||||
mockFilterSuggestionItems.mockImplementation((items: unknown[]) => items)
|
||||
render(
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[mockTab]}
|
||||
activeTabPath={mockEntry.path}
|
||||
entries={entries}
|
||||
/>
|
||||
)
|
||||
getPersonItems = capturedGetItemsByTrigger['@'] ?? null
|
||||
}
|
||||
|
||||
it('registers a SuggestionMenuController with @ trigger', () => {
|
||||
renderForMention()
|
||||
expect(getPersonItems).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns only Person entries for matching query', async () => {
|
||||
renderForMention()
|
||||
const items = await getPersonItems!('Mat')
|
||||
expect(items.length).toBe(1)
|
||||
expect(items[0].title).toBe('Matteo Cellini')
|
||||
})
|
||||
|
||||
it('excludes non-Person entries', async () => {
|
||||
renderForMention()
|
||||
const items = await getPersonItems!('Lap')
|
||||
expect(items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('works with single-character query', async () => {
|
||||
renderForMention()
|
||||
const items = await getPersonItems!('M')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('inserts a wikilink when person item is clicked', async () => {
|
||||
renderForMention()
|
||||
mockEditor.insertInlineContent.mockClear()
|
||||
const items = await getPersonItems!('Matteo')
|
||||
expect(items.length).toBeGreaterThan(0)
|
||||
items[0].onItemClick()
|
||||
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
|
||||
{ type: 'wikilink', props: { target: 'Matteo Cellini' } },
|
||||
' ',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows Person type badge on results', async () => {
|
||||
renderForMention()
|
||||
const items = await getPersonItems!('Matteo')
|
||||
expect(items[0].noteType).toBe('Person')
|
||||
expect(items[0].typeColor).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks, countWords } from '../utils/wikilinks'
|
||||
import { preFilterWikilinks, deduplicateByPath, disambiguateTitles, MAX_RESULTS, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
|
||||
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
|
||||
import { getTypeColor } from '../utils/typeColors'
|
||||
import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkSuggestionMenu'
|
||||
@@ -169,6 +170,31 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
|
||||
}))
|
||||
}, [baseItems, editor])
|
||||
|
||||
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
if (query.length < PERSON_MENTION_MIN_QUERY) return []
|
||||
|
||||
const candidates = filterPersonMentions(baseItems, query)
|
||||
const items = candidates.map(item => ({
|
||||
...item,
|
||||
onItemClick: () => {
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'wikilink' as const,
|
||||
props: { target: item.entryTitle },
|
||||
},
|
||||
" ",
|
||||
])
|
||||
},
|
||||
}))
|
||||
const filtered = filterSuggestionItems(items, query).slice(0, MAX_RESULTS)
|
||||
const final = disambiguateTitles(deduplicateByPath(filtered))
|
||||
return final.map(({ group, ...rest }) => ({
|
||||
...rest,
|
||||
noteType: group,
|
||||
typeColor: getTypeColor(group),
|
||||
}))
|
||||
}, [baseItems, editor])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties}>
|
||||
{isDragOver && (
|
||||
@@ -187,6 +213,12 @@ function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: { e
|
||||
suggestionMenuComponent={WikilinkSuggestionMenu}
|
||||
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
|
||||
/>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="@"
|
||||
getItems={getPersonMentionItems}
|
||||
suggestionMenuComponent={WikilinkSuggestionMenu}
|
||||
onItemClick={(item: WikilinkSuggestionItem) => item.onItemClick()}
|
||||
/>
|
||||
</BlockNoteView>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -432,6 +432,49 @@ Belongs to:
|
||||
# AI Agents Primer
|
||||
|
||||
AI agents are autonomous systems that can plan, execute, and adapt to achieve goals.
|
||||
`,
|
||||
'/Users/luca/Laputa/person/maria-bianchi.md': `---
|
||||
title: Maria Bianchi
|
||||
type: Person
|
||||
aliases:
|
||||
- Maria
|
||||
---
|
||||
|
||||
# Maria Bianchi
|
||||
|
||||
## Role
|
||||
Product designer — leads UX research and design sprints for the app.
|
||||
|
||||
## Contact
|
||||
- Email: maria@example.com
|
||||
- Slack: @maria
|
||||
`,
|
||||
'/Users/luca/Laputa/person/marco-verdi.md': `---
|
||||
title: Marco Verdi
|
||||
type: Person
|
||||
aliases:
|
||||
- Marco
|
||||
---
|
||||
|
||||
# Marco Verdi
|
||||
|
||||
## Role
|
||||
Frontend engineer — focuses on React performance and accessibility.
|
||||
|
||||
## Contact
|
||||
- Email: marco@example.com
|
||||
`,
|
||||
'/Users/luca/Laputa/person/elena-russo.md': `---
|
||||
title: Elena Russo
|
||||
type: Person
|
||||
aliases:
|
||||
- Elena
|
||||
---
|
||||
|
||||
# Elena Russo
|
||||
|
||||
## Role
|
||||
Content strategist — plans newsletter topics and manages the editorial calendar.
|
||||
`,
|
||||
'/Users/luca/Laputa/type/project.md': `---
|
||||
type: Type
|
||||
|
||||
@@ -250,6 +250,81 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
color: null,
|
||||
order: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/maria-bianchi.md',
|
||||
filename: 'maria-bianchi.md',
|
||||
title: 'Maria Bianchi',
|
||||
isA: 'Person',
|
||||
aliases: ['Maria'],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 3,
|
||||
createdAt: now - 86400 * 150,
|
||||
fileSize: 280,
|
||||
snippet: 'Product designer — leads UX research and design sprints for the app.',
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/marco-verdi.md',
|
||||
filename: 'marco-verdi.md',
|
||||
title: 'Marco Verdi',
|
||||
isA: 'Person',
|
||||
aliases: ['Marco'],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 5,
|
||||
createdAt: now - 86400 * 120,
|
||||
fileSize: 240,
|
||||
snippet: 'Frontend engineer — focuses on React performance and accessibility.',
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person/elena-russo.md',
|
||||
filename: 'elena-russo.md',
|
||||
title: 'Elena Russo',
|
||||
isA: 'Person',
|
||||
aliases: ['Elena'],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: now - 86400 * 10,
|
||||
createdAt: now - 86400 * 90,
|
||||
fileSize: 200,
|
||||
snippet: 'Content strategist — plans newsletter topics and manages the editorial calendar.',
|
||||
relationships: {
|
||||
'Type': ['[[type/person]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/event/2026-02-14-laputa-app-kickoff.md',
|
||||
filename: '2026-02-14-laputa-app-kickoff.md',
|
||||
|
||||
60
src/utils/personMentionSuggestions.test.ts
Normal file
60
src/utils/personMentionSuggestions.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from './personMentionSuggestions'
|
||||
import type { WikilinkBaseItem } from './wikilinkSuggestions'
|
||||
|
||||
const items: WikilinkBaseItem[] = [
|
||||
{ title: 'Matteo Cellini', aliases: ['Matteo'], group: 'Person', entryTitle: 'Matteo Cellini', path: '/person/matteo.md' },
|
||||
{ title: 'Maria Bianchi', aliases: ['Maria'], group: 'Person', entryTitle: 'Maria Bianchi', path: '/person/maria.md' },
|
||||
{ title: 'Build Laputa App', aliases: ['Laputa'], group: 'Project', entryTitle: 'Build Laputa App', path: '/project/laputa.md' },
|
||||
{ title: 'Grow Newsletter', aliases: [], group: 'Responsibility', entryTitle: 'Grow Newsletter', path: '/resp/newsletter.md' },
|
||||
{ title: 'Elena Russo', aliases: ['Elena'], group: 'Person', entryTitle: 'Elena Russo', path: '/person/elena.md' },
|
||||
]
|
||||
|
||||
describe('filterPersonMentions', () => {
|
||||
it('returns only Person entries matching query on title', () => {
|
||||
const result = filterPersonMentions(items, 'mat')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Matteo Cellini')
|
||||
})
|
||||
|
||||
it('matches on aliases', () => {
|
||||
const result = filterPersonMentions(items, 'Elena')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Elena Russo')
|
||||
})
|
||||
|
||||
it('excludes non-Person entries even if they match the query', () => {
|
||||
const result = filterPersonMentions(items, 'Lap')
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
const result = filterPersonMentions(items, 'MARIA')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].title).toBe('Maria Bianchi')
|
||||
})
|
||||
|
||||
it('returns multiple matches', () => {
|
||||
const result = filterPersonMentions(items, 'ma')
|
||||
expect(result).toHaveLength(2)
|
||||
const titles = result.map(r => r.title)
|
||||
expect(titles).toContain('Matteo Cellini')
|
||||
expect(titles).toContain('Maria Bianchi')
|
||||
})
|
||||
|
||||
it('returns empty for query shorter than minimum', () => {
|
||||
const result = filterPersonMentions(items, '')
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('works with single-character query (min query is 1)', () => {
|
||||
expect(PERSON_MENTION_MIN_QUERY).toBe(1)
|
||||
const result = filterPersonMentions(items, 'e')
|
||||
expect(result).toHaveLength(2) // Matteo (alias) + Elena
|
||||
})
|
||||
|
||||
it('returns empty when no persons match', () => {
|
||||
const result = filterPersonMentions(items, 'zzz')
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
22
src/utils/personMentionSuggestions.ts
Normal file
22
src/utils/personMentionSuggestions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { WikilinkBaseItem } from './wikilinkSuggestions'
|
||||
|
||||
/** Person mentions need only 1 character to start suggesting (smaller candidate set). */
|
||||
export const PERSON_MENTION_MIN_QUERY = 1
|
||||
|
||||
/**
|
||||
* Pre-filter person mention candidates: only items whose group is 'Person',
|
||||
* matched by case-insensitive substring on title or aliases.
|
||||
*/
|
||||
export function filterPersonMentions<T extends WikilinkBaseItem>(
|
||||
items: T[],
|
||||
query: string,
|
||||
): T[] {
|
||||
if (query.length < PERSON_MENTION_MIN_QUERY) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return items.filter(item =>
|
||||
item.group === 'Person' && (
|
||||
item.title.toLowerCase().includes(lowerQuery) ||
|
||||
item.aliases.some(a => a.toLowerCase().includes(lowerQuery))
|
||||
)
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user