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';