Move SearchBar, SearchBarBase, and SearchTracker from @backstage/plugin-search to @backstage/plugin-search-react and deprecate SearchBar and SearchbarBase in @backstage/plugin-search

Signed-off-by: Anders Näsman <andersn@spotify.com>
This commit is contained in:
Anders Näsman
2022-06-02 10:49:09 +02:00
committed by Eric Peterson
parent 193ae37f15
commit ccbabfd4bd
10 changed files with 613 additions and 15 deletions
+37
View File
@@ -7,6 +7,7 @@
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';
@@ -62,6 +63,35 @@ export type SearchAutocompleteFilterProps = SearchFilterComponentProps & {
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,
@@ -135,6 +165,13 @@ export type SearchResultProps = {
// @public (undocumented)
export const SelectFilter: (props: SearchFilterComponentProps) => JSX.Element;
// @public
export const TrackSearch: ({
children,
}: {
children: React_2.ReactChild;
}) => JSX.Element;
// @public
export const useAsyncFilterValues: (
fn: ((partial: string) => Promise<string[]>) | undefined,
@@ -0,0 +1,96 @@
/*
* 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, { ComponentType } from '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 {
title: 'Plugins/Search/SearchBar',
component: SearchBar,
decorators: [
(Story: ComponentType<{}>) => (
<TestApiProvider apis={[[searchApiRef, new MockSearchApi()]]}>
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
</TestApiProvider>
),
],
};
export const Default = () => {
return (
<Paper style={{ padding: '8px 0' }}>
<SearchBar />
</Paper>
);
};
export const CustomPlaceholder = () => {
return (
<Paper style={{ padding: '8px 0' }}>
<SearchBar placeholder="This is a custom placeholder" />
</Paper>
);
};
export const Focused = () => {
return (
<Paper style={{ padding: '8px 0' }}>
{/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */}
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<SearchBar autoFocus />
</Paper>
);
};
export const WithoutClearButton = () => {
return (
<Paper style={{ padding: '8px 0' }}>
<SearchBar clearButton={false} />
</Paper>
);
};
const useStyles = makeStyles({
search: {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderRadius: '50px',
margin: 'auto',
},
});
export const CustomStyles = () => {
const classes = useStyles();
return (
<Paper className={classes.search}>
<SearchBar />
</Paper>
);
};
@@ -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: 'App',
pluginId: 'root',
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: 'App',
pluginId: 'root',
routeRef: 'unknown',
searchTypes: 'software-catalog,techdocs',
},
subject: 'new value',
});
jest.useRealTimers();
});
});
@@ -0,0 +1,179 @@
/*
* 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 { 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 <SearchBarBase value={term} onChange={handleChange} {...props} />;
};
@@ -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,38 @@
/*
* 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, { useEffect } from 'react';
import { useAnalytics } from '@backstage/core-plugin-api';
import { useSearch } from '../../context';
/**
* Capture search event on term change.
*
* @public
*/
export const TrackSearch = ({ children }: { children: React.ReactChild }) => {
const analytics = useAnalytics();
const { term } = useSearch();
useEffect(() => {
if (term) {
// Capture analytics search event with search term provided as value
analytics.captureEvent('search', term);
}
}, [analytics, term]);
return <>{children}</>;
};
@@ -0,0 +1,16 @@
/*
* 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 { TrackSearch } from './SearchTracker';
@@ -18,3 +18,5 @@ export * from './HighlightedSearchResultText';
export * from './SearchFilter';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchBar';
export * from './SearchTracker';