From 54ef743aa4178f97c27c48904e9808d39259a105 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Wed, 29 Dec 2021 12:45:33 -0500 Subject: [PATCH] feat(SearchTypeTabs): implement a tabs variant search type filter Signed-off-by: Phil Kuang --- .changeset/unlucky-kids-decide.md | 5 + plugins/search/api-report.md | 10 ++ .../SearchType/SearchType.Tabs.test.tsx | 112 ++++++++++++++++++ .../components/SearchType/SearchType.Tabs.tsx | 90 ++++++++++++++ .../SearchType/SearchType.stories.tsx | 13 ++ .../src/components/SearchType/SearchType.tsx | 12 +- .../search/src/components/SearchType/index.ts | 6 +- plugins/search/src/index.ts | 1 + 8 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 .changeset/unlucky-kids-decide.md create mode 100644 plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx create mode 100644 plugins/search/src/components/SearchType/SearchType.Tabs.tsx diff --git a/.changeset/unlucky-kids-decide.md b/.changeset/unlucky-kids-decide.md new file mode 100644 index 0000000000..fbc5b37f7c --- /dev/null +++ b/.changeset/unlucky-kids-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Introduce a `` variant to display tabs for selecting search result types. diff --git a/plugins/search/api-report.md b/plugins/search/api-report.md index 0d5b1a36a5..1fa89f7688 100644 --- a/plugins/search/api-report.md +++ b/plugins/search/api-report.md @@ -219,6 +219,7 @@ export const SearchResultPager: () => JSX.Element; export const SearchType: { (props: SearchTypeProps): JSX.Element; Accordion(props: SearchTypeAccordionProps): JSX.Element; + Tabs(props: SearchTypeTabsProps): JSX.Element; }; // @public (undocumented) @@ -240,6 +241,15 @@ export type SearchTypeProps = { defaultValue?: string[] | string | null; }; +// @public (undocumented) +export type SearchTypeTabsProps = { + types: Array<{ + value: string; + name: string; + }>; + 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) diff --git a/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx b/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx new file mode 100644 index 0000000000..54f09023bc --- /dev/null +++ b/plugins/search/src/components/SearchType/SearchType.Tabs.test.tsx @@ -0,0 +1,112 @@ +/* + * 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 React from 'react'; +import { ApiProvider } from '@backstage/core-app-api'; +import { TestApiRegistry } from '@backstage/test-utils'; +import { act, render } from '@testing-library/react'; +import user from '@testing-library/user-event'; + +import { searchApiRef } from '../../apis'; +import { SearchContext, SearchContextProvider } from '../SearchContext'; +import { SearchType } from './SearchType'; + +describe('SearchType.Tabs', () => { + const query = jest.fn(); + const mockApis = TestApiRegistry.from([searchApiRef, { query }]); + + const contextSpy = { + result: { loading: false, value: { results: [] } }, + term: '', + types: [], + filters: {}, + toggleModal: jest.fn(), + setTerm: jest.fn(), + setTypes: jest.fn(), + setFilters: jest.fn(), + setPageCursor: jest.fn(), + }; + + const expectedType = { + value: 'expected-type', + name: 'Expected Type', + }; + + beforeEach(() => { + query.mockResolvedValue({ results: [] }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should render as expected', async () => { + const { getByText } = render( + + + + + , + ); + + // The default "all" type should be rendered. + expect(getByText('All')).toBeInTheDocument(); + + // The given type is also visible + expect(getByText(expectedType.name)).toBeInTheDocument(); + + await act(() => Promise.resolve()); + }); + + it('should set entire types array when a type is selected', () => { + const { getByText } = render( + + + , + ); + + user.click(getByText(expectedType.name)); + + expect(contextSpy.setTypes).toHaveBeenCalledWith([expectedType.value]); + }); + + it('should reset types array when all is selected', () => { + const { getByText } = render( + + + , + ); + + user.click(getByText('All')); + + expect(contextSpy.setTypes).toHaveBeenCalledWith([]); + }); + + it('should reset page cursor when a new type is selected', () => { + const { getByText } = render( + + + , + ); + + user.click(getByText(expectedType.name)); + + expect(contextSpy.setPageCursor).toHaveBeenCalledWith(undefined); + }); +}); diff --git a/plugins/search/src/components/SearchType/SearchType.Tabs.tsx b/plugins/search/src/components/SearchType/SearchType.Tabs.tsx new file mode 100644 index 0000000000..465afd2de4 --- /dev/null +++ b/plugins/search/src/components/SearchType/SearchType.Tabs.tsx @@ -0,0 +1,90 @@ +/* + * 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 React, { useEffect } from 'react'; +import { useSearch } from '../SearchContext'; +import { BackstageTheme } from '@backstage/theme'; +import { makeStyles, Tab, Tabs } from '@material-ui/core'; + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + tabs: { + borderBottom: `1px solid ${theme.palette.textVerySubtle}`, + padding: theme.spacing(0, 4), + }, + tab: { + height: '50px', + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.pxToRem(13), + color: theme.palette.textSubtle, + minWidth: '130px', + }, +})); + +/** + * @public + */ +export type SearchTypeTabsProps = { + types: Array<{ + value: string; + name: string; + }>; + defaultValue?: string; +}; + +export const SearchTypeTabs = (props: SearchTypeTabsProps) => { + const classes = useStyles(); + const { setPageCursor, setTypes, types } = useSearch(); + const { defaultValue, types: givenTypes } = props; + + const changeTab = (_: React.ChangeEvent<{}>, newType: string) => { + setTypes(newType !== '' ? [newType] : []); + setPageCursor(undefined); + }; + + // Handle any provided defaultValue + useEffect(() => { + if (defaultValue) { + setTypes([defaultValue]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const definedTypes = [ + { + value: '', + name: 'All', + }, + ...givenTypes, + ]; + + return ( + + {definedTypes.map(type => ( + + ))} + + ); +}; diff --git a/plugins/search/src/components/SearchType/SearchType.stories.tsx b/plugins/search/src/components/SearchType/SearchType.stories.tsx index 833573f5ee..596c4a027a 100644 --- a/plugins/search/src/components/SearchType/SearchType.stories.tsx +++ b/plugins/search/src/components/SearchType/SearchType.stories.tsx @@ -61,3 +61,16 @@ export const Accordion = () => { /> ); }; + +export const Tabs = () => { + return ( + + ); +}; diff --git a/plugins/search/src/components/SearchType/SearchType.tsx b/plugins/search/src/components/SearchType/SearchType.tsx index f42802819a..e7349a0b77 100644 --- a/plugins/search/src/components/SearchType/SearchType.tsx +++ b/plugins/search/src/components/SearchType/SearchType.tsx @@ -29,6 +29,7 @@ import { SearchTypeAccordion, SearchTypeAccordionProps, } from './SearchType.Accordion'; +import { SearchTypeTabs, SearchTypeTabsProps } from './SearchType.Tabs'; import { useSearch } from '../SearchContext'; const useStyles = makeStyles(theme => ({ @@ -124,5 +125,14 @@ SearchType.Accordion = (props: SearchTypeAccordionProps) => { return ; }; +/** + * A control surface for the search query's "types" property, displayed as a + * tabs suitable for use in faceted search UIs. + * @public + */ +SearchType.Tabs = (props: SearchTypeTabsProps) => { + return ; +}; + export { SearchType }; -export type { SearchTypeAccordionProps }; +export type { SearchTypeAccordionProps, SearchTypeTabsProps }; diff --git a/plugins/search/src/components/SearchType/index.ts b/plugins/search/src/components/SearchType/index.ts index e605276455..37ccc0c781 100644 --- a/plugins/search/src/components/SearchType/index.ts +++ b/plugins/search/src/components/SearchType/index.ts @@ -15,4 +15,8 @@ */ export { SearchType } from './SearchType'; -export type { SearchTypeAccordionProps, SearchTypeProps } from './SearchType'; +export type { + SearchTypeAccordionProps, + SearchTypeTabsProps, + SearchTypeProps, +} from './SearchType'; diff --git a/plugins/search/src/index.ts b/plugins/search/src/index.ts index 2cd9130721..c386330937 100644 --- a/plugins/search/src/index.ts +++ b/plugins/search/src/index.ts @@ -40,6 +40,7 @@ export { SearchResultPager } from './components/SearchResultPager'; export { SearchType } from './components/SearchType'; export type { SearchTypeAccordionProps, + SearchTypeTabsProps, SearchTypeProps, } from './components/SearchType'; export { SidebarSearch } from './components/SidebarSearch';