Merge pull request #8697 from backstage/search/filter-autocomplete
Introduce a <SearchFilter.Autocomplete /> Component
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/plugin-search': minor
|
||||
---
|
||||
|
||||
The way labels are controlled on both the `<SearchFilter.Checkbox />` and
|
||||
`<SearchFilter.Select />` 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.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
An example instance of a `<SearchFilter.Select />` 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 (
|
||||
<Page themeId="home">
|
||||
@@ -65,6 +73,27 @@ const SearchPage = () => {
|
||||
]}
|
||||
/>
|
||||
<Paper className={classes.filters}>
|
||||
+ {types.includes('techdocs') && (
|
||||
+ <SearchFilter.Select
|
||||
+ className={classes.filter}
|
||||
+ label="Entity"
|
||||
+ name="name"
|
||||
+ values={async () => {
|
||||
+ // 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;
|
||||
+ }}
|
||||
+ />
|
||||
+ )}
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
name="kind"
|
||||
```
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
A `label` prop was added to `<SearchFilter.* />` 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 = () => {
|
||||
)}
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
+ label="Kind"
|
||||
name="kind"
|
||||
values={['Component', 'Template']}
|
||||
/>
|
||||
<SearchFilter.Checkbox
|
||||
className={classes.filter}
|
||||
+ label="Lifecycle"
|
||||
name="lifecycle"
|
||||
values={['experimental', 'production']}
|
||||
/>
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
---
|
||||
|
||||
Introduces a `<SearchFilter.Autocomplete />` variant, which can be used as either a single- or multi-select autocomplete filter.
|
||||
|
||||
This variant, as well as `<SearchFilter.Select />`, 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.
|
||||
@@ -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 (
|
||||
<Page themeId="home">
|
||||
@@ -84,13 +92,36 @@ const SearchPage = () => {
|
||||
]}
|
||||
/>
|
||||
<Paper className={classes.filters}>
|
||||
{types.includes('techdocs') && (
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
label="Entity"
|
||||
name="name"
|
||||
values={async () => {
|
||||
// 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;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
label="Kind"
|
||||
name="kind"
|
||||
values={['Component', 'Template']}
|
||||
/>
|
||||
<SearchFilter.Checkbox
|
||||
className={classes.filter}
|
||||
label="Lifecycle"
|
||||
name="lifecycle"
|
||||
values={['experimental', 'production']}
|
||||
/>
|
||||
|
||||
+31
@@ -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 (
|
||||
<Page themeId="home">
|
||||
@@ -65,13 +73,36 @@ const SearchPage = () => {
|
||||
]}
|
||||
/>
|
||||
<Paper className={classes.filters}>
|
||||
{types.includes('techdocs') && (
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
label="Entity"
|
||||
name="name"
|
||||
values={async () => {
|
||||
// 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;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SearchFilter.Select
|
||||
className={classes.filter}
|
||||
label="Kind"
|
||||
name="kind"
|
||||
values={['Component', 'Template']}
|
||||
/>
|
||||
<SearchFilter.Checkbox
|
||||
className={classes.filter}
|
||||
label="Lifecycle"
|
||||
name="lifecycle"
|
||||
values={['experimental', 'production']}
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,13 @@ export interface SearchApi {
|
||||
// @public (undocumented)
|
||||
export const searchApiRef: ApiRef<SearchApi>;
|
||||
|
||||
// @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<Props, 'component'> & Component): JSX.Element;
|
||||
Select(props: Omit<Props, 'component'> & Component): JSX.Element;
|
||||
({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element;
|
||||
Checkbox(
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
): JSX.Element;
|
||||
Select(
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
): JSX.Element;
|
||||
Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchFilterComponentProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
values?: string[] | ((partial: string) => Promise<string[]>);
|
||||
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<Props, 'component'> & Component): JSX.Element;
|
||||
Select(props: Omit<Props, 'component'> & Component): JSX.Element;
|
||||
({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element;
|
||||
Checkbox(
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
): JSX.Element;
|
||||
Select(
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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 (
|
||||
<span data-testid={`${name}-filter-spy`}>
|
||||
{Array.isArray(value) ? value.join(',') : value}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete name={name} values={values} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete name={name} values={async () => values} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
...emptySearchContext,
|
||||
...{ filters: { unrelated: 'value' } },
|
||||
}}
|
||||
>
|
||||
<SearchFilter.Autocomplete name={name} values={values} />
|
||||
<SearchContextFilterSpy name={name} />
|
||||
<SearchContextFilterSpy name="unrelated" />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete
|
||||
name={name}
|
||||
values={values}
|
||||
defaultValue={values[1]}
|
||||
/>
|
||||
<SearchContextFilterSpy name={name} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
...emptySearchContext,
|
||||
...{ filters: { [name]: values[0] } },
|
||||
}}
|
||||
>
|
||||
<SearchFilter.Autocomplete name={name} values={values} />
|
||||
<SearchContextFilterSpy name={name} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete name={name} values={values} />
|
||||
<SearchContextFilterSpy name={name} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete
|
||||
multiple
|
||||
name={name}
|
||||
values={values}
|
||||
defaultValue={values}
|
||||
/>
|
||||
<SearchContextFilterSpy name={name} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
...emptySearchContext,
|
||||
...{ filters: { [name]: values } },
|
||||
}}
|
||||
>
|
||||
<SearchFilter.Autocomplete multiple name={name} values={values} />
|
||||
<SearchContextFilterSpy name={name} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete
|
||||
multiple
|
||||
limitTags={1}
|
||||
name={name}
|
||||
values={values}
|
||||
/>
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<TestApiProvider apis={[[searchApiRef, { query }]]}>
|
||||
<SearchContextProvider>
|
||||
<SearchFilter.Autocomplete multiple name={name} values={values} />
|
||||
<SearchContextFilterSpy name={name} />
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string>('');
|
||||
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) => (
|
||||
<TextField
|
||||
{...params}
|
||||
name="search"
|
||||
variant="outlined"
|
||||
label={label}
|
||||
fullWidth
|
||||
/>
|
||||
);
|
||||
|
||||
// Render tags as primary-colored chips.
|
||||
const renderTags = (
|
||||
tagValue: string[],
|
||||
getTagProps: AutocompleteGetTagProps,
|
||||
) =>
|
||||
tagValue.map((option: string, index: number) => (
|
||||
<Chip label={option} color="primary" {...getTagProps({ index })} />
|
||||
));
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
filterSelectedOptions={filterSelectedOptions}
|
||||
limitTags={limitTags}
|
||||
multiple={multiple}
|
||||
className={className}
|
||||
id={`${multiple ? 'multi-' : ''}select-filter-${name}--select`}
|
||||
options={values || []}
|
||||
loading={loading}
|
||||
value={filterValue}
|
||||
onChange={handleChange}
|
||||
onInputChange={(_, newValue) => setInputValue(newValue)}
|
||||
renderInput={renderInput}
|
||||
renderTags={renderTags}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -50,9 +50,75 @@ export const SelectFilter = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Select
|
||||
name="Search Select Filter"
|
||||
label="Search Select Filter"
|
||||
name="select_filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const AsyncSelectFilter = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Select
|
||||
label="Asynchronous Values"
|
||||
name="async_values"
|
||||
values={async () => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Autocomplete = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Autocomplete
|
||||
name="autocomplete"
|
||||
label="Single-Select Autocomplete Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const MultiSelectAutocomplete = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Autocomplete
|
||||
multiple
|
||||
name="autocomplete"
|
||||
label="Multi-Select Autocomplete Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const AsyncMultiSelectAutocomplete = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Autocomplete
|
||||
multiple
|
||||
name="starwarsPerson"
|
||||
label="Starwars Character"
|
||||
values={async partial => {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -83,12 +84,12 @@ describe('SearchFilter', () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -101,6 +102,7 @@ describe('SearchFilter', () => {
|
||||
render(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Checkbox
|
||||
label={label}
|
||||
name={name}
|
||||
values={values}
|
||||
defaultValue={[values[0]]}
|
||||
@@ -109,7 +111,7 @@ describe('SearchFilter', () => {
|
||||
);
|
||||
|
||||
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(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SearchContextProvider initialState={{ ...initialState, filters }}>
|
||||
<SearchFilter.Checkbox name={name} values={values} />
|
||||
<SearchFilter.Checkbox label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
<SearchFilter.Select label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Select
|
||||
label={label}
|
||||
name={name}
|
||||
values={async () => values}
|
||||
/>
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
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', () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
<SearchFilter.Select label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
userEvent.click(screen.getByRole('button'));
|
||||
@@ -248,6 +282,7 @@ describe('SearchFilter', () => {
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Select
|
||||
name={name}
|
||||
label={label}
|
||||
values={values}
|
||||
defaultValue={values[0]}
|
||||
/>
|
||||
@@ -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(
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
<SearchFilter.Select label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
@@ -330,12 +365,12 @@ describe('SearchFilter', () => {
|
||||
filters,
|
||||
}}
|
||||
>
|
||||
<SearchFilter.Select name={name} values={values} />
|
||||
<SearchFilter.Select label={label} name={name} values={values} />
|
||||
</SearchContextProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
@@ -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<string[]>);
|
||||
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<HTMLInputElement>) => {
|
||||
const {
|
||||
@@ -81,10 +108,11 @@ const CheckboxFilter = ({
|
||||
return (
|
||||
<FormControl
|
||||
className={className}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
data-testid="search-checkboxfilter-next"
|
||||
>
|
||||
<FormLabel className={classes.label}>{name}</FormLabel>
|
||||
{label ? <FormLabel className={classes.label}>{label}</FormLabel> : null}
|
||||
{values.map((value: string) => (
|
||||
<FormControlLabel
|
||||
key={value}
|
||||
@@ -106,25 +134,29 @@ const CheckboxFilter = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SelectFilter = ({
|
||||
className,
|
||||
name,
|
||||
defaultValue,
|
||||
values = [],
|
||||
}: Component) => {
|
||||
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 (
|
||||
<FormControl
|
||||
disabled={loading}
|
||||
className={className}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
data-testid="search-selectfilter-next"
|
||||
>
|
||||
<InputLabel className={classes.label} margin="dense">
|
||||
{name}
|
||||
</InputLabel>
|
||||
{label ? (
|
||||
<InputLabel className={classes.label} margin="dense">
|
||||
{label}
|
||||
</InputLabel>
|
||||
) : null}
|
||||
<Select
|
||||
variant="outlined"
|
||||
value={filters[name] || ''}
|
||||
@@ -164,16 +199,29 @@ const SelectFilter = ({
|
||||
);
|
||||
};
|
||||
|
||||
const SearchFilter = ({ component: Element, ...props }: Props) => (
|
||||
<Element {...props} />
|
||||
);
|
||||
const SearchFilter = ({
|
||||
component: Element,
|
||||
...props
|
||||
}: SearchFilterWrapperProps) => <Element {...props} />;
|
||||
|
||||
SearchFilter.Checkbox = (props: Omit<Props, 'component'> & Component) => (
|
||||
<SearchFilter {...props} component={CheckboxFilter} />
|
||||
);
|
||||
SearchFilter.Checkbox = (
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
) => <SearchFilter {...props} component={CheckboxFilter} />;
|
||||
|
||||
SearchFilter.Select = (props: Omit<Props, 'component'> & Component) => (
|
||||
<SearchFilter {...props} component={SelectFilter} />
|
||||
SearchFilter.Select = (
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
) => <SearchFilter {...props} component={SelectFilter} />;
|
||||
|
||||
/**
|
||||
* A control surface for a given filter field name, rendered as an autocomplete
|
||||
* textfield. A hard-coded list of values may be provided, or an async function
|
||||
* which returns values may be provided instead.
|
||||
* @public
|
||||
*/
|
||||
SearchFilter.Autocomplete = (props: SearchAutocompleteFilterProps) => (
|
||||
<SearchFilter {...props} component={AutocompleteFilter} />
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* Copyright 2022 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 from 'react';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import { TestApiRegistry } from '@backstage/test-utils';
|
||||
import { act, renderHook } from '@testing-library/react-hooks';
|
||||
import { SearchContextProvider, useSearch } from '../SearchContext';
|
||||
import { useDefaultFilterValue, useAsyncFilterValues } from './hooks';
|
||||
import { searchApiRef } from '../../apis';
|
||||
|
||||
describe('SearchFilter.hooks', () => {
|
||||
describe('useDefaultFilterValue', () => {
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
const mockApis = TestApiRegistry.from([searchApiRef, { query }]);
|
||||
const wrapper = ({
|
||||
children,
|
||||
overrides = {},
|
||||
}: {
|
||||
children?: any;
|
||||
overrides?: any;
|
||||
}) => {
|
||||
const emptySearchContext = {
|
||||
term: '',
|
||||
types: [],
|
||||
filters: {},
|
||||
};
|
||||
return (
|
||||
<ApiProvider apis={mockApis}>
|
||||
<SearchContextProvider
|
||||
initialState={{ ...emptySearchContext, ...overrides }}
|
||||
>
|
||||
{children}
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
it('should set non-empty string value', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = 'someValue';
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, expectedValue);
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters[expectedFilter]).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('should set non-empty array value', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = ['someValue', 'anotherValue'];
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, expectedValue);
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters[expectedFilter]).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('should not set undefined value', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = 'notEmpty';
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, undefined);
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
initialProps: {
|
||||
overrides: {
|
||||
filters: {
|
||||
[expectedFilter]: expectedValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters[expectedFilter]).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('should not set null value', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = 'notEmpty';
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, null);
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
initialProps: {
|
||||
overrides: {
|
||||
filters: {
|
||||
[expectedFilter]: expectedValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters[expectedFilter]).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('should not set empty string value', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = 'notEmpty';
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, '');
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
initialProps: {
|
||||
overrides: {
|
||||
filters: {
|
||||
[expectedFilter]: expectedValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters[expectedFilter]).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('should not set empty array value', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = ['not', 'empty'];
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, []);
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
initialProps: {
|
||||
overrides: {
|
||||
filters: {
|
||||
[expectedFilter]: expectedValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters[expectedFilter]).toEqual(expectedValue);
|
||||
});
|
||||
|
||||
it('should not affect unrelated filters', async () => {
|
||||
const expectedFilter = 'someField';
|
||||
const expectedValue = 'someValue';
|
||||
const { result, waitForNextUpdate } = renderHook(
|
||||
() => {
|
||||
useDefaultFilterValue(expectedFilter, expectedValue);
|
||||
return useSearch();
|
||||
},
|
||||
{
|
||||
wrapper,
|
||||
initialProps: {
|
||||
overrides: {
|
||||
filters: {
|
||||
unrelatedField: 'unrelatedValue',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.filters.unrelatedField).toEqual('unrelatedValue');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useAsyncFilterValues', () => {
|
||||
it('should immediately return given values when provided', () => {
|
||||
const givenValues = ['value1', 'value2'];
|
||||
const { result } = renderHook(() =>
|
||||
useAsyncFilterValues(undefined, '', givenValues),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.value).toEqual(givenValues);
|
||||
});
|
||||
|
||||
it('should return resolved values of provided async function', async () => {
|
||||
const expectedValues = ['value1', 'value2'];
|
||||
const asyncFn = () => Promise.resolve(expectedValues);
|
||||
const { result, waitForNextUpdate } = renderHook(() =>
|
||||
useAsyncFilterValues(asyncFn, '', undefined, 1),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
|
||||
await waitForNextUpdate();
|
||||
|
||||
expect(result.current.loading).toEqual(false);
|
||||
expect(result.current.value).toEqual(expectedValues);
|
||||
});
|
||||
|
||||
it('should debounce method invocation', async () => {
|
||||
const expectedValues = ['value1', 'value2'];
|
||||
const asyncFn = jest.fn().mockResolvedValue(expectedValues);
|
||||
renderHook(() => useAsyncFilterValues(asyncFn, '', undefined, 10));
|
||||
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
|
||||
// Allow 6 milliseconds to pass.
|
||||
await act(() => new Promise(resolve => setTimeout(resolve, 6)));
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
|
||||
// Allow an additional 6 milliseconds to pass.
|
||||
await act(() => new Promise(resolve => setTimeout(resolve, 6)));
|
||||
expect(asyncFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call provided method once per provided input', async () => {
|
||||
const asyncFn = jest
|
||||
.fn()
|
||||
.mockImplementation((x: string) => Promise.resolve([x]));
|
||||
const { rerender, waitForNextUpdate } = renderHook(
|
||||
(props: { inputValue: string } = { inputValue: '' }) =>
|
||||
useAsyncFilterValues(asyncFn, props.inputValue, undefined, 1),
|
||||
);
|
||||
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
await waitForNextUpdate();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
expect(asyncFn).toHaveBeenCalledWith('');
|
||||
|
||||
// Re-render with different input value.
|
||||
rerender({ inputValue: 'somethingElse' });
|
||||
await waitForNextUpdate();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(2);
|
||||
expect(asyncFn).toHaveBeenLastCalledWith('somethingElse');
|
||||
});
|
||||
|
||||
it('should not call provided method more than once when re-rendered with same input', async () => {
|
||||
const expectedValues = ['value1', 'value2'];
|
||||
const asyncFn = jest.fn().mockResolvedValue(expectedValues);
|
||||
const { rerender, waitForNextUpdate } = renderHook(
|
||||
(props: { inputValue: string } = { inputValue: '' }) =>
|
||||
useAsyncFilterValues(asyncFn, props.inputValue, undefined, 1),
|
||||
);
|
||||
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
await waitForNextUpdate();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-render multiple times with the same input.
|
||||
rerender();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
rerender();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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 { useEffect, useRef } from 'react';
|
||||
import useAsyncFn from 'react-use/lib/useAsyncFn';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { useSearch } from '../SearchContext';
|
||||
|
||||
/**
|
||||
* Utility hook for either asynchronously loading filter values from a given
|
||||
* function or synchronously providing a given list of default values.
|
||||
*/
|
||||
export const useAsyncFilterValues = (
|
||||
fn: ((partial: string) => Promise<string[]>) | undefined,
|
||||
inputValue: string,
|
||||
defaultValues: string[] = [],
|
||||
debounce: number = 250,
|
||||
) => {
|
||||
const valuesMemo = useRef<Record<string, string[] | Promise<string[]>>>({});
|
||||
const definiteFn = fn || (() => Promise.resolve([]));
|
||||
|
||||
const [state, callback] = useAsyncFn(definiteFn, [inputValue], {
|
||||
loading: true,
|
||||
});
|
||||
|
||||
// Do not invoke the given function more than necessary.
|
||||
useDebounce(
|
||||
() => {
|
||||
// Performance optimization: only invoke the callback once per inputValue
|
||||
// for the lifetime of the hook/component.
|
||||
if (valuesMemo.current[inputValue] === undefined) {
|
||||
valuesMemo.current[inputValue] = callback(inputValue).then(values => {
|
||||
// Overrite the value for future immediate returns.
|
||||
valuesMemo.current[inputValue] = values;
|
||||
return values;
|
||||
});
|
||||
}
|
||||
},
|
||||
debounce,
|
||||
[callback, inputValue],
|
||||
);
|
||||
|
||||
// Immediately return the default values if they are provided.
|
||||
if (defaultValues.length) {
|
||||
return {
|
||||
loading: false,
|
||||
value: defaultValues,
|
||||
};
|
||||
}
|
||||
|
||||
// Immediately return a memoized value if it is set (and not a promise).
|
||||
const possibleValue = valuesMemo.current[inputValue];
|
||||
if (Array.isArray(possibleValue)) {
|
||||
return {
|
||||
loading: false,
|
||||
value: possibleValue,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility hook for applying a given default value to the search context.
|
||||
*/
|
||||
export const useDefaultFilterValue = (
|
||||
name: string,
|
||||
defaultValue?: string | string[] | null,
|
||||
) => {
|
||||
const { setFilters } = useSearch();
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultValue && [defaultValue].flat().length > 0) {
|
||||
setFilters(prevFilters => ({
|
||||
...prevFilters,
|
||||
[name]: defaultValue,
|
||||
}));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
};
|
||||
@@ -15,3 +15,8 @@
|
||||
*/
|
||||
|
||||
export { SearchFilter, SearchFilterNext } from './SearchFilter';
|
||||
export type {
|
||||
SearchFilterComponentProps,
|
||||
SearchFilterWrapperProps,
|
||||
} from './SearchFilter';
|
||||
export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete';
|
||||
|
||||
@@ -33,6 +33,11 @@ export type {
|
||||
} from './components/SearchBar';
|
||||
export { SearchContextProvider, useSearch } from './components/SearchContext';
|
||||
export { SearchFilter, SearchFilterNext } from './components/SearchFilter';
|
||||
export type {
|
||||
SearchAutocompleteFilterProps,
|
||||
SearchFilterComponentProps,
|
||||
SearchFilterWrapperProps,
|
||||
} from './components/SearchFilter';
|
||||
export { SearchModal } from './components/SearchModal';
|
||||
export type { SearchModalProps } from './components/SearchModal';
|
||||
export { SearchPage as Router } from './components/SearchPage';
|
||||
|
||||
Reference in New Issue
Block a user