Move SearchFilter from @backstage/plugin-search to @backstage/plugin-search-react and deprecate it in @backstage/plugin-search, and also remove SearchFilterNext
Signed-off-by: Anders Näsman <andersn@spotify.com>
This commit is contained in:
committed by
Eric Peterson
parent
ecf4bc5590
commit
d7044784f0
@@ -12,7 +12,9 @@ import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ResultHighlight } from '@backstage/plugin-search-common';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { SearchAutocompleteFilterProps as SearchAutocompleteFilterProps_2 } from '@backstage/plugin-search-react';
|
||||
import { SearchDocument } from '@backstage/plugin-search-common';
|
||||
import { SearchFilterComponentProps as SearchFilterComponentProps_2 } from '@backstage/plugin-search-react';
|
||||
import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "DefaultResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
@@ -78,8 +80,8 @@ export type HomePageSearchBarProps = Partial<
|
||||
// @public (undocumented)
|
||||
export const Router: () => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
|
||||
// @public @deprecated (undocumented)
|
||||
export type SearchAutocompleteFilterProps = SearchFilterComponentProps_2 & {
|
||||
filterSelectedOptions?: boolean;
|
||||
limitTags?: number;
|
||||
multiple?: boolean;
|
||||
@@ -124,7 +126,7 @@ export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const SearchFilter: {
|
||||
({ component: Element, ...props }: SearchFilterWrapperProps): JSX.Element;
|
||||
Checkbox(
|
||||
@@ -135,10 +137,10 @@ export const SearchFilter: {
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
): JSX.Element;
|
||||
Autocomplete(props: SearchAutocompleteFilterProps): JSX.Element;
|
||||
Autocomplete(props: SearchAutocompleteFilterProps_2): JSX.Element;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type SearchFilterComponentProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
@@ -148,23 +150,7 @@ export type SearchFilterComponentProps = {
|
||||
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 }: 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;
|
||||
|
||||
@@ -21,11 +21,16 @@ import {
|
||||
AutocompleteGetTagProps,
|
||||
AutocompleteRenderInputParams,
|
||||
} from '@material-ui/lab';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
|
||||
import { SearchFilterComponentProps } from './SearchFilter';
|
||||
import {
|
||||
SearchFilterComponentProps,
|
||||
useSearch,
|
||||
useAsyncFilterValues,
|
||||
useDefaultFilterValue,
|
||||
} from '@backstage/plugin-search-react';
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
|
||||
@@ -34,6 +39,10 @@ export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
|
||||
multiple?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*
|
||||
*/
|
||||
export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
|
||||
const {
|
||||
className,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
/*
|
||||
* 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 { Grid, Paper } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import {
|
||||
searchApiRef,
|
||||
MockSearchApi,
|
||||
SearchContextProvider,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
|
||||
export default {
|
||||
title: 'Plugins/Search/SearchFilter',
|
||||
component: SearchFilter,
|
||||
decorators: [
|
||||
(Story: ComponentType<{}>) => (
|
||||
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
|
||||
<SearchContextProvider>
|
||||
<Grid container direction="row">
|
||||
<Grid item xs={4}>
|
||||
<Story />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SearchContextProvider>
|
||||
</TestApiProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const CheckBoxFilter = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Checkbox
|
||||
name="Search Checkbox Filter"
|
||||
values={['value1', 'value2']}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export const SelectFilter = () => {
|
||||
return (
|
||||
<Paper style={{ padding: 10 }}>
|
||||
<SearchFilter.Select
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -14,32 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactElement, ChangeEvent } from 'react';
|
||||
import {
|
||||
makeStyles,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
InputLabel,
|
||||
Checkbox,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormLabel,
|
||||
} from '@material-ui/core';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
AutocompleteFilter,
|
||||
CheckboxFilter,
|
||||
SearchAutocompleteFilterProps,
|
||||
} from './SearchFilter.Autocomplete';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
label: {
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
});
|
||||
SelectFilter,
|
||||
} from '@backstage/plugin-search-react';
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchFilterComponentProps = {
|
||||
@@ -62,6 +48,8 @@ export type SearchFilterComponentProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchFilterWrapperProps = SearchFilterComponentProps & {
|
||||
@@ -69,152 +57,33 @@ export type SearchFilterWrapperProps = SearchFilterComponentProps & {
|
||||
debug?: boolean;
|
||||
};
|
||||
|
||||
const CheckboxFilter = (props: SearchFilterComponentProps) => {
|
||||
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 {
|
||||
target: { value, checked },
|
||||
} = e;
|
||||
|
||||
setFilters(prevFilters => {
|
||||
const { [name]: filter, ...others } = prevFilters;
|
||||
const rest = ((filter as string[]) || []).filter(i => i !== value);
|
||||
const items = checked ? [...rest, value] : rest;
|
||||
return items.length ? { ...others, [name]: items } : others;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
className={className}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
data-testid="search-checkboxfilter-next"
|
||||
>
|
||||
{label ? <FormLabel className={classes.label}>{label}</FormLabel> : null}
|
||||
{values.map((value: string) => (
|
||||
<FormControlLabel
|
||||
key={value}
|
||||
control={
|
||||
<Checkbox
|
||||
color="primary"
|
||||
tabIndex={-1}
|
||||
inputProps={{ 'aria-labelledby': value }}
|
||||
value={value}
|
||||
name={value}
|
||||
onChange={handleChange}
|
||||
checked={((filters[name] as string[]) ?? []).includes(value)}
|
||||
/>
|
||||
}
|
||||
label={value}
|
||||
/>
|
||||
))}
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
const handleChange = (e: ChangeEvent<{ value: unknown }>) => {
|
||||
const {
|
||||
target: { value },
|
||||
} = e;
|
||||
|
||||
setFilters(prevFilters => {
|
||||
const { [name]: filter, ...others } = prevFilters;
|
||||
return value ? { ...others, [name]: value as string } : others;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
disabled={loading}
|
||||
className={className}
|
||||
variant="filled"
|
||||
fullWidth
|
||||
data-testid="search-selectfilter-next"
|
||||
>
|
||||
{label ? (
|
||||
<InputLabel className={classes.label} margin="dense">
|
||||
{label}
|
||||
</InputLabel>
|
||||
) : null}
|
||||
<Select
|
||||
variant="outlined"
|
||||
value={filters[name] || ''}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<MenuItem value="">
|
||||
<em>All</em>
|
||||
</MenuItem>
|
||||
{values.map((value: string) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{value}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*/
|
||||
const SearchFilter = ({
|
||||
component: Element,
|
||||
...props
|
||||
}: SearchFilterWrapperProps) => <Element {...props} />;
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*/
|
||||
SearchFilter.Checkbox = (
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
) => <SearchFilter {...props} component={CheckboxFilter} />;
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*/
|
||||
SearchFilter.Select = (
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
) => <SearchFilter {...props} component={SelectFilter} />;
|
||||
|
||||
/**
|
||||
* @deprecated Moved to `@backstage/plugin-search-react`.
|
||||
*
|
||||
* 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.
|
||||
@@ -224,12 +93,4 @@ SearchFilter.Autocomplete = (props: SearchAutocompleteFilterProps) => (
|
||||
<SearchFilter {...props} component={AutocompleteFilter} />
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchFilter /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
*/
|
||||
const SearchFilterNext = SearchFilter;
|
||||
|
||||
export { SearchFilter, SearchFilterNext };
|
||||
export { SearchFilter };
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
/*
|
||||
* 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 { renderHook } from '@testing-library/react-hooks';
|
||||
import {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
searchApiRef,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { useDefaultFilterValue, useAsyncFilterValues } from './hooks';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
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, 1000),
|
||||
);
|
||||
|
||||
expect(result.current.loading).toEqual(true);
|
||||
|
||||
jest.runAllTimers();
|
||||
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, 1000));
|
||||
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
|
||||
// Advance timers by 600ms
|
||||
jest.advanceTimersByTime(600);
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
|
||||
// Another 600ms to exceed the 1000ms debounce
|
||||
jest.advanceTimersByTime(600);
|
||||
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, 1000),
|
||||
);
|
||||
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
jest.runAllTimers();
|
||||
await waitForNextUpdate();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
expect(asyncFn).toHaveBeenCalledWith('');
|
||||
|
||||
// Re-render with different input value.
|
||||
rerender({ inputValue: 'somethingElse' });
|
||||
jest.runAllTimers();
|
||||
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, 1000),
|
||||
);
|
||||
|
||||
expect(asyncFn).not.toHaveBeenCalled();
|
||||
|
||||
jest.runAllTimers();
|
||||
await waitForNextUpdate();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Re-render multiple times with the same input.
|
||||
rerender();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
rerender();
|
||||
expect(asyncFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
/*
|
||||
* 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 '@backstage/plugin-search-react';
|
||||
|
||||
/**
|
||||
* 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 => {
|
||||
// Override 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
|
||||
}, []);
|
||||
};
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchFilter, SearchFilterNext } from './SearchFilter';
|
||||
export { SearchFilter } from './SearchFilter';
|
||||
export type {
|
||||
SearchFilterComponentProps,
|
||||
SearchFilterWrapperProps,
|
||||
|
||||
@@ -28,7 +28,7 @@ export type {
|
||||
SearchBarBaseProps,
|
||||
SearchBarProps,
|
||||
} from './components/SearchBar';
|
||||
export { SearchFilter, SearchFilterNext } from './components/SearchFilter';
|
||||
export { SearchFilter } from './components/SearchFilter';
|
||||
export type {
|
||||
SearchAutocompleteFilterProps,
|
||||
SearchFilterComponentProps,
|
||||
|
||||
Reference in New Issue
Block a user