diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx
new file mode 100644
index 0000000000..7a8bfe2624
--- /dev/null
+++ b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx
@@ -0,0 +1,365 @@
+/*
+ * Copyright 2021 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ApiProvider } from '@backstage/core-app-api';
+import { TestApiRegistry } from '@backstage/test-utils';
+import { screen, render, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+import { searchApiRef } from '../../apis';
+import { SearchContextProvider, useSearch } from '../SearchContext';
+import { SearchFilter } from './SearchFilter';
+
+const SearchContextFilterSpy = ({ name }: { name: string }) => {
+ const { filters } = useSearch();
+ const value = filters[name];
+ return (
+
+ {Array.isArray(value) ? value.join(',') : value}
+
+ );
+};
+
+describe('SearchFilter.Autocomplete', () => {
+ const query = jest.fn().mockResolvedValue({});
+ const mockApis = TestApiRegistry.from([searchApiRef, { query }]);
+ const emptySearchContext = {
+ term: '',
+ types: [],
+ filters: {},
+ };
+
+ const name = 'field';
+ const values = ['value1', 'value2'];
+
+ it('renders as expected', async () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+ userEvent.click(input);
+
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+
+ expect(screen.getByRole('option', { name: values[0] })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: values[1] })).toBeInTheDocument();
+ });
+
+ it('renders as expected with async values', async () => {
+ render(
+
+
+ values}
+ />
+
+ ,
+ );
+
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+ userEvent.click(input);
+
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+
+ expect(screen.getByRole('option', { name: values[0] })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: values[1] })).toBeInTheDocument();
+ });
+
+ it('does not affect unrelated filter state', async () => {
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ // The spy should show the initial value.
+ expect(screen.getByTestId('unrelated-filter-spy')).toHaveTextContent(
+ 'value',
+ );
+
+ // Select a value from the autocomplete filter.
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+ userEvent.click(input);
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+ userEvent.click(screen.getByRole('option', { name: values[1] }));
+
+ // Wait for the autocomplete filter's value to change.
+ await waitFor(() => {
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values[1],
+ );
+ });
+
+ // Unrelated filter spy should maintain the same value.
+ expect(screen.getByTestId('unrelated-filter-spy')).toHaveTextContent(
+ 'value',
+ );
+ });
+
+ describe('single', () => {
+ it('renders as expected with defaultValue', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+
+ await waitFor(() => {
+ expect(input).toHaveValue(values[1]);
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values[1],
+ );
+ });
+ });
+
+ it('renders as expected with initial context', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+
+ await waitFor(() => {
+ expect(input).toHaveValue(values[0]);
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values[0],
+ );
+ });
+ });
+
+ it('sets filter state when selecting a value', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ // Select the first option in the autocomplete.
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+ userEvent.click(input);
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+ userEvent.click(screen.getByRole('option', { name: values[0] }));
+
+ // The value should be present in the context.
+ await waitFor(() => {
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values[0],
+ );
+ });
+
+ // Click the "Clear" button to remove the value.
+ const clearButton = within(autocomplete).getByLabelText('Clear');
+ userEvent.click(clearButton);
+
+ // That value should have been unset from the context.
+ await waitFor(() => {
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent('');
+ });
+ });
+ });
+
+ describe('multiple', () => {
+ it('renders as expected with defaultValue', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(values[0])).toBeInTheDocument();
+ expect(screen.getByText(values[1])).toBeInTheDocument();
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values.join(','),
+ );
+ });
+ });
+
+ it('renders as expected with initial context', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(values[0])).toBeInTheDocument();
+ expect(screen.getByText(values[1])).toBeInTheDocument();
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values.join(','),
+ );
+ });
+ });
+
+ it('respects tag limit configuration', async () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ const autocomplete = screen.getByRole('combobox');
+ const input = within(autocomplete).getByRole('textbox');
+
+ // Select the second value.
+ userEvent.click(input);
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+ userEvent.click(screen.getByRole('option', { name: values[1] }));
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: values[1] }),
+ ).toBeInTheDocument();
+ });
+
+ // Select the first value.
+ userEvent.click(input);
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+ userEvent.click(screen.getByRole('option', { name: values[0] }));
+ await waitFor(() => {
+ expect(
+ screen.getByRole('button', { name: values[0] }),
+ ).toBeInTheDocument();
+ });
+
+ // Blur the field and only one tag should be shown with a +1.
+ input.blur();
+ expect(
+ screen.queryByRole('button', { name: values[0] }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText('+1')).toBeInTheDocument();
+ });
+
+ it('sets filter state when selecting a value', async () => {
+ render(
+
+
+
+
+
+ ,
+ );
+
+ const autocomplete = screen.getByRole('combobox');
+
+ // Select both values in the autocomplete.
+ const input = within(autocomplete).getByRole('textbox');
+ userEvent.click(input);
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+ userEvent.click(screen.getByRole('option', { name: values[0] }));
+ userEvent.click(input);
+ await waitFor(() => {
+ screen.getByRole('listbox');
+ });
+ userEvent.click(screen.getByRole('option', { name: values[1] }));
+
+ // Both options should be present in the context.
+ await waitFor(() => {
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent(
+ values.join(','),
+ );
+ });
+
+ // Click the "Clear" button to remove the value.
+ const clearButton = within(autocomplete).getByLabelText('Clear');
+ userEvent.click(clearButton);
+
+ // There should be no content in the filter context.
+ await waitFor(() => {
+ expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent('');
+ });
+ });
+ });
+});
diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx
index e8e62b1929..bf0745864b 100644
--- a/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx
+++ b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-import React, { ChangeEvent, useState } from 'react';
+import React, { ChangeEvent, useEffect, useState } from 'react';
import { Chip, TextField } from '@material-ui/core';
import {
Autocomplete,
@@ -38,6 +38,7 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
asyncValues,
asyncDebounce,
className,
+ defaultValue,
multiple,
name,
values: givenValues,
@@ -51,7 +52,23 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
givenValues,
asyncDebounce,
);
- const { setFilters } = useSearch();
+ const { filters, setFilters } = useSearch();
+ const filterValue =
+ (filters[name] as string | string[] | undefined) || (multiple ? [] : null);
+
+ useEffect(() => {
+ const defaultIsEmpty = !defaultValue;
+ const defaultIsEmptyArray =
+ Array.isArray(defaultValue) && defaultValue.length === 0;
+
+ if (!defaultIsEmpty && !defaultIsEmptyArray) {
+ setFilters(prevFilters => ({
+ ...prevFilters,
+ [name]: defaultValue,
+ }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
// Set new filter values on input change.
const handleChange = (
@@ -92,10 +109,11 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
setInputValue(newValue)}
renderInput={renderInput}
diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx
index 8f716caf4b..aa1b7e5e88 100644
--- a/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx
+++ b/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx
@@ -207,6 +207,34 @@ describe('SearchFilter', () => {
).toBeInTheDocument();
});
+ it('Renders values when provided asynchronously', async () => {
+ render(
+
+ values} />
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByRole('button')).toBeInTheDocument();
+ expect(
+ screen.getByRole('button').getAttribute('aria-disabled'),
+ ).not.toBe('true');
+ });
+
+ userEvent.click(screen.getByRole('button'));
+
+ await waitFor(() => {
+ expect(screen.getByRole('listbox')).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByRole('option', { name: values[0] }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('option', { name: values[1] }),
+ ).toBeInTheDocument();
+ });
+
it('Renders correctly based on filter state', async () => {
render(