Merge pull request #11849 from realandersn/search/move-reusable-components
[Search] move reusable components to search-react package
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-search': patch
|
||||
'@backstage/plugin-search-react': patch
|
||||
---
|
||||
|
||||
Components `<DefaultResultListItem>`, `<SearchBar>` (including `<SearchBarBase>`), `<SearchFilter>` (including `.Checkbox`, `.Select`, and `.Autocomplete` static prop components), `<SearchResult>`, and `<SearchResultPager>` are now exported from `@backstage/plugin-search-react`. They are now deprecated in `@backstage/plugin-search` and will be removed in a future release.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
'@backstage/create-app': patch
|
||||
---
|
||||
|
||||
Components `<DefaultResultListItem>`, `<SearchBar>`, `<SearchFilter>`, and `<SearchResult>` are now deprecated in `@backstage/plugin-search` and should be imported from `@backstage/plugin-search-react` instead.
|
||||
|
||||
To upgrade your App, update the following in `packages/app/src/components/search/SearchPage.tsx`:
|
||||
|
||||
```diff
|
||||
import {
|
||||
DefaultResultListItem
|
||||
SearchBar
|
||||
SearchFilter
|
||||
SearchResult
|
||||
- } from `@backstage/plugin-search`;
|
||||
+ } from `@backstage/plugin-search-react`;
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search': minor
|
||||
---
|
||||
|
||||
The pre-alpha `<SearchPageNext>`, `<SearchBarNext>`, `etc...` components have been removed. In the unlikely event you were still using/referencing them, please update to using their non-`*Next` equivalents from either `@backstage/plugin-search-react` or `@backstage/plugin-search`.
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 82 KiB |
@@ -18,7 +18,7 @@ If you haven't setup Backstage already, start
|
||||
|
||||
```bash
|
||||
# From your Backstage root directory
|
||||
yarn add --cwd packages/app @backstage/plugin-search
|
||||
yarn add --cwd packages/app @backstage/plugin-search @backstage/plugin-search-react
|
||||
```
|
||||
|
||||
Create a new `packages/app/src/components/search/SearchPage.tsx` file in your
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
SearchResult,
|
||||
DefaultResultListItem,
|
||||
SearchFilter,
|
||||
} from '@backstage/plugin-search';
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { CatalogResultListItem } from '@backstage/plugin-catalog';
|
||||
|
||||
export const searchPage = (
|
||||
@@ -213,7 +213,7 @@ apiRouter.use('/search', await search(searchEnv));
|
||||
|
||||
### Frontend
|
||||
|
||||
The Search Plugin exposes several default filter types as static properties,
|
||||
The Search Plugin web library (`@backstage/plugin-search-react`) exposes several default filter types as static properties,
|
||||
including `<SearchFilter.Select />` and `<SearchFilter.Checkbox />`. These allow
|
||||
you to provide values relevant to your Backstage instance that, when selected,
|
||||
get passed to the backend.
|
||||
@@ -237,7 +237,7 @@ If you have advanced filter needs, you can specify your own filter component
|
||||
like this (although new core filter contributions are welcome):
|
||||
|
||||
```tsx
|
||||
import { useSearch, SearchFilter } from '@backstage/plugin-search';
|
||||
import { useSearch, SearchFilter } from '@backstage/plugin-search-react';
|
||||
|
||||
const MyCustomFilter = () => {
|
||||
// Note: filters contain filter data from other filter components. Be sure
|
||||
|
||||
@@ -38,16 +38,15 @@ import {
|
||||
catalogApiRef,
|
||||
CATALOG_FILTER_EXISTS,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { searchPlugin, SearchType } from '@backstage/plugin-search';
|
||||
import {
|
||||
DefaultResultListItem,
|
||||
SearchBar,
|
||||
SearchFilter,
|
||||
searchPlugin,
|
||||
SearchBar,
|
||||
SearchResult,
|
||||
SearchResultPager,
|
||||
SearchType,
|
||||
} from '@backstage/plugin-search';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
|
||||
@@ -29,15 +29,15 @@ import {
|
||||
catalogApiRef,
|
||||
CATALOG_FILTER_EXISTS,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { SearchType } from '@backstage/plugin-search';
|
||||
import {
|
||||
DefaultResultListItem,
|
||||
SearchBar,
|
||||
SearchFilter,
|
||||
SearchResult,
|
||||
SearchResultPager,
|
||||
SearchType,
|
||||
} from '@backstage/plugin-search';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
|
||||
import { Grid, List, makeStyles, Paper, Theme } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
|
||||
+4
-4
@@ -8,14 +8,14 @@ import {
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { TechDocsSearchResultListItem } from '@backstage/plugin-techdocs';
|
||||
|
||||
import { SearchType } from '@backstage/plugin-search';
|
||||
import {
|
||||
DefaultResultListItem,
|
||||
SearchBar,
|
||||
SearchFilter,
|
||||
SearchResult,
|
||||
SearchType,
|
||||
DefaultResultListItem,
|
||||
} from '@backstage/plugin-search';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import {
|
||||
CatalogIcon,
|
||||
Content,
|
||||
|
||||
@@ -7,12 +7,40 @@
|
||||
|
||||
import { ApiRef } from '@backstage/core-plugin-api';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { InputBaseProps } from '@material-ui/core';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { default as React_2 } from 'react';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ResultHighlight } from '@backstage/plugin-search-common';
|
||||
import { SearchDocument } from '@backstage/plugin-search-common';
|
||||
import { SearchQuery } from '@backstage/plugin-search-common';
|
||||
import { SearchResult as SearchResult_2 } 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 DefaultResultListItem: (
|
||||
props: DefaultResultListItemProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type DefaultResultListItemProps = {
|
||||
icon?: ReactNode;
|
||||
secondaryAction?: ReactNode;
|
||||
result: SearchDocument;
|
||||
highlight?: ResultHighlight;
|
||||
lineClamp?: number;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const HighlightedSearchResultText: ({
|
||||
text,
|
||||
@@ -20,7 +48,7 @@ export const HighlightedSearchResultText: ({
|
||||
postTag,
|
||||
}: HighlightedSearchResultTextProps) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export type HighlightedSearchResultTextProps = {
|
||||
text: string;
|
||||
preTag: string;
|
||||
@@ -45,6 +73,42 @@ 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;
|
||||
|
||||
// @public
|
||||
export const SearchBarBase: ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
debounceTime,
|
||||
clearButton,
|
||||
fullWidth,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps,
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
debounceTime?: number;
|
||||
clearButton?: boolean;
|
||||
onClear?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
|
||||
// @public
|
||||
export const SearchContextProvider: (
|
||||
props: SearchContextProviderProps,
|
||||
@@ -74,6 +138,55 @@ 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 SearchResult: (props: SearchResultProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const SearchResultComponent: ({
|
||||
children,
|
||||
}: SearchResultProps) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export const SearchResultPager: () => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type SearchResultProps = {
|
||||
children: (results: { results: SearchResult_2[] }) => JSX.Element;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const SelectFilter: (props: SearchFilterComponentProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export const useSearch: () => SearchContextValue;
|
||||
|
||||
|
||||
@@ -32,11 +32,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/plugin-search-common": "^0.3.5-next.0",
|
||||
"@backstage/core-components": "^0.9.5-next.1",
|
||||
"@backstage/core-plugin-api": "^1.0.3-next.0",
|
||||
"@backstage/version-bridge": "^1.0.1",
|
||||
"react-use": "^17.3.2",
|
||||
"@backstage/theme": "^0.2.15",
|
||||
"@backstage/types": "^1.0.0",
|
||||
"@material-ui/core": "^4.12.2"
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-use": "^17.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
@@ -47,7 +52,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"
|
||||
|
||||
+1
-1
@@ -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.
|
||||
+1
-1
@@ -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.
|
||||
+36
-5
@@ -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.
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { AnalyticsContext } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
ResultHighlight,
|
||||
SearchDocument,
|
||||
} from '@backstage/plugin-search-common';
|
||||
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
|
||||
import { HighlightedSearchResultText } from '../HighlightedSearchResultText';
|
||||
import {
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
@@ -29,7 +30,12 @@ import {
|
||||
} from '@material-ui/core';
|
||||
import { Link } from '@backstage/core-components';
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* Props for {@link DefaultResultListItem}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type DefaultResultListItemProps = {
|
||||
icon?: ReactNode;
|
||||
secondaryAction?: ReactNode;
|
||||
result: SearchDocument;
|
||||
@@ -37,13 +43,18 @@ type Props = {
|
||||
lineClamp?: number;
|
||||
};
|
||||
|
||||
export const DefaultResultListItem = ({
|
||||
/**
|
||||
* A default result list item.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const DefaultResultListItemComponent = ({
|
||||
result,
|
||||
highlight,
|
||||
icon,
|
||||
secondaryAction,
|
||||
lineClamp = 5,
|
||||
}: Props) => {
|
||||
}: DefaultResultListItemProps) => {
|
||||
return (
|
||||
<Link to={result.location}>
|
||||
<ListItem alignItems="center">
|
||||
@@ -88,3 +99,23 @@ export const DefaultResultListItem = ({
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
const HigherOrderDefaultResultListItem = (
|
||||
props: DefaultResultListItemProps,
|
||||
) => {
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
pluginId: 'search',
|
||||
extension: 'DefaultResultListItem',
|
||||
}}
|
||||
>
|
||||
<DefaultResultListItemComponent {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
export { HigherOrderDefaultResultListItem as DefaultResultListItem };
|
||||
+2
-1
@@ -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.
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
export { DefaultResultListItem } from './DefaultResultListItem';
|
||||
export type { DefaultResultListItemProps } from './DefaultResultListItem';
|
||||
+2
@@ -25,6 +25,8 @@ const useStyles = makeStyles(
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for {@link HighlightedSearchResultText}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type HighlightedSearchResultTextProps = {
|
||||
|
||||
+7
-7
@@ -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,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Grid, makeStyles, Paper } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import {
|
||||
searchApiRef,
|
||||
MockSearchApi,
|
||||
SearchContextProvider,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { Grid, makeStyles, Paper } from '@material-ui/core';
|
||||
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef, MockSearchApi } from '../../api';
|
||||
import { SearchContextProvider } from '../../context';
|
||||
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
export default {
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api';
|
||||
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
|
||||
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef } from '../../api';
|
||||
import { SearchContextProvider } from '../../context';
|
||||
import { SearchBar } from './SearchBar';
|
||||
|
||||
jest.mock('@backstage/core-plugin-api', () => ({
|
||||
...jest.requireActual('@backstage/core-plugin-api'),
|
||||
}));
|
||||
|
||||
describe('SearchBar', () => {
|
||||
const initialState = {
|
||||
term: '',
|
||||
filters: {},
|
||||
types: ['*'],
|
||||
pageCursor: '',
|
||||
};
|
||||
|
||||
const query = jest.fn().mockResolvedValue({});
|
||||
const analyticsApiSpy = new MockAnalyticsApi();
|
||||
let apiRegistry: TestApiRegistry;
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: { title: 'Mock title' },
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
const name = 'Search';
|
||||
const term = 'term';
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('Renders without exploding', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByPlaceholderText('Search in Mock title'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Renders with custom placeholder', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState }}>
|
||||
<SearchBar placeholder="This is a custom placeholder" />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByPlaceholderText('This is a custom placeholder'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Renders based on initial search', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates term state when text is entered', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
const defaultDebounceTime = 200;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(defaultDebounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(textbox).toHaveValue(value);
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('Clear button clears term state', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBar />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue(term);
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toHaveValue('');
|
||||
});
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: '' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('Should not show clear button', async () => {
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={{ ...initialState, term }}>
|
||||
<SearchBar clearButton={false} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Adheres to provided debounceTime', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
expect(query).not.toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
expect(textbox).toHaveValue(value);
|
||||
|
||||
expect(query).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ term: value }),
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('does not capture analytics event if not enabled in app', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider initialState={initialState}>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
,
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('captures analytics events if enabled in app', async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
jest.useFakeTimers();
|
||||
|
||||
const debounceTime = 600;
|
||||
|
||||
apiRegistry = TestApiRegistry.from(
|
||||
[analyticsApiRef, analyticsApiSpy],
|
||||
[
|
||||
configApiRef,
|
||||
new ConfigReader({
|
||||
app: {
|
||||
title: 'Mock title',
|
||||
analytics: {
|
||||
ga: {
|
||||
trackingId: 'xyz123',
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
[searchApiRef, { query }],
|
||||
);
|
||||
|
||||
render(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<SearchContextProvider
|
||||
initialState={{
|
||||
term: '',
|
||||
types: ['techdocs', 'software-catalog'],
|
||||
filters: {},
|
||||
}}
|
||||
>
|
||||
<SearchBar debounceTime={debounceTime} />
|
||||
</SearchContextProvider>
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const textbox = screen.getByRole('textbox', { name });
|
||||
|
||||
const value = 'value';
|
||||
|
||||
await user.type(textbox, value);
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue(value));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(1);
|
||||
expect(analyticsApiSpy.getEvents()[0]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'SearchBar',
|
||||
pluginId: 'search',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
},
|
||||
subject: 'value',
|
||||
});
|
||||
|
||||
await user.clear(textbox);
|
||||
|
||||
// make sure new term is captured
|
||||
await user.type(textbox, 'new value');
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(debounceTime);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(textbox).toHaveValue('new value'));
|
||||
|
||||
expect(analyticsApiSpy.getEvents()).toHaveLength(2);
|
||||
expect(analyticsApiSpy.getEvents()[1]).toEqual({
|
||||
action: 'search',
|
||||
context: {
|
||||
extension: 'SearchBar',
|
||||
pluginId: 'search',
|
||||
routeRef: 'unknown',
|
||||
searchTypes: 'software-catalog,techdocs',
|
||||
},
|
||||
subject: 'new value',
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* 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,
|
||||
KeyboardEvent,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import {
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
|
||||
import {
|
||||
AnalyticsContext,
|
||||
configApiRef,
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
useSearchContextCheck,
|
||||
} from '../../context';
|
||||
import { TrackSearch } from '../SearchTracker';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBarBase}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
debounceTime?: number;
|
||||
clearButton?: boolean;
|
||||
onClear?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* All search boxes exported by the search plugin are based on the <SearchBarBase />,
|
||||
* and this one is based on the <InputBase /> component from Material UI.
|
||||
* Recommended if you don't use Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchBarBase = ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
debounceTime = 200,
|
||||
clearButton = true,
|
||||
fullWidth = true,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps = {},
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const [value, setValue] = useState<string>(defaultValue as string);
|
||||
const hasSearchContext = useSearchContextCheck();
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prevValue =>
|
||||
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
|
||||
);
|
||||
}, [defaultValue]);
|
||||
|
||||
useDebounce(() => onChange(value), debounceTime, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
if (onSubmit && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
|
||||
const placeholder = `Search in ${
|
||||
configApi.getOptionalString('app.title') || 'Backstage'
|
||||
}`;
|
||||
|
||||
const startAdornment = (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const searchBar = (
|
||||
<TrackSearch>
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</TrackSearch>
|
||||
);
|
||||
|
||||
return hasSearchContext ? (
|
||||
searchBar
|
||||
) : (
|
||||
<SearchContextProvider>{searchBar}</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBar}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
|
||||
/**
|
||||
* Recommended search bar when you use the Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setTerm(newValue);
|
||||
}
|
||||
},
|
||||
[onChange, setTerm],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
|
||||
>
|
||||
<SearchBarBase value={term} onChange={handleChange} {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 { SearchBar, SearchBarBase } from './SearchBar';
|
||||
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+6
-7
@@ -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 };
|
||||
+3
-5
@@ -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();
|
||||
+7
-2
@@ -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,23 @@
|
||||
/*
|
||||
* 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 type {
|
||||
SearchFilterComponentProps,
|
||||
SearchFilterWrapperProps,
|
||||
} from './SearchFilter';
|
||||
export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete';
|
||||
+8
-10
@@ -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,20 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { List, ListItem } from '@material-ui/core';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { List, ListItem } from '@material-ui/core';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import { DefaultResultListItem } from '../DefaultResultListItem';
|
||||
|
||||
import {
|
||||
searchApiRef,
|
||||
MockSearchApi,
|
||||
SearchContextProvider,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { SearchResult } from './SearchResult';
|
||||
import { Link } from '@backstage/core-components';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef, MockSearchApi } from '../../api';
|
||||
import { SearchContextProvider } from '../../context';
|
||||
import { DefaultResultListItem } from '../DefaultResultListItem';
|
||||
import { SearchResult } from './SearchResult';
|
||||
|
||||
const mockResults = {
|
||||
results: [
|
||||
{
|
||||
+8
-6
@@ -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,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
|
||||
import { useSearch } from '../../context';
|
||||
import { SearchResult } from './SearchResult';
|
||||
|
||||
jest.mock('@backstage/plugin-search-react', () => ({
|
||||
...jest.requireActual('@backstage/plugin-search-react'),
|
||||
jest.mock('../../context', () => ({
|
||||
...jest.requireActual('../../context'),
|
||||
useSearch: jest.fn().mockReturnValue({
|
||||
result: {},
|
||||
}),
|
||||
+35
-6
@@ -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,20 +14,33 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {
|
||||
EmptyState,
|
||||
Progress,
|
||||
ResponseErrorPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { AnalyticsContext } from '@backstage/core-plugin-api';
|
||||
import { SearchResult } from '@backstage/plugin-search-common';
|
||||
import React from 'react';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
|
||||
type Props = {
|
||||
import { useSearch } from '../../context';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchResultComponent}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchResultProps = {
|
||||
children: (results: { results: SearchResult[] }) => JSX.Element;
|
||||
};
|
||||
|
||||
export const SearchResultComponent = ({ children }: Props) => {
|
||||
/**
|
||||
* A component returning the search result.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchResultComponent = ({ children }: SearchResultProps) => {
|
||||
const {
|
||||
result: { loading, error, value },
|
||||
} = useSearch();
|
||||
@@ -51,4 +64,20 @@ export const SearchResultComponent = ({ children }: Props) => {
|
||||
return <>{children({ results: value.results })}</>;
|
||||
};
|
||||
|
||||
export { SearchResultComponent as SearchResult };
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
const HigherOrderSearchResult = (props: SearchResultProps) => {
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{
|
||||
pluginId: 'search',
|
||||
extension: 'SearchResult',
|
||||
}}
|
||||
>
|
||||
<SearchResultComponent {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
export { HigherOrderSearchResult as SearchResult };
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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 { SearchResult, SearchResultComponent } from './SearchResult';
|
||||
export type { SearchResultProps } from './SearchResult';
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { renderInTestApp } from '@backstage/test-utils';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { useSearch } from '../../context';
|
||||
import { SearchResultPager } from './SearchResultPager';
|
||||
|
||||
jest.mock('../../context', () => ({
|
||||
...jest.requireActual('../../context'),
|
||||
useSearch: jest.fn().mockReturnValue({
|
||||
result: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('SearchResultPager', () => {
|
||||
it('renders pager buttons', async () => {
|
||||
const fetchNextPage = jest.fn();
|
||||
const fetchPreviousPage = jest.fn();
|
||||
(useSearch as jest.Mock).mockReturnValueOnce({
|
||||
result: { loading: false, value: [] },
|
||||
fetchNextPage,
|
||||
fetchPreviousPage,
|
||||
});
|
||||
|
||||
const { getByLabelText } = await renderInTestApp(<SearchResultPager />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText('previous page')).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(getByLabelText('previous page'));
|
||||
expect(fetchPreviousPage).toBeCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByLabelText('next page')).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(getByLabelText('next page'));
|
||||
|
||||
expect(fetchNextPage).toBeCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* 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 { Button, makeStyles } from '@material-ui/core';
|
||||
import ArrowBackIosIcon from '@material-ui/icons/ArrowBackIos';
|
||||
import ArrowForwardIosIcon from '@material-ui/icons/ArrowForwardIos';
|
||||
|
||||
import { useSearch } from '../../context';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing(2),
|
||||
margin: theme.spacing(2, 0),
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SearchResultPager = () => {
|
||||
const { fetchNextPage, fetchPreviousPage } = useSearch();
|
||||
const classes = useStyles();
|
||||
|
||||
if (!fetchNextPage && !fetchPreviousPage) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<nav arial-label="pagination navigation" className={classes.root}>
|
||||
<Button
|
||||
aria-label="previous page"
|
||||
disabled={!fetchPreviousPage}
|
||||
onClick={fetchPreviousPage}
|
||||
startIcon={<ArrowBackIosIcon />}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
aria-label="next page"
|
||||
disabled={!fetchNextPage}
|
||||
onClick={fetchNextPage}
|
||||
endIcon={<ArrowForwardIosIcon />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
+2
-2
@@ -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,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { SearchResult } from './SearchResult';
|
||||
export { SearchResultPager } from './SearchResultPager';
|
||||
+2
-2
@@ -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.
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
import { useAnalytics } from '@backstage/core-plugin-api';
|
||||
import { useSearch } from '@backstage/plugin-search-react';
|
||||
import { useSearch } from '../../context';
|
||||
|
||||
/**
|
||||
* Capture search event on term change.
|
||||
+1
-1
@@ -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.
|
||||
@@ -15,3 +15,8 @@
|
||||
*/
|
||||
|
||||
export * from './HighlightedSearchResultText';
|
||||
export * from './SearchFilter';
|
||||
export * from './SearchResult';
|
||||
export * from './SearchResultPager';
|
||||
export * from './SearchBar';
|
||||
export * from './DefaultResultListItem';
|
||||
|
||||
@@ -13,14 +13,17 @@ Run `yarn dev` in the root directory, and then navigate to [/search](http://loca
|
||||
This search plugin is primarily responsible for the following:
|
||||
|
||||
- Providing a `<SearchPage />` routable extension.
|
||||
- Exposing various search-related components (like `<SearchBar />`,
|
||||
`<SearchFilter />`, etc), which can be composed by a Backstage App or by
|
||||
- Exposing various search-related components (like `<SearchModal />`,
|
||||
`<SidebarSearch />`, etc), which can be composed by a Backstage App or by
|
||||
other Backstage Plugins to power search experiences of all kinds.
|
||||
- Exposing a `<SearchContextProvider />`, which manages search state and API
|
||||
communication with the Backstage backend.
|
||||
|
||||
Don't forget, a lot of functionality is available in backend plugins:
|
||||
Don't forget, a lot of functionality is available in web libraries and backend plugins:
|
||||
|
||||
- `@backstage/plugin-search-react`, which is responsible for:
|
||||
- Exposing a `<SearchContextProvider />`, which manages search state and API
|
||||
communication with the Backstage backend.
|
||||
- Exposing the `SearchApi` and its corresponding ref.
|
||||
- Exposing reusable components, such as `<SearchBar>` and `<SearchFilter>`, etc.
|
||||
- `@backstage/plugin-search-backend-node`, which is responsible for the search
|
||||
index management
|
||||
- `@backstage/plugin-search-backend`, which is responsible for query processing
|
||||
|
||||
+51
-110
@@ -6,36 +6,29 @@
|
||||
/// <reference types="react" />
|
||||
|
||||
import { BackstagePlugin } from '@backstage/core-plugin-api';
|
||||
import { DefaultResultListItemProps } from '@backstage/plugin-search-react';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
import { InputBaseProps } from '@material-ui/core';
|
||||
import { ReactElement } from 'react';
|
||||
import { ReactNode } from 'react';
|
||||
import { ResultHighlight } from '@backstage/plugin-search-common';
|
||||
import { RouteRef } from '@backstage/core-plugin-api';
|
||||
import { SearchDocument } from '@backstage/plugin-search-common';
|
||||
import { SearchResult as SearchResult_2 } from '@backstage/plugin-search-common';
|
||||
import { SearchAutocompleteFilterProps as SearchAutocompleteFilterProps_2 } from '@backstage/plugin-search-react';
|
||||
import { SearchBarBaseProps as SearchBarBaseProps_2 } from '@backstage/plugin-search-react';
|
||||
import { SearchFilterComponentProps as SearchFilterComponentProps_2 } from '@backstage/plugin-search-react';
|
||||
import { SearchResultProps } from '@backstage/plugin-search-react';
|
||||
|
||||
// Warning: (ae-missing-release-tag) "DefaultResultListItem" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const DefaultResultListItem: ({
|
||||
result,
|
||||
highlight,
|
||||
icon,
|
||||
secondaryAction,
|
||||
lineClamp,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
secondaryAction?: ReactNode;
|
||||
result: SearchDocument;
|
||||
highlight?: ResultHighlight | undefined;
|
||||
lineClamp?: number | undefined;
|
||||
}) => JSX.Element;
|
||||
// @public @deprecated (undocumented)
|
||||
export const DefaultResultListItem: (
|
||||
props: DefaultResultListItemProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "FiltersProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "Filters" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export type FilterOptions = {
|
||||
kind: Array<string>;
|
||||
lifecycle: Array<string>;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export const Filters: ({
|
||||
filters,
|
||||
filterOptions,
|
||||
@@ -44,51 +37,57 @@ export const Filters: ({
|
||||
updateChecked,
|
||||
}: FiltersProps) => JSX.Element;
|
||||
|
||||
// Warning: (ae-forgotten-export) The symbol "FiltersButtonProps" needs to be exported by the entry point index.d.ts
|
||||
// Warning: (ae-missing-release-tag) "FiltersButton" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const FiltersButton: ({
|
||||
numberOfSelectedFilters,
|
||||
handleToggleFilters,
|
||||
}: FiltersButtonProps) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "FiltersState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated
|
||||
export type FiltersButtonProps = {
|
||||
numberOfSelectedFilters: number;
|
||||
handleToggleFilters: () => void;
|
||||
};
|
||||
|
||||
// @public @deprecated
|
||||
export type FiltersProps = {
|
||||
filters: FiltersState;
|
||||
filterOptions: FilterOptions;
|
||||
resetFilters: () => void;
|
||||
updateSelected: (filter: string) => void;
|
||||
updateChecked: (filter: string) => void;
|
||||
};
|
||||
|
||||
// @public @deprecated (undocumented)
|
||||
export type FiltersState = {
|
||||
selected: string;
|
||||
checked: Array<string>;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "HomePageSearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const HomePageSearchBar: ({
|
||||
...props
|
||||
}: Partial<Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>>) => JSX.Element;
|
||||
}: Partial<Omit<SearchBarBaseProps_2, 'onChange' | 'onSubmit'>>) => JSX.Element;
|
||||
|
||||
// @public
|
||||
export type HomePageSearchBarProps = Partial<
|
||||
Omit<SearchBarBaseProps, 'onChange' | 'onSubmit'>
|
||||
Omit<SearchBarBaseProps_2, 'onChange' | 'onSubmit'>
|
||||
>;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @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;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export const SearchBar: ({ onChange, ...props }: SearchBarProps) => JSX.Element;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export const SearchBarBase: ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
@@ -100,9 +99,9 @@ export const SearchBarBase: ({
|
||||
inputProps: defaultInputProps,
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => JSX.Element;
|
||||
}: SearchBarBaseProps_2) => JSX.Element;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
debounceTime?: number;
|
||||
clearButton?: boolean;
|
||||
@@ -111,20 +110,10 @@ export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchBarNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export const SearchBarNext: ({
|
||||
onChange,
|
||||
...props
|
||||
}: Partial<SearchBarBaseProps>) => JSX.Element;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
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 +124,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,30 +137,12 @@ 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;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SearchModal: ({
|
||||
open,
|
||||
@@ -185,8 +156,6 @@ export interface SearchModalChildrenProps {
|
||||
toggleModal: () => void;
|
||||
}
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchModalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export interface SearchModalProps {
|
||||
children?: (props: SearchModalChildrenProps) => JSX.Element;
|
||||
@@ -217,45 +186,25 @@ export type SearchModalValue = {
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SearchPage: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchPageNext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public @deprecated (undocumented)
|
||||
export const SearchPageNext: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "searchPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
const searchPlugin: BackstagePlugin<
|
||||
{
|
||||
root: RouteRef<undefined>;
|
||||
nextRoot: RouteRef<undefined>;
|
||||
},
|
||||
{}
|
||||
>;
|
||||
export { searchPlugin as plugin };
|
||||
export { searchPlugin };
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SearchResult: ({
|
||||
children,
|
||||
}: {
|
||||
children: (results: { results: SearchResult_2[] }) => JSX.Element;
|
||||
}) => JSX.Element;
|
||||
// @public @deprecated (undocumented)
|
||||
export const SearchResult: (props: SearchResultProps) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchResultPager" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated (undocumented)
|
||||
export const SearchResultPager: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SearchType: {
|
||||
(props: SearchTypeProps): JSX.Element;
|
||||
@@ -274,7 +223,7 @@ export type SearchTypeAccordionProps = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export type SearchTypeProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
@@ -291,29 +240,21 @@ export type SearchTypeTabsProps = {
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SidebarSearch: (props: SidebarSearchProps) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SidebarSearchModal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
export const SidebarSearchModal: (
|
||||
props: SidebarSearchModalProps,
|
||||
) => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SidebarSearchModalProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export type SidebarSearchModalProps = {
|
||||
icon?: IconComponent;
|
||||
children?: (props: SearchModalChildrenProps) => JSX.Element;
|
||||
};
|
||||
|
||||
// Warning: (ae-missing-release-tag) "SidebarSearchProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public
|
||||
export type SidebarSearchProps = {
|
||||
icon?: IconComponent;
|
||||
};
|
||||
|
||||
@@ -44,17 +44,34 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This type and corresponding component will be removed in a
|
||||
* future release.
|
||||
*/
|
||||
export type FiltersState = {
|
||||
selected: string;
|
||||
checked: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This type and corresponding component will be removed in a
|
||||
* future release.
|
||||
*/
|
||||
export type FilterOptions = {
|
||||
kind: Array<string>;
|
||||
lifecycle: Array<string>;
|
||||
};
|
||||
|
||||
type FiltersProps = {
|
||||
/**
|
||||
* Props for {@link Filters}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type and corresponding component will be removed in a
|
||||
* future release.
|
||||
*/
|
||||
export type FiltersProps = {
|
||||
filters: FiltersState;
|
||||
filterOptions: FilterOptions;
|
||||
resetFilters: () => void;
|
||||
@@ -62,6 +79,11 @@ type FiltersProps = {
|
||||
updateChecked: (filter: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated This component will be removed in a future release. Use
|
||||
* `SearchFilter` from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const Filters = ({
|
||||
filters,
|
||||
filterOptions,
|
||||
|
||||
@@ -28,11 +28,22 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
type FiltersButtonProps = {
|
||||
/**
|
||||
* Props for {@link FiltersButton}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated This type and corresponding component will be removed in a
|
||||
* future release.
|
||||
*/
|
||||
export type FiltersButtonProps = {
|
||||
numberOfSelectedFilters: number;
|
||||
handleToggleFilters: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated See `SearchFilter` in `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const FiltersButton = ({
|
||||
numberOfSelectedFilters,
|
||||
handleToggleFilters,
|
||||
|
||||
@@ -15,5 +15,6 @@
|
||||
*/
|
||||
|
||||
export { FiltersButton } from './FiltersButton';
|
||||
export type { FiltersButtonProps } from './FiltersButton';
|
||||
export { Filters } from './Filters';
|
||||
export type { FiltersState } from './Filters';
|
||||
export type { FilterOptions, FiltersProps, FiltersState } from './Filters';
|
||||
|
||||
@@ -16,7 +16,10 @@
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { SearchBarBase, SearchBarBaseProps } from '../SearchBar';
|
||||
import {
|
||||
SearchBarBase,
|
||||
SearchBarBaseProps,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { useNavigateToQuery } from '../util';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
@@ -37,9 +40,7 @@ export type HomePageSearchBarProps = Partial<
|
||||
>;
|
||||
|
||||
/**
|
||||
* The search bar created specifically for the composable home page
|
||||
*
|
||||
* @public
|
||||
* The search bar created specifically for the composable home page.
|
||||
*/
|
||||
export const HomePageSearchBar = ({ ...props }: HomePageSearchBarProps) => {
|
||||
const classes = useStyles(props);
|
||||
|
||||
@@ -14,35 +14,20 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
KeyboardEvent,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
import { configApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
} from '@material-ui/core';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import ClearButton from '@material-ui/icons/Clear';
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import { InputBaseProps } from '@material-ui/core';
|
||||
|
||||
import {
|
||||
SearchContextProvider,
|
||||
SearchBarBase as RealSearchBarBase,
|
||||
useSearch,
|
||||
useSearchContextCheck,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { TrackSearch } from '../SearchTracker';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBarBase}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
debounceTime?: number;
|
||||
@@ -58,100 +43,15 @@ export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
* Recommended if you don't use Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const SearchBarBase = ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
debounceTime = 200,
|
||||
clearButton = true,
|
||||
fullWidth = true,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps = {},
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const [value, setValue] = useState<string>(defaultValue as string);
|
||||
const hasSearchContext = useSearchContextCheck();
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prevValue =>
|
||||
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
|
||||
);
|
||||
}, [defaultValue]);
|
||||
|
||||
useDebounce(() => onChange(value), debounceTime, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
if (onSubmit && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit],
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
|
||||
const placeholder = `Search in ${
|
||||
configApi.getOptionalString('app.title') || 'Backstage'
|
||||
}`;
|
||||
|
||||
const startAdornment = (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const searchBar = (
|
||||
<TrackSearch>
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</TrackSearch>
|
||||
);
|
||||
|
||||
return hasSearchContext ? (
|
||||
searchBar
|
||||
) : (
|
||||
<SearchContextProvider>{searchBar}</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
export const SearchBarBase = RealSearchBarBase;
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBar}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
|
||||
@@ -159,6 +59,7 @@ export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
* Recommended search bar when you use the Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
|
||||
@@ -14,103 +14,14 @@
|
||||
* 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 '@backstage/plugin-search-react';
|
||||
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
|
||||
import { SearchFilterComponentProps } from './SearchFilter';
|
||||
import { SearchFilterComponentProps } from '@backstage/plugin-search-react';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,33 +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';
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export type SearchFilterComponentProps = {
|
||||
className?: string;
|
||||
@@ -63,152 +48,33 @@ export type SearchFilterComponentProps = {
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export type SearchFilterWrapperProps = SearchFilterComponentProps & {
|
||||
component: (props: SearchFilterComponentProps) => ReactElement;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
const SearchFilter = ({
|
||||
component: Element,
|
||||
...props
|
||||
}: SearchFilterWrapperProps) => <Element {...props} />;
|
||||
|
||||
/**
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
SearchFilter.Checkbox = (
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
) => <SearchFilter {...props} component={CheckboxFilter} />;
|
||||
|
||||
/**
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
SearchFilter.Select = (
|
||||
props: Omit<SearchFilterWrapperProps, 'component'> &
|
||||
SearchFilterComponentProps,
|
||||
@@ -218,18 +84,12 @@ SearchFilter.Select = (
|
||||
* 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
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
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,
|
||||
|
||||
@@ -27,17 +27,17 @@ import {
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { rootRouteRef } from '../../plugin';
|
||||
import { DefaultResultListItem } from '../DefaultResultListItem';
|
||||
import { SearchBar } from '../SearchBar';
|
||||
import {
|
||||
DefaultResultListItem,
|
||||
searchApiRef,
|
||||
MockSearchApi,
|
||||
SearchContextProvider,
|
||||
SearchResult,
|
||||
SearchResultPager,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { SearchModal } from './SearchModal';
|
||||
import { SearchResult } from '../SearchResult';
|
||||
import { SearchResultPager } from '../SearchResultPager';
|
||||
import { SearchType } from '../SearchType';
|
||||
import { useSearchModal } from './useSearchModal';
|
||||
|
||||
|
||||
@@ -28,14 +28,14 @@ import {
|
||||
} from '@material-ui/core';
|
||||
import LaunchIcon from '@material-ui/icons/Launch';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { SearchBar } from '../SearchBar';
|
||||
import { DefaultResultListItem } from '../DefaultResultListItem';
|
||||
import { SearchResult } from '../SearchResult';
|
||||
import {
|
||||
DefaultResultListItem,
|
||||
SearchContextProvider,
|
||||
SearchBar,
|
||||
SearchResult,
|
||||
SearchResultPager,
|
||||
useSearch,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import { SearchResultPager } from '../SearchResultPager';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Link, useContent } from '@backstage/core-components';
|
||||
import { rootRouteRef } from '../../plugin';
|
||||
@@ -50,6 +50,9 @@ export interface SearchModalChildrenProps {
|
||||
toggleModal: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
**/
|
||||
export interface SearchModalProps {
|
||||
/**
|
||||
* If true, it renders the modal.
|
||||
@@ -167,6 +170,9 @@ export const Modal = ({ toggleModal }: SearchModalProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SearchModal = ({
|
||||
open = true,
|
||||
hidden,
|
||||
|
||||
@@ -87,6 +87,9 @@ export const UrlUpdater = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SearchPage = () => {
|
||||
const outlet = useOutlet();
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ const useStyles = makeStyles(theme => ({
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const SearchResultPager = () => {
|
||||
const { fetchNextPage, fetchPreviousPage } = useSearch();
|
||||
const classes = useStyles();
|
||||
|
||||
@@ -47,6 +47,8 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
/**
|
||||
* Props for {@link SearchType}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchTypeProps = {
|
||||
@@ -56,6 +58,9 @@ export type SearchTypeProps = {
|
||||
defaultValue?: string[] | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
const SearchType = (props: SearchTypeProps) => {
|
||||
const { className, defaultValue, name, values = [] } = props;
|
||||
const classes = useStyles();
|
||||
|
||||
@@ -21,10 +21,18 @@ import { rootRouteRef } from '../../plugin';
|
||||
import { useRouteRef, IconComponent } from '@backstage/core-plugin-api';
|
||||
import { SidebarSearchField, useContent } from '@backstage/core-components';
|
||||
|
||||
/**
|
||||
* Props for {@link SidebarSearch}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SidebarSearchProps = {
|
||||
icon?: IconComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SidebarSearch = (props: SidebarSearchProps) => {
|
||||
const searchRoute = useRouteRef(rootRouteRef);
|
||||
const { focusContent } = useContent();
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
useSearchModal,
|
||||
} from '../SearchModal';
|
||||
|
||||
/**
|
||||
* Props for {@link SidebarSearchModal}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SidebarSearchModalProps = {
|
||||
icon?: IconComponent;
|
||||
children?: (props: SearchModalChildrenProps) => JSX.Element;
|
||||
|
||||
@@ -21,14 +21,19 @@
|
||||
*/
|
||||
|
||||
export { Filters, FiltersButton } from './components/Filters';
|
||||
export type { FiltersState } from './components/Filters';
|
||||
export type {
|
||||
FilterOptions,
|
||||
FiltersState,
|
||||
FiltersProps,
|
||||
FiltersButtonProps,
|
||||
} from './components/Filters';
|
||||
export type { HomePageSearchBarProps } from './components/HomePageComponent';
|
||||
export { SearchBar, SearchBarBase } from './components/SearchBar';
|
||||
export type {
|
||||
SearchBarBaseProps,
|
||||
SearchBarProps,
|
||||
} from './components/SearchBar';
|
||||
export { SearchFilter, SearchFilterNext } from './components/SearchFilter';
|
||||
export { SearchFilter } from './components/SearchFilter';
|
||||
export type {
|
||||
SearchAutocompleteFilterProps,
|
||||
SearchFilterComponentProps,
|
||||
@@ -60,9 +65,7 @@ export type { SidebarSearchModalProps } from './components/SidebarSearchModal';
|
||||
export {
|
||||
DefaultResultListItem,
|
||||
HomePageSearchBar,
|
||||
SearchBarNext,
|
||||
SearchPage,
|
||||
SearchPageNext,
|
||||
searchPlugin as plugin,
|
||||
searchPlugin,
|
||||
SearchResult,
|
||||
|
||||
@@ -15,7 +15,11 @@
|
||||
*/
|
||||
|
||||
import { SearchClient } from './apis';
|
||||
import { searchApiRef } from '@backstage/plugin-search-react';
|
||||
import {
|
||||
searchApiRef,
|
||||
SearchResult as RealSearchResult,
|
||||
DefaultResultListItem as RealDefaultResultListItem,
|
||||
} from '@backstage/plugin-search-react';
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
@@ -30,10 +34,9 @@ export const rootRouteRef = createRouteRef({
|
||||
id: 'search',
|
||||
});
|
||||
|
||||
export const rootNextRouteRef = createRouteRef({
|
||||
id: 'search:next',
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const searchPlugin = createPlugin({
|
||||
id: 'search',
|
||||
apis: [
|
||||
@@ -47,10 +50,12 @@ export const searchPlugin = createPlugin({
|
||||
],
|
||||
routes: {
|
||||
root: rootRouteRef,
|
||||
nextRoot: rootNextRouteRef,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const SearchPage = searchPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'SearchPage',
|
||||
@@ -60,67 +65,14 @@ export const SearchPage = searchPlugin.provide(
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchPage /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const SearchPageNext = searchPlugin.provide(
|
||||
createRoutableExtension({
|
||||
name: 'SearchPageNext',
|
||||
component: () => import('./components/SearchPage').then(m => m.SearchPage),
|
||||
mountPoint: rootNextRouteRef,
|
||||
}),
|
||||
);
|
||||
|
||||
export const SearchBar = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'SearchBar',
|
||||
component: {
|
||||
lazy: () => import('./components/SearchBar').then(m => m.SearchBar),
|
||||
},
|
||||
}),
|
||||
);
|
||||
export const SearchResult = RealSearchResult;
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchBar /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
* @public
|
||||
*/
|
||||
export const SearchBarNext = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'SearchBarNext',
|
||||
component: {
|
||||
lazy: () => import('./components/SearchBar').then(m => m.SearchBar),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const SearchResult = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'SearchResult',
|
||||
component: {
|
||||
lazy: () => import('./components/SearchResult').then(m => m.SearchResult),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* @deprecated This component was used for rapid prototyping of the Backstage
|
||||
* Search platform. Now that the API has stabilized, you should use the
|
||||
* <SearchResult /> component instead. This component will be removed in an
|
||||
* upcoming release.
|
||||
*/
|
||||
export const SearchResultNext = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'SearchResultNext',
|
||||
component: {
|
||||
lazy: () => import('./components/SearchResult').then(m => m.SearchResult),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const SidebarSearchModal = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'SidebarSearchModal',
|
||||
@@ -133,18 +85,15 @@ export const SidebarSearchModal = searchPlugin.provide(
|
||||
}),
|
||||
);
|
||||
|
||||
export const DefaultResultListItem = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'DefaultResultListItem',
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/DefaultResultListItem').then(
|
||||
m => m.DefaultResultListItem,
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
/**
|
||||
* @public
|
||||
* @deprecated Import from `@backstage/plugin-search-react` instead.
|
||||
*/
|
||||
export const DefaultResultListItem = RealDefaultResultListItem;
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export const HomePageSearchBar = searchPlugin.provide(
|
||||
createComponentExtension({
|
||||
name: 'HomePageSearchBar',
|
||||
|
||||
@@ -256,6 +256,7 @@ const NO_WARNING_PACKAGES = [
|
||||
'plugins/scaffolder-backend-module-rails',
|
||||
'plugins/scaffolder-backend-module-yeoman',
|
||||
'plugins/scaffolder-common',
|
||||
'plugins/search',
|
||||
'plugins/search-backend',
|
||||
'plugins/search-backend-node',
|
||||
'plugins/search-backend-module-elasticsearch',
|
||||
|
||||
Reference in New Issue
Block a user