feat(434): facet-value typeahead combobox for text fields

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-23 20:43:27 +02:00
co-authored by Claude Opus 4.8
parent 8d805ecbd8
commit 13bfcebd04
3 changed files with 102 additions and 1 deletions
+15
View File
@@ -15,6 +15,21 @@ export function getSearchFields(): Promise<SearchField[]> {
return request<SearchField[]>('/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<string[]> {
const searchParams = new URLSearchParams();
searchParams.set('q', q);
searchParams.set('limit', String(limit));
return request<SearchFieldValues>(`/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.
+36 -1
View File
@@ -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',
+51
View File
@@ -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 `<datalist>` 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<string[]>([]);
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 (
<>
<input
aria-label="Value"
className="ctv-input"
value={value}
onChange={(e) => onChange(e.target.value)}
type="text"
list={listId}
autoComplete="off"
/>
<datalist id={listId}>
{suggestions.map((s) => (
<option key={s} value={s} />
))}
</datalist>
</>
);
}
function defaultRule(fields: RuleField[]): Rule {
const field = fields[0]?.name ?? 'title';
const type = typeOf(fields, field);
@@ -124,6 +173,8 @@ function RuleRow({
))}
</select>
</>
) : type === 'text' ? (
<TextValueInput field={rule.field} value={rule.value} onChange={(v) => onChange({ ...rule, value: v })} />
) : (
<input
aria-label="Value"