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}