Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,28 @@ export type AutocompleteConnectorParams = {
transformItems?: (
indices: TransformItemsIndicesConfig[]
) => TransformItemsIndicesConfig[];
/**
* Enable usage of future Autocomplete behavior.
*/
future?: {
/**
* When set to true, `currentRefinement` is `undefined` when no query has
* been set (instead of an empty string). This lets consumers distinguish
* between "initial/submitted state" and "user explicitly cleared the input".
*
* @default `false`
*/
undefinedEmptyQuery?: boolean;
};
};

export type AutocompleteRenderState = {
/**
* The current value of the query.
* When `future.undefinedEmptyQuery` is `true`, this is `undefined` when no
* query has been set yet (e.g. on init or after submit).
*/
currentRefinement: string;
currentRefinement: string | undefined;

/**
* The indices this widget has access to.
Expand Down Expand Up @@ -111,6 +126,7 @@ const connectAutocomplete: AutocompleteConnector = function connectAutocomplete(
transformItems = ((indices) => indices) as NonNullable<
AutocompleteConnectorParams['transformItems']
>,
future: { undefinedEmptyQuery = false } = {},
} = widgetParams || {};

warning(
Expand Down Expand Up @@ -221,7 +237,9 @@ search.addWidgets([
});

return {
currentRefinement: state.query || '',
currentRefinement: undefinedEmptyQuery
? state.query
: state.query || '',
indices: transformItems(indices).map((transformedIndex) => ({
...transformedIndex,
sendEvent: sendEventMap[transformedIndex.indexId],
Expand All @@ -232,9 +250,11 @@ search.addWidgets([
},

getWidgetUiState(uiState, { searchParameters }) {
const query = searchParameters.query || '';
const query = undefinedEmptyQuery
? searchParameters.query
: searchParameters.query || '';

if (query === '' || (uiState && uiState.query === query)) {
if (!query || query === '' || (uiState && uiState.query === query)) {
return uiState;
}

Expand All @@ -246,7 +266,7 @@ search.addWidgets([

getWidgetSearchParameters(searchParameters, { uiState }) {
const parameters = {
query: uiState.query || '',
query: undefinedEmptyQuery ? uiState.query : uiState.query || '',
};

if (!escapeHTML) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -383,16 +383,15 @@ function AutocompleteWrapper<TItem extends BaseHit>({
const searchboxQuery = isolatedIndex?.getHelper()?.state.query;
const targetIndexQuery = targetIndex?.getHelper()?.state.query;

// Local query state for immediate updates (especially for detached search button)
const [localQuery, setLocalQuery] = useState(
searchboxQuery || targetIndexQuery || ''
searchboxQuery !== undefined ? searchboxQuery : targetIndexQuery ?? ''
);

// Sync local query with searchbox query when it changes externally
useEffect(() => {
// If the isolated index has a query, use it (user typing).
// If not, fall back to the target index query (URL/main state).
const query = searchboxQuery || targetIndexQuery;
// When the isolated index has a defined query (including ''), use it.
// Only fall back to the target index query when not yet set (undefined).
const query =
searchboxQuery !== undefined ? searchboxQuery : targetIndexQuery;
if (query !== undefined) {
setLocalQuery(query);
}
Expand Down Expand Up @@ -1275,7 +1274,11 @@ export function EXPERIMENTAL_autocomplete<TItem extends BaseHit = BaseHit>(
])
),
{
...makeWidget({ escapeHTML, transformItems }),
...makeWidget({
escapeHTML,
transformItems,
future: { undefinedEmptyQuery: true },
}),
$$widgetType: 'ais.autocomplete',
},
]),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export type AutocompleteSearchProps = {
clearQuery: () => void;
onQueryChange?: (query: string) => void;
query: string;
refine: (query: string) => void;
isSearchStalled: boolean;
onAiModeClick?: () => void;
};
Expand All @@ -23,7 +22,6 @@ export function AutocompleteSearch({
clearQuery,
onQueryChange,
query,
refine,
isSearchStalled,
onAiModeClick,
}: AutocompleteSearchProps) {
Expand All @@ -33,7 +31,6 @@ export function AutocompleteSearch({
...(inputProps as NonNullable<AutocompleteSearchProps['inputProps']>),
onChange: (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value;
refine(value);
onQueryChange?.(value);
},
}}
Expand Down
17 changes: 10 additions & 7 deletions packages/react-instantsearch/src/widgets/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -622,8 +622,14 @@ function InnerAutocomplete<TItem extends BaseHit = BaseHit>({
currentRefinement,
} = useAutocomplete({
transformItems,
future: { undefinedEmptyQuery: true },
});

const resolvedQuery =
currentRefinement !== undefined
? currentRefinement
: indexUiState.query ?? '';

const { isDetached, isModalDetached, isModalOpen, setIsModalOpen } =
useDetachedMode(detachedMediaQuery);
const previousIsDetachedRef = useRef(isDetached);
Expand Down Expand Up @@ -878,18 +884,15 @@ function InnerAutocomplete<TItem extends BaseHit = BaseHit>({
onQueryChange={(query) => {
refineAutocomplete(query);
}}
query={currentRefinement || indexUiState.query || ''}
refine={refineSearchBox}
query={resolvedQuery}
isSearchStalled={isSearchStalled}
onAiModeClick={
aiMode
? () => {
if (chatRenderState) {
chatRenderState.setOpen?.(true);
const query =
currentRefinement || indexUiState.query || '';
if (query.trim()) {
chatRenderState.sendMessage?.({ text: query });
if (resolvedQuery.trim()) {
chatRenderState.sendMessage?.({ text: resolvedQuery });
}
}
}
Expand Down Expand Up @@ -932,7 +935,7 @@ function InnerAutocomplete<TItem extends BaseHit = BaseHit>({
classNames={classNames}
>
<AutocompleteDetachedSearchButton
query={currentRefinement || indexUiState.query || ''}
query={resolvedQuery}
placeholder={placeholder}
classNames={classNames}
onClick={() => {
Expand Down
197 changes: 197 additions & 0 deletions tests/common/widgets/autocomplete/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,138 @@ export function createOptionsTests(
]);
});

test('only triggers one search when typing, not a duplicate on the parent', async () => {
const searchClient = createMockedSearchClient(
createMultiSearchResponse(
createSingleSearchResponse({
index: 'indexName',
hits: [
{ objectID: '1', name: 'Item 1' },
{ objectID: '2', name: 'Item 2' },
],
})
)
);

await setup({
instantSearchOptions: {
indexName: 'indexName',
searchClient,
},
widgetParams: {
javascript: {
indices: [
{
indexName: 'indexName',
templates: {
item: (props) => props.item.name,
},
},
],
},
react: {
indices: [
{
indexName: 'indexName',
itemComponent: (props) => props.item.name,
},
],
},
vue: {},
},
});

await act(async () => {
await wait(0);
});

(searchClient.search as jest.Mock).mockClear();

const input = screen.getByRole('combobox', { name: /submit/i });

await act(async () => {
await userEvent.click(input);
await userEvent.paste(input, 'hello');
await wait(0);
});

expect(searchClient.search).toHaveBeenCalledTimes(1);
expect(searchClient.search).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({
params: expect.objectContaining({
query: 'hello',
}),
}),
])
);
});

test('triggers a search on the parent index when submitting', async () => {
const searchClient = createMockedSearchClient(
createMultiSearchResponse(
createSingleSearchResponse({
index: 'indexName',
hits: [
{ objectID: '1', name: 'Item 1' },
{ objectID: '2', name: 'Item 2' },
],
})
)
);

await setup({
instantSearchOptions: {
indexName: 'indexName',
searchClient,
},
widgetParams: {
javascript: {
indices: [
{
indexName: 'indexName',
templates: {
item: (props) => props.item.name,
},
},
],
},
react: {
indices: [
{
indexName: 'indexName',
itemComponent: (props) => props.item.name,
},
],
},
vue: {},
},
});

await act(async () => {
await wait(0);
});

const input = screen.getByRole('combobox', { name: /submit/i });

await act(async () => {
await userEvent.click(input);
await userEvent.paste(input, 'hello');
await wait(0);
});

(searchClient.search as jest.Mock).mockClear();

await act(async () => {
userEvent.keyboard('{Enter}');
await wait(0);
});

expect(
(searchClient.search as jest.Mock).mock.calls.length
).toBeGreaterThanOrEqual(2);
});

test('closes the panel then blurs the input when pressing enter', async () => {
const searchClient = createMockedSearchClient(
createMultiSearchResponse(
Expand Down Expand Up @@ -954,6 +1086,71 @@ export function createOptionsTests(
]);
});

test('does not show the submitted query after clearing the input', async () => {
const searchClient = createMockedSearchClient(
createMultiSearchResponse(
createSingleSearchResponse({
index: 'indexName',
hits: [
{ objectID: '1', name: 'Item 1' },
{ objectID: '2', name: 'Item 2' },
],
})
)
);

await setup({
instantSearchOptions: {
indexName: 'indexName',
searchClient,
},
widgetParams: {
javascript: {
indices: [
{
indexName: 'indexName',
templates: {
item: (props) => props.item.name,
},
},
],
},
react: {
indices: [
{
indexName: 'indexName',
itemComponent: (props) => props.item.name,
},
],
},
vue: {},
},
});

await act(async () => {
await wait(0);
});

const input = screen.getByRole('combobox', { name: /submit/i });

// Type and submit a query
await act(async () => {
userEvent.click(input);
userEvent.type(input, 'hello');
userEvent.keyboard('{Enter}');
await wait(0);
});

// Clear the input by typing
await act(async () => {
userEvent.click(input);
userEvent.clear(input);
await wait(0);
});

expect(input).toHaveValue('');
});

test('refocuses the input after clearing the query', async () => {
const searchClient = createMockedSearchClient(
createMultiSearchResponse(
Expand Down
Loading