fix(users): select exact content restriction matches

Prioritize exact genre and tag results within bounded catalog searches. Resolve Enter presses against existing IDs and ignore stale lookup responses.

Fixes #1295
This commit is contained in:
neon
2026-09-08 15:43:27 +00:00
parent 3d6afb3149
commit cf40058cb6
6 changed files with 185 additions and 16 deletions
@@ -19,19 +19,45 @@ const query = ref('')
const results = ref<Item[]>([])
const showDropdown = ref(false)
let debounceTimer: ReturnType<typeof setTimeout>
let latestRequestId = 0
function comparableName(value: string): string {
return value
.normalize('NFD')
.replace(/\p{Mark}/gu, '')
.replace(/\s+/gu, ' ')
.trim()
.toLocaleLowerCase()
}
function findExactResult(items: readonly Item[], value: string): Item | undefined {
const comparableValue = comparableName(value)
return items.find((item) => comparableName(item.name) === comparableValue)
}
async function search(queryValue: string): Promise<Item[] | null> {
const requestId = ++latestRequestId
const selectedIds = new Set(props.modelValue.map((item) => item.id))
const response = await props.searchFn(queryValue)
if (requestId !== latestRequestId || query.value !== queryValue) return null
const available = response.filter((item) => !selectedIds.has(item.id))
results.value = available
showDropdown.value = available.length > 0
return available
}
async function onInput() {
clearTimeout(debounceTimer)
if (!query.value.trim()) {
latestRequestId++
results.value = []
showDropdown.value = false
return
}
debounceTimer = setTimeout(async () => {
const selectedIds = new Set(props.modelValue.map((i) => i.id))
const res = await props.searchFn(query.value)
results.value = res.filter((r) => !selectedIds.has(r.id))
showDropdown.value = results.value.length > 0
const queryValue = query.value
debounceTimer = setTimeout(() => {
void search(queryValue)
}, 200)
}
@@ -39,6 +65,8 @@ function addItem(item: Item) {
if (!props.modelValue.some((v) => v.id === item.id)) {
emit('update:modelValue', [...props.modelValue, item])
}
latestRequestId++
clearTimeout(debounceTimer)
query.value = ''
results.value = []
showDropdown.value = false
@@ -51,8 +79,21 @@ function removeItem(id: number) {
)
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Backspace' && !query.value && props.modelValue.length > 0) {
async function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && query.value.trim()) {
e.preventDefault()
const queryValue = query.value
const loadedExactMatch = findExactResult(results.value, queryValue)
if (loadedExactMatch) {
addItem(loadedExactMatch)
return
}
clearTimeout(debounceTimer)
const available = await search(queryValue)
const fetchedExactMatch = available ? findExactResult(available, queryValue) : undefined
if (fetchedExactMatch) addItem(fetchedExactMatch)
} else if (e.key === 'Backspace' && !query.value && props.modelValue.length > 0) {
emit('update:modelValue', props.modelValue.slice(0, -1))
}
}
@@ -63,7 +104,10 @@ function onBlur() {
}, 150)
}
onUnmounted(() => clearTimeout(debounceTimer))
onUnmounted(() => {
latestRequestId++
clearTimeout(debounceTimer)
})
</script>
<template>
@@ -0,0 +1,94 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import ContentFilterChipInput from '../ContentFilterChipInput.vue'
interface Item {
id: number
name: string
}
function mountInput(searchFn: (query: string) => Promise<Item[]>, modelValue: Item[] = []) {
return mount(ContentFilterChipInput, { props: { modelValue, searchFn } })
}
describe('ContentFilterChipInput', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('fetches immediately and selects an exact existing item when Enter is pressed', async () => {
const exact = { id: 21, name: 'Young Adult' }
const partials = Array.from({ length: 15 }, (_, index) => ({ id: index + 1, name: `Young Adult category ${index + 1}` }))
const searchFn = vi.fn<(query: string) => Promise<Item[]>>().mockResolvedValue([...partials, exact])
const wrapper = mountInput(searchFn)
const input = wrapper.get('input')
await input.setValue('young adult')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(searchFn).toHaveBeenCalledExactlyOnceWith('young adult')
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[exact]])
expect((input.element as HTMLInputElement).value).toBe('')
})
it('uses a loaded exact match without issuing a duplicate request', async () => {
const exact = { id: 7, name: 'Caf\u00e9 Society' }
const searchFn = vi.fn<(query: string) => Promise<Item[]>>().mockResolvedValue([{ id: 8, name: 'Cafe Society Stories' }, exact])
const wrapper = mountInput(searchFn)
const input = wrapper.get('input')
await input.setValue(' CAFE SOCIETY ')
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
await input.trigger('keydown', { key: 'Enter' })
expect(searchFn).toHaveBeenCalledOnce()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[exact]])
})
it('does not create a restriction when no exact catalog item exists', async () => {
const partials = [
{ id: 1, name: 'Young Adult Fiction' },
{ id: 2, name: 'Fiction for Young Adults' },
]
const searchFn = vi.fn<(query: string) => Promise<Item[]>>().mockResolvedValue(partials)
const wrapper = mountInput(searchFn)
const input = wrapper.get('input')
await input.setValue('Young Adult')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(wrapper.findAll('li').map((item) => item.text())).toEqual(partials.map((item) => item.name))
expect((input.element as HTMLInputElement).value).toBe('Young Adult')
})
it('ignores an older response after a newer exact match has been selected', async () => {
let resolveOlder!: (items: Item[]) => void
const olderResponse = new Promise<Item[]>((resolve) => {
resolveOlder = resolve
})
const exact = { id: 21, name: 'Young Adult' }
const searchFn = vi.fn<(query: string) => Promise<Item[]>>().mockReturnValueOnce(olderResponse).mockResolvedValueOnce([exact])
const wrapper = mountInput(searchFn)
const input = wrapper.get('input')
await input.setValue('Young')
await vi.advanceTimersByTimeAsync(200)
await input.setValue('Young Adult')
await input.trigger('keydown', { key: 'Enter' })
await flushPromises()
resolveOlder([{ id: 1, name: 'Young Readers' }])
await flushPromises()
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[exact]])
expect(wrapper.findAll('li')).toHaveLength(0)
expect((input.element as HTMLInputElement).value).toBe('')
})
})
@@ -1,6 +1,6 @@
import { PgDialect, pgTable, text } from 'drizzle-orm/pg-core';
import { accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from './accent-insensitive-search.utils';
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern, escapeLikePattern } from './accent-insensitive-search.utils';
const records = pgTable('records', {
name: text('name'),
@@ -25,6 +25,17 @@ describe('accentInsensitiveIlike', () => {
});
});
describe('accentInsensitiveExactMatchRank', () => {
it('ranks accent-insensitive, case-insensitive exact matches before partial matches', () => {
const dialect = new PgDialect();
const query = dialect.sqlToQuery(accentInsensitiveExactMatchRank(records.name, 'Garcia'));
expect(query.sql).toBe('case when lower(public.bookorbit_unaccent("records"."name")) = lower(public.bookorbit_unaccent($1)) then 0 else 1 end');
expect(query.params).toEqual(['Garcia']);
});
});
describe('escapeLikePattern', () => {
it('escapes every LIKE metacharacter', () => {
expect(escapeLikePattern('100%_off\\now')).toBe('100\\%\\_off\\\\now');
@@ -8,6 +8,10 @@ export function accentInsensitiveIlike(value: SQLWrapper, pattern: string | SQLW
return sql`public.bookorbit_unaccent(${value}) ILIKE public.bookorbit_unaccent(${pattern})`;
}
export function accentInsensitiveExactMatchRank(value: SQLWrapper, term: string): SQL<number> {
return sql<number>`case when lower(public.bookorbit_unaccent(${value})) = lower(public.bookorbit_unaccent(${term})) then 0 else 1 end`;
}
export function escapeLikePattern(value: string): string {
return value.replace(/[\\%_]/g, '\\$&');
}
@@ -8,13 +8,14 @@ vi.mock('drizzle-orm', () => ({
vi.mock('../../common/utils/accent-insensitive-search.utils', async (importOriginal) => ({
...(await importOriginal<typeof import('../../common/utils/accent-insensitive-search.utils')>()),
accentInsensitiveExactMatchRank: vi.fn((left: unknown, term: string) => ({ type: 'sql', left, term })),
accentInsensitiveIlike: vi.fn((left: unknown, pattern: string) => ({ type: 'accentInsensitiveIlike', left, pattern })),
}));
import { and, eq, isNotNull } from 'drizzle-orm';
import { accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import { authors, bookMetadata, bookSeries, collections, narrators } from '../../db/schema';
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike } from '../../common/utils/accent-insensitive-search.utils';
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
import { CatalogService } from './catalog.service';
interface QueryChain<T> {
@@ -114,6 +115,20 @@ describe('CatalogService', () => {
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
});
it.each([
['genres', genres, (service: CatalogService, q: string) => service.searchGenres(q)],
['tags', tags, (service: CatalogService, q: string) => service.searchTags(q)],
] as const)('ranks an exact %s match before alphabetical partial matches', async (_label, table, search) => {
const { service, selectChains } = makeService();
await search(service, ' Young\u00a0 Adult ');
expect(accentInsensitiveIlike).toHaveBeenCalledWith(table.name, '%Young Adult%');
expect(accentInsensitiveExactMatchRank).toHaveBeenCalledWith(table.name, 'Young Adult');
expect(selectChains[0]?.orderBy).toHaveBeenCalledWith(expect.objectContaining({ type: 'sql' }), table.name);
expect(selectChains[0]?.limit).toHaveBeenCalledWith(15);
});
it('uses distinct metadata lookup for publishers and filters null rows defensively', async () => {
const { service, setSelectDistinctRows, selectDistinctChains } = makeService();
setSelectDistinctRows([{ name: 'Orbit' }, { name: null }, { name: 'Tor' }]);
@@ -3,7 +3,8 @@ import { and, eq, isNotNull } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import { accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
import { accentInsensitiveExactMatchRank, accentInsensitiveIlike, buildSearchPattern } from '../../common/utils/accent-insensitive-search.utils';
import { normalizeMetadataText } from '../../common/utils/metadata-text-normalize.utils';
import * as schema from '../../db/schema';
import { authors, bookMetadata, bookSeries, collections, genres, narrators, tags } from '../../db/schema';
@@ -74,14 +75,14 @@ export class CatalogService {
}
private searchByNameWithId(q: string, table: NamedTableWithId): Promise<SearchResultWithId[]> {
const pattern = this.toContainsPattern(q);
if (!pattern) return Promise.resolve([]);
const term = normalizeMetadataText(q);
if (!term) return Promise.resolve([]);
return this.db
.select({ id: table.id, name: table.name })
.from(table)
.where(accentInsensitiveIlike(table.name, pattern))
.orderBy(table.name)
.where(accentInsensitiveIlike(table.name, buildSearchPattern(term)))
.orderBy(accentInsensitiveExactMatchRank(table.name, term), table.name)
.limit(DEFAULT_SEARCH_LIMIT);
}