diff --git a/web/src/api/search.ts b/web/src/api/search.ts index 313e46ad9..44d12b457 100644 --- a/web/src/api/search.ts +++ b/web/src/api/search.ts @@ -15,6 +15,21 @@ export function getSearchFields(): Promise { return request('/api/v1/search/fields'); } +export type SearchFieldValues = components['schemas']['SearchFieldValuesResponseModel']; + +// Backs the rule builder's facet-value typeahead (#434): distinct values a text field already holds +// in the library, matching the in-progress query prefix. `.values` is non-nullable on the DTO, but we +// still coerce defensively at the boundary (Core-DTO-nullable convention) in case a future model +// change reintroduces nullability. +export function getSearchFieldValues(name: string, q: string, limit = 50): Promise { + const searchParams = new URLSearchParams(); + searchParams.set('q', q); + searchParams.set('limit', String(limit)); + return request(`/api/v1/search/fields/${encodeURIComponent(name)}/values?${searchParams.toString()}`).then( + (result) => result.values ?? [] + ); +} + // The ten media-item id arrays a search query resolves to, one bucket per addable kind. Wire keys // match AddItemsToCollectionRequest exactly, so a result pipes straight into addItemsToCollection / // addItemsToPlaylist via toAddItemsRequestFromSearch below. diff --git a/web/src/builder/rules/RuleBuilder.test.tsx b/web/src/builder/rules/RuleBuilder.test.tsx index f33ab67f4..5151b5e95 100644 --- a/web/src/builder/rules/RuleBuilder.test.tsx +++ b/web/src/builder/rules/RuleBuilder.test.tsx @@ -1,12 +1,20 @@ import { useState } from 'react'; -import { cleanup, render, screen, fireEvent } from '@testing-library/react'; +import { cleanup, render, screen, fireEvent, act } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { RuleBuilder } from './RuleBuilder'; import type { RuleField } from './fieldCatalog'; import type { Group } from './types'; +const getSearchFieldValues = vi.fn().mockResolvedValue([]); +vi.mock('../../api/search', () => ({ + getSearchFieldValues: (...args: unknown[]) => getSearchFieldValues(...args) +})); + afterEach(() => { cleanup(); + vi.useRealTimers(); + getSearchFieldValues.mockReset(); + getSearchFieldValues.mockResolvedValue([]); }); const FIELDS: RuleField[] = [ @@ -103,6 +111,33 @@ describe('RuleBuilder', () => { expect(screen.queryByLabelText('Match')).not.toBeInTheDocument(); }); + it('offers facet-value suggestions for a text field and still accepts free text (#434)', async () => { + vi.useFakeTimers(); + getSearchFieldValues.mockResolvedValue(['Action', 'Adventure']); + + const { container } = setupControlled({ match: 'all', children: [{ field: 'genre', operator: 'is', value: '' }] }); + + const input = screen.getByLabelText('Value') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Ac' } }); + expect(input.value).toBe('Ac'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + + expect(getSearchFieldValues).toHaveBeenCalledWith('genre', 'Ac'); + const options = Array.from(container.querySelectorAll('datalist option')).map((o) => (o as HTMLOptionElement).value); + expect(options).toEqual(['Action', 'Adventure']); + + // Selecting a suggestion (a native datalist selection fires the same input/change event a keystroke would). + fireEvent.change(input, { target: { value: 'Adventure' } }); + expect(input.value).toBe('Adventure'); + + // A free-typed value that isn't in the suggestion list is still retained (free-text fallback). + fireEvent.change(input, { target: { value: 'Something else entirely' } }); + expect(input.value).toBe('Something else entirely'); + }); + it('shows an all-negative warning for the root group', () => { setup({ match: 'all', diff --git a/web/src/builder/rules/RuleBuilder.tsx b/web/src/builder/rules/RuleBuilder.tsx index 16a1e9b16..42b9bfea0 100644 --- a/web/src/builder/rules/RuleBuilder.tsx +++ b/web/src/builder/rules/RuleBuilder.tsx @@ -1,5 +1,7 @@ +import { useEffect, useId, useRef, useState } from 'react'; import { Plus, Trash2 } from 'lucide-react'; import { Badge, Button, IconButton } from '../../components'; +import { getSearchFieldValues } from '../../api/search'; import type { RuleField } from './fieldCatalog'; import { isGroup, OPERATORS_BY_TYPE, type DateUnit, type FieldType, type Group, type Operator, type Rule } from './types'; import { normalizeGroup, ruleError, topGroupAllNegative } from './validation'; @@ -34,6 +36,53 @@ function typeOf(fields: RuleField[], name: string): FieldType { return fields.find((f) => f.name === name)?.type ?? 'text'; } +// Facet-value typeahead for text-field rules (#434): backed by a `` so free-text is always +// accepted alongside the server-suggested values (the query preview count is the safety net for a +// value that doesn't match anything). Debounces the lookup ~200ms after each keystroke and drops any +// response that arrives after a newer request was issued or the field changed underneath it. +function TextValueInput({ field, value, onChange }: { field: string; value: string; onChange: (v: string) => void }) { + const [suggestions, setSuggestions] = useState([]); + const seqRef = useRef(0); + const listId = useId(); + + useEffect(() => { + const seq = ++seqRef.current; + const handle = window.setTimeout(() => { + getSearchFieldValues(field, value.trim()) + .then((values) => { + if (seqRef.current === seq) { + setSuggestions(values); + } + }) + .catch(() => { + if (seqRef.current === seq) { + setSuggestions([]); + } + }); + }, 200); + return () => window.clearTimeout(handle); + }, [field, value]); + + return ( + <> + onChange(e.target.value)} + type="text" + list={listId} + autoComplete="off" + /> + + {suggestions.map((s) => ( + + + ); +} + function defaultRule(fields: RuleField[]): Rule { const field = fields[0]?.name ?? 'title'; const type = typeOf(fields, field); @@ -124,6 +173,8 @@ function RuleRow({ ))} + ) : type === 'text' ? ( + onChange({ ...rule, value: v })} /> ) : (