diff --git a/plugins/search/src/alpha.tsx b/plugins/search/src/alpha.tsx
index 89ff6b7664..669656338d 100644
--- a/plugins/search/src/alpha.tsx
+++ b/plugins/search/src/alpha.tsx
@@ -14,16 +14,55 @@
* limitations under the License.
*/
+import React from 'react';
+
+import { Grid, makeStyles, Paper, Theme } from '@material-ui/core';
+
import {
+ CatalogIcon,
+ Content,
+ DocsIcon,
+ Header,
+ Page,
+ useSidebarPinState,
+} from '@backstage/core-components';
+import {
+ createRouteRef,
+ useApi,
DiscoveryApi,
IdentityApi,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
-import { createApiExtension } from '@backstage/frontend-plugin-api';
+import {
+ createApiExtension,
+ createPageExtension,
+ createExtensionInput,
+ createSchemaFromZod,
+} from '@backstage/frontend-plugin-api';
+import {
+ catalogApiRef,
+ CATALOG_FILTER_EXISTS,
+} from '@backstage/plugin-catalog-react';
+
+import { SearchType } from '@backstage/plugin-search';
+import {
+ DefaultResultListItem,
+ SearchBar,
+ SearchFilter,
+ SearchPagination,
+ SearchResult as SearchResults,
+ SearchResultPager,
+ useSearch,
+ SearchContextProvider,
+} from '@backstage/plugin-search-react';
+import { SearchResult } from '@backstage/plugin-search-common';
import { searchApiRef } from '@backstage/plugin-search-react';
+import { searchResultItemExtensionData } from '@backstage/plugin-search-react/alpha';
+
+import { UrlUpdater } from './components/SearchPage/SearchPage';
import { SearchClient } from './apis';
@@ -41,3 +80,150 @@ export const SearchApi = createApiExtension({
}) => new SearchClient({ discoveryApi, identityApi }),
},
});
+
+const useStyles = makeStyles((theme: Theme) => ({
+ filter: {
+ '& + &': {
+ marginTop: theme.spacing(2.5),
+ },
+ },
+ filters: {
+ padding: theme.spacing(2),
+ marginTop: theme.spacing(2),
+ },
+}));
+
+const searchRouteRef = createRouteRef({ id: 'plugin.search.page' });
+
+/** @alpha */
+export const SearchPage = createPageExtension({
+ id: 'plugin.search.page',
+ routeRef: searchRouteRef,
+ configSchema: createSchemaFromZod(z =>
+ z.object({
+ path: z.string().default('/search'),
+ noTrack: z.boolean().default(false),
+ }),
+ ),
+ inputs: {
+ items: createExtensionInput({
+ item: searchResultItemExtensionData,
+ }),
+ },
+ loader: async ({ config, inputs }) => {
+ const getResultItemComponent = (result: SearchResult) => {
+ const value = inputs.items.find(({ item }) => item?.predicate?.(result));
+ return value?.item.component ?? DefaultResultListItem;
+ };
+
+ const Component = () => {
+ const classes = useStyles();
+ const { isMobile } = useSidebarPinState();
+ const { types } = useSearch();
+ const catalogApi = useApi(catalogApiRef);
+
+ return (
+
+ {!isMobile && }
+
+
+
+
+
+ {!isMobile && (
+
+ ,
+ },
+ {
+ value: 'techdocs',
+ name: 'Documentation',
+ icon: ,
+ },
+ {
+ value: 'adr',
+ name: 'Architecture Decision Records',
+ icon: ,
+ },
+ ]}
+ />
+
+ {types.includes('techdocs') && (
+ {
+ // Return a list of entities which are documented.
+ const { items } = await catalogApi.getEntities({
+ fields: ['metadata.name'],
+ filter: {
+ 'metadata.annotations.backstage.io/techdocs-ref':
+ CATALOG_FILTER_EXISTS,
+ },
+ });
+
+ const names = items.map(
+ entity => entity.metadata.name,
+ );
+ names.sort();
+ return names;
+ }}
+ />
+ )}
+
+
+
+
+ )}
+
+
+
+ {({ results }) =>
+ results.map((result, index) => {
+ const SearchResultListItem =
+ getResultItemComponent(result);
+ return (
+
+ );
+ })
+ }
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+
+ );
+ },
+});
diff --git a/plugins/search/src/components/SearchPage/UrlUpdater.tsx b/plugins/search/src/components/SearchPage/UrlUpdater.tsx
new file mode 100644
index 0000000000..d44195b6e2
--- /dev/null
+++ b/plugins/search/src/components/SearchPage/UrlUpdater.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2023 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 { useEffect } from 'react';
+import usePrevious from 'react-use/lib/usePrevious';
+import qs from 'qs';
+import { useLocation } from 'react-router-dom';
+import { useSearch } from '@backstage/plugin-search-react';
+import { JsonObject } from '@backstage/types';
+
+export const UrlUpdater = () => {
+ const location = useLocation();
+ const {
+ term,
+ setTerm,
+ types,
+ setTypes,
+ pageCursor,
+ setPageCursor,
+ filters,
+ setFilters,
+ } = useSearch();
+
+ const prevQueryParams = usePrevious(location.search);
+
+ useEffect(() => {
+ // Only respond to changes to url query params
+ if (location.search === prevQueryParams) {
+ return;
+ }
+
+ const query =
+ qs.parse(location.search.substring(1), { arrayLimit: 0 }) || {};
+
+ if (query.filters) {
+ setFilters(query.filters as JsonObject);
+ }
+
+ if (query.query) {
+ setTerm(query.query as string);
+ }
+
+ if (query.pageCursor) {
+ setPageCursor(query.pageCursor as string);
+ }
+
+ if (query.types) {
+ setTypes(query.types as string[]);
+ }
+ }, [prevQueryParams, location, setTerm, setTypes, setPageCursor, setFilters]);
+
+ useEffect(() => {
+ const newParams = qs.stringify(
+ {
+ query: term,
+ types,
+ pageCursor,
+ filters,
+ },
+ { arrayFormat: 'brackets' },
+ );
+ const newUrl = `${window.location.pathname}?${newParams}`;
+
+ // We directly manipulate window history here in order to not re-render
+ // infinitely (state => location => state => etc). The intention of this
+ // code is just to ensure the right query/filters are loaded when a user
+ // clicks the "back" button after clicking a result.
+ window.history.replaceState(null, document.title, newUrl);
+ }, [term, types, pageCursor, filters]);
+
+ return null;
+};