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:
Anders Näsman
2022-06-01 14:55:10 +02:00
committed by Eric Peterson
parent ecf4bc5590
commit d7044784f0
16 changed files with 1296 additions and 201 deletions
+82
View File
@@ -10,9 +10,18 @@ import { AsyncState } from 'react-use/lib/useAsync';
import { JsonObject } from '@backstage/types';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ReactElement } from 'react';
import { SearchQuery } from '@backstage/plugin-search-common';
import { SearchResultSet } from '@backstage/plugin-search-common';
// @public (undocumented)
export const AutocompleteFilter: (
props: SearchAutocompleteFilterProps,
) => JSX.Element;
// @public (undocumented)
export const CheckboxFilter: (props: SearchFilterComponentProps) => JSX.Element;
// @public (undocumented)
export const HighlightedSearchResultText: ({
text,
@@ -45,6 +54,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 SearchContextProvider: (
props: SearchContextProviderProps,
@@ -74,6 +90,72 @@ export type SearchContextValue = {
fetchPreviousPage?: React_2.DispatchWithoutAction;
} & SearchContextState;
// @public (undocumented)
export const SearchFilter: {
({ 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;
};
// @public (undocumented)
export type SearchFilterWrapperProps = SearchFilterComponentProps & {
component: (props: SearchFilterComponentProps) => ReactElement;
debug?: boolean;
};
// @public (undocumented)
export const SelectFilter: (props: SearchFilterComponentProps) => JSX.Element;
// @public
export const useAsyncFilterValues: (
fn: ((partial: string) => Promise<string[]>) | undefined,
inputValue: string,
defaultValues?: string[],
debounce?: number,
) =>
| {
loading: boolean;
error?: undefined;
value?: undefined;
}
| {
loading: false;
error: Error;
value?: undefined;
}
| {
loading: true;
error?: Error | undefined;
value?: string[] | undefined;
}
| {
loading: boolean;
value: string[];
};
// @public
export const useDefaultFilterValue: (
name: string,
defaultValue?: string | string[] | null | undefined,
) => void;
// @public
export const useSearch: () => SearchContextValue;
+4 -2
View File
@@ -36,7 +36,8 @@
"@backstage/version-bridge": "^1.0.1",
"react-use": "^17.3.2",
"@backstage/types": "^1.0.0",
"@material-ui/core": "^4.12.2"
"@material-ui/core": "^4.12.2",
"@material-ui/lab": "4.0.0-alpha.57"
},
"peerDependencies": {
"@types/react": "^16.13.1 || ^17.0.0",
@@ -47,7 +48,8 @@
"@backstage/test-utils": "^1.1.1-next.0",
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^8.0.0",
"@testing-library/jest-dom": "^5.10.1"
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/user-event": "^14.0.0"
},
"files": [
"dist"
@@ -0,0 +1,361 @@
/*
* 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 '../../api';
import { SearchContextProvider, useSearch } from '../../context';
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');
await 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');
await 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');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await 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');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await 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');
await 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.
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[1] }));
await waitFor(() => {
expect(
screen.getByRole('button', { name: values[1] }),
).toBeInTheDocument();
});
// Select the first value.
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await 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');
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await userEvent.click(input);
await waitFor(() => {
screen.getByRole('listbox');
});
await 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');
await userEvent.click(clearButton);
// There should be no content in the filter context.
await waitFor(() => {
expect(screen.getByTestId(`${name}-filter-spy`)).toHaveTextContent('');
});
});
});
});
@@ -0,0 +1,120 @@
/*
* 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, { ChangeEvent, useState } from 'react';
import { Chip, TextField } from '@material-ui/core';
import {
Autocomplete,
AutocompleteGetTagProps,
AutocompleteRenderInputParams,
} from '@material-ui/lab';
import { useSearch } from '../../context';
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
import { SearchFilterComponentProps } from './SearchFilter';
/**
* @public
*/
export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
filterSelectedOptions?: boolean;
limitTags?: number;
multiple?: boolean;
};
/**
* @public
*/
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}
/>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2021 The Backstage Authors
* 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.
@@ -14,14 +14,13 @@
* 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 { Grid, Paper } from '@material-ui/core';
import { TestApiProvider } from '@backstage/test-utils';
import { searchApiRef, MockSearchApi } from '../../api';
import { SearchContextProvider } from '../../context';
import { SearchFilter } from './SearchFilter';
export default {
@@ -0,0 +1,410 @@
/*
* 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 { screen, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useApi } from '@backstage/core-plugin-api';
import { SearchContextProvider } from '../../context';
import { SearchFilter } from './SearchFilter';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
useApi: jest.fn().mockReturnValue({}),
}));
describe('SearchFilter', () => {
const initialState = {
term: '',
filters: {},
types: [],
};
const label = 'Field';
const name = 'field';
const values = ['value1', 'value2'];
const filters = { unrelated: 'unrelated' };
const query = jest.fn().mockResolvedValue({});
(useApi as jest.Mock).mockReturnValue({ query: query });
afterAll(() => {
jest.resetAllMocks();
});
it('Check that element was rendered and received props', async () => {
const CustomFilter = (props: { name: string }) => <h6>{props.name}</h6>;
render(<SearchFilter name={name} component={CustomFilter} />);
expect(screen.getByRole('heading', { name })).toBeInTheDocument();
});
describe('Checkbox', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
expect(
screen.getByRole('checkbox', { name: values[0] }),
).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: values[1] }),
).toBeInTheDocument();
});
it('Renders correctly based on filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters: {
[name]: [values[1]],
},
}}
>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
expect(
screen.getByRole('checkbox', { name: values[0] }),
).not.toBeChecked();
expect(screen.getByRole('checkbox', { name: values[1] })).toBeChecked();
});
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Checkbox
label={label}
name={name}
values={values}
defaultValue={[values[0]]}
/>
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
expect(screen.getByRole('checkbox', { name: values[0] })).toBeChecked();
expect(
screen.getByRole('checkbox', { name: values[1] }),
).not.toBeChecked();
});
it('Checking / unchecking a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const checkBox = screen.getByRole('checkbox', { name: values[0] });
// Check the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: { field: [values[0]] } }),
);
});
// Uncheck the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters: {} }),
);
});
});
it('Checking / unchecking a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider initialState={{ ...initialState, filters }}>
<SearchFilter.Checkbox label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const checkBox = screen.getByRole('checkbox', { name: values[0] });
// Check the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, field: [values[0]] },
}),
);
});
// Uncheck the box.
await userEvent.click(checkBox);
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
});
});
describe('Select', () => {
it('Renders field name and values when provided as props', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
await 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');
});
await 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 correctly based on filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters: {
[name]: values[0],
},
}}
>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Renders correctly based on defaultValue', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select
name={name}
label={label}
values={values}
defaultValue={values[0]}
/>
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button'));
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
expect(screen.getByRole('option', { name: values[0] })).toHaveAttribute(
'aria-selected',
'true',
);
expect(
screen.getByRole('option', { name: values[1] }),
).not.toHaveAttribute('aria-selected');
expect(screen.getByRole('option', { name: 'All' })).not.toHaveAttribute(
'aria-selected',
);
});
it('Selecting a value sets filter state', async () => {
render(
<SearchContextProvider initialState={initialState}>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const button = screen.getByRole('button');
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { [name]: values[0] },
}),
);
});
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: {},
}),
);
});
});
it('Selecting a value maintains unrelated filter state', async () => {
render(
<SearchContextProvider
initialState={{
...initialState,
filters,
}}
>
<SearchFilter.Select label={label} name={name} values={values} />
</SearchContextProvider>,
);
await waitFor(() => {
expect(screen.getByText(label)).toBeInTheDocument();
});
const button = screen.getByRole('button');
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: values[0] }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({
filters: { ...filters, [name]: values[0] },
}),
);
});
await userEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('option', { name: 'All' }));
await waitFor(() => {
expect(query).toHaveBeenLastCalledWith(
expect.objectContaining({ filters }),
);
});
});
});
});
@@ -0,0 +1,237 @@
/*
* 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, { ReactElement, ChangeEvent } from 'react';
import {
makeStyles,
FormControl,
FormControlLabel,
InputLabel,
Checkbox,
Select,
MenuItem,
FormLabel,
} from '@material-ui/core';
import { useSearch } from '../../context';
import {
AutocompleteFilter,
SearchAutocompleteFilterProps,
} from './SearchFilter.Autocomplete';
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
const useStyles = makeStyles({
label: {
textTransform: 'capitalize',
},
});
/**
* @public
*/
export type SearchFilterComponentProps = {
className?: string;
name: 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;
};
/**
* @public
*/
export type SearchFilterWrapperProps = SearchFilterComponentProps & {
component: (props: SearchFilterComponentProps) => ReactElement;
debug?: boolean;
};
/**
* @public
*/
export 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>
);
};
/**
* @public
*/
export 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>
);
};
/**
* @public
*/
const SearchFilter = ({
component: Element,
...props
}: SearchFilterWrapperProps) => <Element {...props} />;
SearchFilter.Checkbox = (
props: Omit<SearchFilterWrapperProps, 'component'> &
SearchFilterComponentProps,
) => <SearchFilter {...props} component={CheckboxFilter} />;
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} />
);
export { SearchFilter };
@@ -17,11 +17,9 @@ 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 { searchApiRef } from '../../api';
import { SearchContextProvider, useSearch } from '../../context';
import { useDefaultFilterValue, useAsyncFilterValues } from './hooks';
jest.useFakeTimers();
@@ -1,5 +1,5 @@
/*
* Copyright 2021 The Backstage Authors
* 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.
@@ -17,11 +17,14 @@
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';
import { useSearch } from '../../context';
/**
* Utility hook for either asynchronously loading filter values from a given
* function or synchronously providing a given list of default values.
*
* @public
*/
export const useAsyncFilterValues = (
fn: ((partial: string) => Promise<string[]>) | undefined,
@@ -75,6 +78,8 @@ export const useAsyncFilterValues = (
/**
* Utility hook for applying a given default value to the search context.
*
* @public
*/
export const useDefaultFilterValue = (
name: string,
@@ -0,0 +1,24 @@
/*
* 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.
*/
export { CheckboxFilter, SearchFilter, SelectFilter } from './SearchFilter';
export { AutocompleteFilter } from './SearchFilter.Autocomplete';
export { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
export type {
SearchFilterComponentProps,
SearchFilterWrapperProps,
} from './SearchFilter';
export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete';
@@ -15,3 +15,4 @@
*/
export * from './HighlightedSearchResultText';
export * from './SearchFilter';
+7 -21
View File
@@ -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,
@@ -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 };
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export { SearchFilter, SearchFilterNext } from './SearchFilter';
export { SearchFilter } from './SearchFilter';
export type {
SearchFilterComponentProps,
SearchFilterWrapperProps,
+1 -1
View File
@@ -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,