Review feedback.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2022-01-22 18:05:21 +01:00
parent 1dbe63ec39
commit 8f370b038d
11 changed files with 115 additions and 89 deletions
+7 -7
View File
@@ -2,7 +2,7 @@
'@backstage/create-app': patch
---
An example instance of the new `<SearchFilter.Autocomplete />` was added to the composed `SearchPage.tsx`, allowing searches bound to the `techdocs` type to be filtered by entity name.
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:
@@ -50,12 +50,12 @@ This is an entirely optional change; if you wish to adopt it, you can make the f
/>
<Paper className={classes.filters}>
+ {types.includes('techdocs') && (
+ <SearchFilter.Autocomplete
+ <SearchFilter.Select
+ className={classes.filter}
+ label="Entity"
+ name="name"
+ asyncValues={async partial => {
+ // Return a list of entitis which are documented.
+ values={async () => {
+ // Return a list of entities which are documented.
+ const { items } = await catalogApi.getEntities({
+ fields: ['metadata.name'],
+ filter: {
@@ -64,9 +64,9 @@ This is an entirely optional change; if you wish to adopt it, you can make the f
+ },
+ });
+
+ return items
+ .map(entity => entity.metadata.name)
+ .filter(name => name.includes(partial));
+ const names = items.map(entity => entity.metadata.name);
+ names.sort();
+ return names;
+ }}
+ />
+ )}
+1 -1
View File
@@ -4,6 +4,6 @@
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 with a new `asyncValues` prop, which takes an asynchronous function that resolves to the list of values (an optional `asyncDebounce` prop may also be provided).
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.
@@ -93,12 +93,12 @@ const SearchPage = () => {
/>
<Paper className={classes.filters}>
{types.includes('techdocs') && (
<SearchFilter.Autocomplete
<SearchFilter.Select
className={classes.filter}
label="Entity"
name="name"
asyncValues={async partial => {
// Return a list of entitis which are documented.
values={async () => {
// Return a list of entities which are documented.
const { items } = await catalogApi.getEntities({
fields: ['metadata.name'],
filter: {
@@ -107,9 +107,9 @@ const SearchPage = () => {
},
});
return items
.map(entity => entity.metadata.name)
.filter(name => name.includes(partial));
const names = items.map(entity => entity.metadata.name);
names.sort();
return names;
}}
/>
)}
@@ -74,11 +74,11 @@ const SearchPage = () => {
/>
<Paper className={classes.filters}>
{types.includes('techdocs') && (
<SearchFilter.Autocomplete
<SearchFilter.Select
className={classes.filter}
label="Entity"
name="name"
asyncValues={async partial => {
values={async () => {
// Return a list of entities which are documented.
const { items } = await catalogApi.getEntities({
fields: ['metadata.name'],
@@ -88,9 +88,9 @@ const SearchPage = () => {
},
});
return items
.map(entity => entity.metadata.name)
.filter(name => name.includes(partial));
const names = items.map(entity => entity.metadata.name);
names.sort();
return names;
}}
/>
)}
+4 -4
View File
@@ -94,8 +94,9 @@ export const searchApiRef: ApiRef<SearchApi>;
// @public (undocumented)
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
multiple?: boolean;
filterSelectedOptions?: boolean;
limitTags?: number;
multiple?: boolean;
};
// @public
@@ -166,10 +167,9 @@ export type SearchFilterComponentProps = {
className?: string;
name: string;
label?: string;
values?: string[];
asyncValues?: (partial: string) => Promise<string[]>;
asyncDebounce?: number;
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)
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import { ApiProvider } from '@backstage/core-app-api';
import { TestApiRegistry } from '@backstage/test-utils';
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';
@@ -35,7 +34,6 @@ const SearchContextFilterSpy = ({ name }: { name: string }) => {
describe('SearchFilter.Autocomplete', () => {
const query = jest.fn().mockResolvedValue({});
const mockApis = TestApiRegistry.from([searchApiRef, { query }]);
const emptySearchContext = {
term: '',
types: [],
@@ -47,11 +45,11 @@ describe('SearchFilter.Autocomplete', () => {
it('renders as expected', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete name={name} values={values} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
@@ -68,14 +66,11 @@ describe('SearchFilter.Autocomplete', () => {
it('renders as expected with async values', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
name={name}
asyncValues={async () => values}
/>
<SearchFilter.Autocomplete name={name} values={async () => values} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
@@ -92,7 +87,7 @@ describe('SearchFilter.Autocomplete', () => {
it('does not affect unrelated filter state', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider
initialState={{
...emptySearchContext,
@@ -103,7 +98,7 @@ describe('SearchFilter.Autocomplete', () => {
<SearchContextFilterSpy name={name} />
<SearchContextFilterSpy name="unrelated" />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
// The spy should show the initial value.
@@ -136,7 +131,7 @@ describe('SearchFilter.Autocomplete', () => {
describe('single', () => {
it('renders as expected with defaultValue', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
name={name}
@@ -145,7 +140,7 @@ describe('SearchFilter.Autocomplete', () => {
/>
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
@@ -161,7 +156,7 @@ describe('SearchFilter.Autocomplete', () => {
it('renders as expected with initial context', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider
initialState={{
...emptySearchContext,
@@ -171,7 +166,7 @@ describe('SearchFilter.Autocomplete', () => {
<SearchFilter.Autocomplete name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
@@ -187,12 +182,12 @@ describe('SearchFilter.Autocomplete', () => {
it('sets filter state when selecting a value', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
// Select the first option in the autocomplete.
@@ -225,7 +220,7 @@ describe('SearchFilter.Autocomplete', () => {
describe('multiple', () => {
it('renders as expected with defaultValue', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
multiple
@@ -235,7 +230,7 @@ describe('SearchFilter.Autocomplete', () => {
/>
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
await waitFor(() => {
@@ -249,7 +244,7 @@ describe('SearchFilter.Autocomplete', () => {
it('renders as expected with initial context', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider
initialState={{
...emptySearchContext,
@@ -259,7 +254,7 @@ describe('SearchFilter.Autocomplete', () => {
<SearchFilter.Autocomplete multiple name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
await waitFor(() => {
@@ -273,7 +268,7 @@ describe('SearchFilter.Autocomplete', () => {
it('respects tag limit configuration', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete
multiple
@@ -282,7 +277,7 @@ describe('SearchFilter.Autocomplete', () => {
values={values}
/>
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
@@ -322,12 +317,12 @@ describe('SearchFilter.Autocomplete', () => {
it('sets filter state when selecting a value', async () => {
render(
<ApiProvider apis={mockApis}>
<TestApiProvider apis={[[searchApiRef, { query }]]}>
<SearchContextProvider>
<SearchFilter.Autocomplete multiple name={name} values={values} />
<SearchContextFilterSpy name={name} />
</SearchContextProvider>
</ApiProvider>,
</TestApiProvider>,
);
const autocomplete = screen.getByRole('combobox');
@@ -29,29 +29,34 @@ import { SearchFilterComponentProps } from './SearchFilter';
* @public
*/
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
multiple?: boolean;
filterSelectedOptions?: boolean;
limitTags?: number;
multiple?: boolean;
};
export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
const {
asyncValues,
asyncDebounce,
className,
defaultValue,
multiple,
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,
givenValues,
asyncDebounce,
defaultValues,
valuesDebounceMs,
);
const { filters, setFilters } = useSearch();
const filterValue =
@@ -94,12 +99,13 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
return (
<Autocomplete
filterSelectedOptions={filterSelectedOptions}
limitTags={limitTags}
multiple={multiple}
className={className}
id={`${multiple ? 'multi-' : ''}select-filter-${name}--select`}
options={values || []}
loading={loading}
limitTags={limitTags}
value={filterValue}
onChange={handleChange}
onInputChange={(_, newValue) => setInputValue(newValue)}
@@ -50,7 +50,8 @@ 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>
@@ -61,8 +62,9 @@ export const AsyncSelectFilter = () => {
return (
<Paper style={{ padding: 10 }}>
<SearchFilter.Select
name="Asynchronous Values"
asyncValues={async () => {
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();
@@ -79,7 +81,7 @@ export const Autocomplete = () => {
<SearchFilter.Autocomplete
name="autocomplete"
label="Single-Select Autocomplete Filter"
values={['abba', 'cadaver']}
values={['value1', 'value2']}
/>
</Paper>
);
@@ -92,7 +94,7 @@ export const MultiSelectAutocomplete = () => {
multiple
name="autocomplete"
label="Multi-Select Autocomplete Filter"
values={['abba', 'cadaver']}
values={['value1', 'value2']}
/>
</Paper>
);
@@ -105,10 +107,12 @@ export const AsyncMultiSelectAutocomplete = () => {
multiple
name="starwarsPerson"
label="Starwars Character"
asyncValues={async partial => {
values={async partial => {
if (partial === '') return [];
const response = await fetch(
`https://swapi.dev/api/people?search=${partial}`,
`https://swapi.dev/api/people?search=${encodeURIComponent(
partial,
)}`,
);
const json: { results: Array<{ name: string }> } =
await response.json();
@@ -215,7 +215,7 @@ describe('SearchFilter', () => {
<SearchFilter.Select
label={label}
name={name}
asyncValues={async () => values}
values={async () => values}
/>
</SearchContextProvider>,
);
@@ -46,18 +46,19 @@ export type SearchFilterComponentProps = {
className?: string;
name: string;
label?: string;
values?: string[];
/**
* 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.
* 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.
*/
asyncValues?: (partial: string) => Promise<string[]>;
/**
* Debounce time (ms) used by the asyncValues callback. Defaults to 250ms.
*/
asyncDebounce?: number;
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;
};
/**
@@ -69,10 +70,27 @@ export type SearchFilterWrapperProps = SearchFilterComponentProps & {
};
const CheckboxFilter = (props: SearchFilterComponentProps) => {
const { className, defaultValue, label, name, values = [] } = props;
const {
className,
defaultValue,
label,
name,
values: givenValues = [],
valuesDebounceMs,
} = props;
const classes = useStyles();
const { filters, setFilters } = useSearch();
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 {
@@ -90,6 +108,7 @@ const CheckboxFilter = (props: SearchFilterComponentProps) => {
return (
<FormControl
className={className}
disabled={loading}
fullWidth
data-testid="search-checkboxfilter-next"
>
@@ -117,21 +136,24 @@ const CheckboxFilter = (props: SearchFilterComponentProps) => {
const SelectFilter = (props: SearchFilterComponentProps) => {
const {
asyncValues,
asyncDebounce,
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,
'',
givenValues,
asyncDebounce,
defaultValues,
valuesDebounceMs,
);
const { filters, setFilters } = useSearch();
@@ -29,7 +29,7 @@ export const useAsyncFilterValues = (
defaultValues: string[] = [],
debounce: number = 250,
) => {
const valuesMemo = useRef<Record<string, string[]>>({});
const valuesMemo = useRef<Record<string, string[] | Promise<string[]>>>({});
const definiteFn = fn || (() => Promise.resolve([]));
const [state, callback] = useAsyncFn(definiteFn, [inputValue], {
@@ -42,8 +42,10 @@ export const useAsyncFilterValues = (
// Performance optimization: only invoke the callback once per inputValue
// for the lifetime of the hook/component.
if (valuesMemo.current[inputValue] === undefined) {
callback(inputValue).then(values => {
valuesMemo.current[inputValue] = callback(inputValue).then(values => {
// Overrite the value for future immediate returns.
valuesMemo.current[inputValue] = values;
return values;
});
}
},
@@ -59,11 +61,12 @@ export const useAsyncFilterValues = (
};
}
// Immediately return a memoized value if it is set.
if (valuesMemo.current[inputValue] !== undefined) {
// 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: valuesMemo.current[inputValue],
value: possibleValue,
};
}
@@ -80,11 +83,7 @@ export const useDefaultFilterValue = (
const { setFilters } = useSearch();
useEffect(() => {
const defaultIsEmpty = !defaultValue;
const defaultIsEmptyArray =
Array.isArray(defaultValue) && defaultValue.length === 0;
if (!defaultIsEmpty && !defaultIsEmptyArray) {
if (defaultValue && [defaultValue].flat().length > 0) {
setFilters(prevFilters => ({
...prevFilters,
[name]: defaultValue,