From cf40058cb6d6d4459d9068825e06c48a30cc2361 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:43:27 +0000 Subject: [PATCH] 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 --- .../components/ui/ContentFilterChipInput.vue | 60 ++++++++++-- .../__tests__/ContentFilterChipInput.spec.ts | 94 +++++++++++++++++++ .../accent-insensitive-search.utils.test.ts | 13 ++- .../utils/accent-insensitive-search.utils.ts | 4 + .../modules/catalog/catalog.service.test.ts | 19 +++- server/src/modules/catalog/catalog.service.ts | 11 ++- 6 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 client/src/components/ui/__tests__/ContentFilterChipInput.spec.ts diff --git a/client/src/components/ui/ContentFilterChipInput.vue b/client/src/components/ui/ContentFilterChipInput.vue index 409f0a6e6..a15269493 100644 --- a/client/src/components/ui/ContentFilterChipInput.vue +++ b/client/src/components/ui/ContentFilterChipInput.vue @@ -19,19 +19,45 @@ const query = ref('') const results = ref([]) const showDropdown = ref(false) let debounceTimer: ReturnType +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 { + 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) +})