mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user