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:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user