diff --git a/.changeset/search-crying-in-hmart.md b/.changeset/search-crying-in-hmart.md new file mode 100644 index 0000000000..b6df59658e --- /dev/null +++ b/.changeset/search-crying-in-hmart.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-search': minor +--- + +The way labels are controlled on both the `` and +`` components has changed. Previously, the string passed +on the `name` prop (which controls the field being filtered on) was also +rendered as the field label. Now, if you want a label rendered, it must be +passed on the new `label` prop. If no `label` is provided, no label will be +rendered. diff --git a/.changeset/search-something-new.md b/.changeset/search-something-new.md new file mode 100644 index 0000000000..58a817e3ad --- /dev/null +++ b/.changeset/search-something-new.md @@ -0,0 +1,76 @@ +--- +'@backstage/create-app': patch +--- + +An example instance of a `` with asynchronously loaded values was added to the composed `SearchPage.tsx`, allowing searches bound to the `techdocs` type to be filtered by entity name. + +This is an entirely optional change; if you wish to adopt it, you can make the following (or similar) changes to your search page layout: + +```diff +--- a/packages/app/src/components/search/SearchPage.tsx ++++ b/packages/app/src/components/search/SearchPage.tsx +@@ -2,6 +2,10 @@ import React from 'react'; + import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core'; + + import { CatalogResultListItem } from '@backstage/plugin-catalog'; ++import { ++ catalogApiRef, ++ CATALOG_FILTER_EXISTS, ++} from '@backstage/plugin-catalog-react'; + import { DocsResultListItem } from '@backstage/plugin-techdocs'; + + import { +@@ -10,6 +14,7 @@ import { + SearchResult, + SearchType, + DefaultResultListItem, ++ useSearch, + } from '@backstage/plugin-search'; + import { + CatalogIcon, +@@ -18,6 +23,7 @@ import { + Header, + Page, + } from '@backstage/core-components'; ++import { useApi } from '@backstage/core-plugin-api'; + + const useStyles = makeStyles((theme: Theme) => ({ + bar: { +@@ -36,6 +42,8 @@ const useStyles = makeStyles((theme: Theme) => ({ + + const SearchPage = () => { + const classes = useStyles(); ++ const { types } = useSearch(); ++ const catalogApi = useApi(catalogApiRef); + + return ( + +@@ -65,6 +73,27 @@ const SearchPage = () => { + ]} + /> + ++ {types.includes('techdocs') && ( ++ { ++ // Return a list of entities which are documented. ++ const { items } = await catalogApi.getEntities({ ++ fields: ['metadata.name'], ++ filter: { ++ 'metadata.annotations.backstage.io/techdocs-ref': ++ CATALOG_FILTER_EXISTS, ++ }, ++ }); ++ ++ const names = items.map(entity => entity.metadata.name); ++ names.sort(); ++ return names; ++ }} ++ /> ++ )} + ` components in order to allow +user-friendly label strings (as well as the option to omit a label). In order +to maintain labels on your existing filters, add a `label` prop to them in your +`SearchPage.tsx`. + +```diff +--- a/packages/app/src/components/search/SearchPage.tsx ++++ b/packages/app/src/components/search/SearchPage.tsx +@@ -96,11 +96,13 @@ const SearchPage = () => { + )} + + +``` diff --git a/.changeset/search-under-the-sun.md b/.changeset/search-under-the-sun.md new file mode 100644 index 0000000000..3f97a60c1c --- /dev/null +++ b/.changeset/search-under-the-sun.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-search': patch +--- + +Introduces a `` variant, which can be used as either a single- or multi-select autocomplete filter. + +This variant, as well as ``, now also supports loading allowed values asynchronously by passing a function that resolves the list of values to the `values` prop. (An optional `valuesDebounceMs` prop may also be provided to control the debounce time). + +Check the [search plugin storybook](https://backstage.io/storybook/?path=/story/plugins-search-searchfilter) to see how to leverage these new additions. diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index 9a8c208103..d4e60244d7 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -23,7 +23,12 @@ import { Page, SidebarPinStateContext, } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + CATALOG_FILTER_EXISTS, +} from '@backstage/plugin-catalog-react'; import { DefaultResultListItem, SearchBar, @@ -31,6 +36,7 @@ import { SearchResult, SearchResultPager, SearchType, + useSearch, } from '@backstage/plugin-search'; import { DocsResultListItem } from '@backstage/plugin-techdocs'; import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core'; @@ -54,6 +60,8 @@ const useStyles = makeStyles((theme: Theme) => ({ const SearchPage = () => { const classes = useStyles(); const { isMobile } = useContext(SidebarPinStateContext); + const { types } = useSearch(); + const catalogApi = useApi(catalogApiRef); return ( @@ -84,13 +92,36 @@ const SearchPage = () => { ]} /> + {types.includes('techdocs') && ( + { + // Return a list of entities which are documented. + const { items } = await catalogApi.getEntities({ + fields: ['metadata.name'], + filter: { + 'metadata.annotations.backstage.io/techdocs-ref': + CATALOG_FILTER_EXISTS, + }, + }); + + const names = items.map(entity => entity.metadata.name); + names.sort(); + return names; + }} + /> + )} diff --git a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx index cf380b6f5c..a88e7250e9 100644 --- a/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/components/search/SearchPage.tsx @@ -2,6 +2,10 @@ import React from 'react'; import { makeStyles, Theme, Grid, List, Paper } from '@material-ui/core'; import { CatalogResultListItem } from '@backstage/plugin-catalog'; +import { + catalogApiRef, + CATALOG_FILTER_EXISTS, +} from '@backstage/plugin-catalog-react'; import { DocsResultListItem } from '@backstage/plugin-techdocs'; import { @@ -10,6 +14,7 @@ import { SearchResult, SearchType, DefaultResultListItem, + useSearch, } from '@backstage/plugin-search'; import { CatalogIcon, @@ -18,6 +23,7 @@ import { Header, Page, } from '@backstage/core-components'; +import { useApi } from '@backstage/core-plugin-api'; const useStyles = makeStyles((theme: Theme) => ({ bar: { @@ -36,6 +42,8 @@ const useStyles = makeStyles((theme: Theme) => ({ const SearchPage = () => { const classes = useStyles(); + const { types } = useSearch(); + const catalogApi = useApi(catalogApiRef); return ( @@ -65,13 +73,36 @@ const SearchPage = () => { ]} /> + {types.includes('techdocs') && ( + { + // Return a list of entities which are documented. + const { items } = await catalogApi.getEntities({ + fields: ['metadata.name'], + filter: { + 'metadata.annotations.backstage.io/techdocs-ref': + CATALOG_FILTER_EXISTS, + }, + }); + + const names = items.map(entity => entity.metadata.name); + names.sort(); + return names; + }} + /> + )} diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 0a7da01ffd..fc82e2be47 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -92,6 +92,13 @@ export interface SearchApi { // @public (undocumented) export const searchApiRef: ApiRef; +// @public (undocumented) +export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { + filterSelectedOptions?: boolean; + limitTags?: number; + multiple?: boolean; +}; + // @public export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element; @@ -143,18 +150,48 @@ export const SearchContextProvider: ({ // // @public (undocumented) export const SearchFilter: { - ({ component: Element, ...props }: Props): JSX.Element; - Checkbox(props: Omit & Component): JSX.Element; - Select(props: Omit & Component): JSX.Element; + ({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element; + Checkbox( + props: Omit & + SearchFilterComponentProps, + ): JSX.Element; + Select( + props: Omit & + SearchFilterComponentProps, + ): JSX.Element; + Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element; +}; + +// @public (undocumented) +export type SearchFilterComponentProps = { + className?: string; + name: string; + label?: string; + values?: string[] | ((partial: string) => Promise); + defaultValue?: string[] | string | null; + valuesDebounceMs?: number; }; // Warning: (ae-missing-release-tag) "SearchFilterNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) export const SearchFilterNext: { - ({ component: Element, ...props }: Props): JSX.Element; - Checkbox(props: Omit & Component): JSX.Element; - Select(props: Omit & Component): JSX.Element; + ({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element; + Checkbox( + props: Omit & + SearchFilterComponentProps, + ): JSX.Element; + Select( + props: Omit & + SearchFilterComponentProps, + ): JSX.Element; + Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element; +}; + +// @public (undocumented) +export type SearchFilterWrapperProps = SearchFilterComponentProps & { + component: (props: SearchFilterComponentProps) => ReactElement; + debug?: boolean; }; // Warning: (ae-missing-release-tag) "SearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -284,6 +321,4 @@ export const useSearch: () => SearchContextValue; // Warnings were encountered during analysis: // // src/components/SearchContext/SearchContext.d.ts:23:5 - (ae-forgotten-export) The symbol "SettableSearchContext" needs to be exported by the entry point index.d.ts -// src/components/SearchFilter/SearchFilter.d.ts:13:5 - (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// src/components/SearchFilter/SearchFilter.d.ts:14:5 - (ae-forgotten-export) The symbol "Component" needs to be exported by the entry point index.d.ts ``` 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..5c774c0d2f --- /dev/null +++ b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.test.tsx @@ -0,0 +1,360 @@ +/* + * 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 { TestApiProvider } 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 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 new file mode 100644 index 0000000000..9a27f63565 --- /dev/null +++ b/plugins/search/src/components/SearchFilter/SearchFilter.Autocomplete.tsx @@ -0,0 +1,116 @@ +/* + * 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 React, { ChangeEvent, useState } from 'react'; +import { Chip, TextField } from '@material-ui/core'; +import { + Autocomplete, + AutocompleteGetTagProps, + AutocompleteRenderInputParams, +} from '@material-ui/lab'; +import { useSearch } from '../SearchContext'; +import { useAsyncFilterValues, useDefaultFilterValue } from './hooks'; +import { SearchFilterComponentProps } from './SearchFilter'; + +/** + * @public + */ +export type SearchAutocompleteFilterProps = SearchFilterComponentProps & { + filterSelectedOptions?: boolean; + limitTags?: number; + multiple?: boolean; +}; + +export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => { + const { + className, + defaultValue, + name, + values: givenValues, + valuesDebounceMs, + label, + filterSelectedOptions, + limitTags, + multiple, + } = props; + const [inputValue, setInputValue] = useState(''); + useDefaultFilterValue(name, defaultValue); + const asyncValues = + typeof givenValues === 'function' ? givenValues : undefined; + const defaultValues = + typeof givenValues === 'function' ? undefined : givenValues; + const { value: values, loading } = useAsyncFilterValues( + asyncValues, + inputValue, + defaultValues, + valuesDebounceMs, + ); + const { filters, setFilters } = useSearch(); + const filterValue = + (filters[name] as string | string[] | undefined) || (multiple ? [] : null); + + // Set new filter values on input change. + const handleChange = ( + _: ChangeEvent<{}>, + newValue: string | string[] | null, + ) => { + setFilters(prevState => { + const { [name]: filter, ...others } = prevState; + + if (newValue) { + return { ...others, [name]: newValue }; + } + return { ...others }; + }); + }; + + // Provide the input field. + const renderInput = (params: AutocompleteRenderInputParams) => ( + + ); + + // Render tags as primary-colored chips. + const renderTags = ( + tagValue: string[], + getTagProps: AutocompleteGetTagProps, + ) => + tagValue.map((option: string, index: number) => ( + + )); + + return ( + setInputValue(newValue)} + renderInput={renderInput} + renderTags={renderTags} + /> + ); +}; diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx index d6fc70716e..c43753e8d2 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.stories.tsx @@ -50,9 +50,75 @@ export const SelectFilter = () => { return ( ); }; + +export const AsyncSelectFilter = () => { + return ( + + { + const response = await fetch('https://swapi.dev/api/planets'); + const json: { results: Array<{ name: string }> } = + await response.json(); + return json.results.map(r => r.name); + }} + /> + + ); +}; + +export const Autocomplete = () => { + return ( + + + + ); +}; + +export const MultiSelectAutocomplete = () => { + return ( + + + + ); +}; + +export const AsyncMultiSelectAutocomplete = () => { + return ( + + { + if (partial === '') return []; + const response = await fetch( + `https://swapi.dev/api/people?search=${encodeURIComponent( + partial, + )}`, + ); + const json: { results: Array<{ name: string }> } = + await response.json(); + return json.results.map(r => r.name); + }} + /> + + ); +}; diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx index 8f716caf4b..7b997088ec 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.test.tsx @@ -34,6 +34,7 @@ describe('SearchFilter', () => { types: [], }; + const label = 'Field'; const name = 'field'; const values = ['value1', 'value2']; const filters = { unrelated: 'unrelated' }; @@ -57,12 +58,12 @@ describe('SearchFilter', () => { it('Renders field name and values when provided as props', async () => { render( - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); expect( @@ -83,12 +84,12 @@ describe('SearchFilter', () => { }, }} > - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); expect( @@ -101,6 +102,7 @@ describe('SearchFilter', () => { render( { ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); expect(screen.getByRole('checkbox', { name: values[0] })).toBeChecked(); @@ -121,12 +123,12 @@ describe('SearchFilter', () => { it('Checking / unchecking a value sets filter state', async () => { render( - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); const checkBox = screen.getByRole('checkbox', { name: values[0] }); @@ -151,12 +153,12 @@ describe('SearchFilter', () => { it('Checking / unchecking a value maintains unrelated filter state', async () => { render( - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); const checkBox = screen.getByRole('checkbox', { name: values[0] }); @@ -185,12 +187,44 @@ describe('SearchFilter', () => { it('Renders field name and values when provided as props', async () => { render( - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + 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 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')); @@ -217,12 +251,12 @@ describe('SearchFilter', () => { }, }} > - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); userEvent.click(screen.getByRole('button')); @@ -248,6 +282,7 @@ describe('SearchFilter', () => { @@ -255,7 +290,7 @@ describe('SearchFilter', () => { ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); userEvent.click(screen.getByRole('button')); @@ -279,12 +314,12 @@ describe('SearchFilter', () => { it('Selecting a value sets filter state', async () => { render( - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); const button = screen.getByRole('button'); @@ -330,12 +365,12 @@ describe('SearchFilter', () => { filters, }} > - + , ); await waitFor(() => { - expect(screen.getByText(name)).toBeInTheDocument(); + expect(screen.getByText(label)).toBeInTheDocument(); }); const button = screen.getByRole('button'); diff --git a/plugins/search/src/components/SearchFilter/SearchFilter.tsx b/plugins/search/src/components/SearchFilter/SearchFilter.tsx index 52b03c785b..1308333100 100644 --- a/plugins/search/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search/src/components/SearchFilter/SearchFilter.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactElement, ChangeEvent, useEffect } from 'react'; +import React, { ReactElement, ChangeEvent } from 'react'; import { makeStyles, FormControl, @@ -26,7 +26,12 @@ import { FormLabel, } from '@material-ui/core'; +import { + AutocompleteFilter, + SearchAutocompleteFilterProps, +} from './SearchFilter.Autocomplete'; import { useSearch } from '../SearchContext'; +import { useAsyncFilterValues, useDefaultFilterValue } from './hooks'; const useStyles = makeStyles({ label: { @@ -34,36 +39,58 @@ const useStyles = makeStyles({ }, }); -export type Component = { +/** + * @public + */ +export type SearchFilterComponentProps = { className?: string; name: string; - values?: string[]; + label?: string; + /** + * Either an array of values directly, or an async function to return a list + * of values to be used in the filter. In the autocomplete filter, the last + * input value is provided as an input to allow values to be filtered. This + * function is debounced and values cached. + */ + values?: string[] | ((partial: string) => Promise); defaultValue?: string[] | string | null; + /** + * Debounce time in milliseconds, used when values is an async callback. + * Defaults to 250ms. + */ + valuesDebounceMs?: number; }; -export type Props = Component & { - component: (props: Component) => ReactElement; +/** + * @public + */ +export type SearchFilterWrapperProps = SearchFilterComponentProps & { + component: (props: SearchFilterComponentProps) => ReactElement; debug?: boolean; }; -const CheckboxFilter = ({ - className, - name, - defaultValue, - values = [], -}: Component) => { +const CheckboxFilter = (props: SearchFilterComponentProps) => { + const { + className, + defaultValue, + label, + name, + values: givenValues = [], + valuesDebounceMs, + } = props; const classes = useStyles(); const { filters, setFilters } = useSearch(); - - useEffect(() => { - if (Array.isArray(defaultValue)) { - setFilters(prevFilters => ({ - ...prevFilters, - [name]: defaultValue, - })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + useDefaultFilterValue(name, defaultValue); + const asyncValues = + typeof givenValues === 'function' ? givenValues : undefined; + const defaultValues = + typeof givenValues === 'function' ? undefined : givenValues; + const { value: values = [], loading } = useAsyncFilterValues( + asyncValues, + '', + defaultValues, + valuesDebounceMs, + ); const handleChange = (e: ChangeEvent) => { const { @@ -81,10 +108,11 @@ const CheckboxFilter = ({ return ( - {name} + {label ? {label} : null} {values.map((value: string) => ( { +const SelectFilter = (props: SearchFilterComponentProps) => { + const { + className, + defaultValue, + label, + name, + values: givenValues, + valuesDebounceMs, + } = props; const classes = useStyles(); + useDefaultFilterValue(name, defaultValue); + const asyncValues = + typeof givenValues === 'function' ? givenValues : undefined; + const defaultValues = + typeof givenValues === 'function' ? undefined : givenValues; + const { value: values = [], loading } = useAsyncFilterValues( + asyncValues, + '', + defaultValues, + valuesDebounceMs, + ); const { filters, setFilters } = useSearch(); - useEffect(() => { - if (typeof defaultValue === 'string') { - setFilters(prevFilters => ({ - ...prevFilters, - [name]: defaultValue, - })); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - const handleChange = (e: ChangeEvent<{ value: unknown }>) => { const { target: { value }, @@ -138,14 +170,17 @@ const SelectFilter = ({ return ( - - {name} - + {label ? ( + + {label} + + ) : null}