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(