Merge pull request #20169 from backstage/camilaibs/frontend-di-plugin-search-migration

[Search] Migrate search plugin to frontend DI
This commit is contained in:
Patrik Oldsberg
2023-09-28 17:47:09 +02:00
committed by GitHub
15 changed files with 498 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Add support for `SidebarGroup` on the sidebar item extension.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Create an experimental search plugin that is compatible with the declarative integration system, it is exported from `/alpha` subpath.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Register default api implementations when creating declarative integrated apps.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Migrate catalog api to declarative integration system, it is exported from `/alpha` subpath.
@@ -48,6 +48,9 @@ const IndexPage = createPageExtension({
<div>
<Link to="/graphiql">GraphiQL</Link>
</div>
<div>
<Link to="/search">Search</Link>
</div>
</div>
);
};
@@ -19,6 +19,8 @@ import {
createExtension,
coreExtensionData,
createExtensionInput,
useRouteRef,
NavTarget,
} from '@backstage/frontend-plugin-api';
import { makeStyles } from '@material-ui/core';
import {
@@ -29,7 +31,6 @@ import {
SidebarDivider,
SidebarItem,
} from '@backstage/core-components';
import { GraphiQLIcon } from '@backstage/plugin-graphiql';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import LogoIcon from '../../../app/src/components/Root/LogoIcon';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
@@ -63,25 +64,33 @@ const SidebarLogo = () => {
);
};
const SidebarNavItem = (props: NavTarget) => {
const { icon: Icon, title, routeRef } = props;
const to = useRouteRef(routeRef)();
// TODO: Support opening modal, for example, the search one
return <SidebarItem to={to} icon={Icon} text={title} />;
};
export const CoreNav = createExtension({
id: 'core.nav',
at: 'core.layout/nav',
inputs: {
items: createExtensionInput({
path: coreExtensionData.navTarget,
target: coreExtensionData.navTarget,
}),
},
output: {
element: coreExtensionData.reactElement,
},
factory({ bind }) {
factory({ bind, inputs }) {
bind({
// TODO: set base path using the logic from AppRouter
element: (
<Sidebar>
<SidebarLogo />
<SidebarDivider />
<SidebarItem icon={GraphiQLIcon} to="graphiql" text="GraphiQL" />
{inputs.items.map((item, index) => (
<SidebarNavItem {...item.target} key={index} />
))}
</Sidebar>
),
});
@@ -49,6 +49,7 @@ import {
featureFlagsApiRef,
attachComponentData,
useRouteRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { getAvailablePlugins } from './discovery';
import {
@@ -62,6 +63,8 @@ import {
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppThemeProvider } from '../../../core-app-api/src/app/AppThemeProvider';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppContextProvider } from '../../../core-app-api/src/app/AppContext';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { LocalStorageFeatureFlags } from '../../../core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags';
@@ -366,13 +369,13 @@ function createApiHolder(
): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
const apiFactories =
const pluginApis =
coreExtension.attachments
.get('apis')
?.map(e => e.getData(coreExtensionData.apiFactory))
.filter((x): x is AnyApiFactory => !!x) ?? [];
for (const factory of apiFactories) {
for (const factory of [...defaultApis, ...pluginApis]) {
factoryRegistry.register('default', factory);
}
@@ -383,6 +386,38 @@ function createApiHolder(
factory: () => new LocalStorageFeatureFlags(),
});
factoryRegistry.register('static', {
api: identityApiRef,
deps: {},
factory: () => {
const appIdentityProxy = new AppIdentityProxy();
// TODO: Remove this when sign-in page is migrated
appIdentityProxy.setTarget(
{
getUserId: () => 'guest',
getIdToken: async () => undefined,
getProfile: () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
},
{ signOutTargetUrl: '/' },
);
return appIdentityProxy;
},
});
factoryRegistry.register('static', {
api: appThemeApiRef,
deps: {},
+9
View File
@@ -3,8 +3,13 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
import { TranslationRef } from '@backstage/core-plugin-api/alpha';
// @alpha (undocumented)
export const CatalogApi: Extension<{}>;
// @alpha (undocumented)
export const catalogTranslationRef: TranslationRef<
'catalog',
@@ -14,5 +19,9 @@ export const catalogTranslationRef: TranslationRef<
}
>;
// @alpha (undocumented)
const _default: BackstagePlugin;
export default _default;
// (No @packageDocumentation comment for this package)
```
+1
View File
@@ -51,6 +51,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
+37
View File
@@ -15,3 +15,40 @@
*/
export * from './translation';
import {
DiscoveryApi,
FetchApi,
discoveryApiRef,
fetchApiRef,
} from '@backstage/core-plugin-api';
import { CatalogClient } from '@backstage/catalog-client';
import {
createApiExtension,
createPlugin,
} from '@backstage/frontend-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
/** @alpha */
export const CatalogApi = createApiExtension({
factory: {
api: catalogApiRef,
deps: {
discoveryApi: discoveryApiRef,
fetchApi: fetchApiRef,
},
factory: ({
discoveryApi,
fetchApi,
}: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
}) => new CatalogClient({ discoveryApi, fetchApi }),
},
});
/** @alpha */
export default createPlugin({
id: 'catalog',
extensions: [CatalogApi],
});
+28
View File
@@ -0,0 +1,28 @@
## API Report File for "@backstage/plugin-search"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackstagePlugin } from '@backstage/frontend-plugin-api';
import { Extension } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
const _default: BackstagePlugin;
export default _default;
// @alpha (undocumented)
export const SearchApi: Extension<{}>;
// @alpha (undocumented)
export const SearchNavItem: Extension<{
title: string;
}>;
// @alpha (undocumented)
export const SearchPage: Extension<{
path: string;
noTrack: boolean;
}>;
// (No @packageDocumentation comment for this package)
```
+17 -3
View File
@@ -6,9 +6,22 @@
"types": "src/index.ts",
"license": "Apache-2.0",
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
"access": "public"
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.tsx"
],
"package.json": [
"package.json"
]
}
},
"backstage": {
"role": "frontend-plugin"
@@ -38,6 +51,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-search-common": "workspace:^",
"@backstage/plugin-search-react": "workspace:^",
+245
View File
@@ -0,0 +1,245 @@
/*
* 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 React from 'react';
import { makeStyles, Theme, Grid, Paper } from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import {
CatalogIcon,
Content,
DocsIcon,
Header,
Page,
useSidebarPinState,
} from '@backstage/core-components';
import {
createRouteRef,
useApi,
DiscoveryApi,
IdentityApi,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import {
createPlugin,
createApiExtension,
createPageExtension,
createExtensionInput,
createNavItemExtension,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import {
catalogApiRef,
CATALOG_FILTER_EXISTS,
} from '@backstage/plugin-catalog-react';
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 { SearchClient } from './apis';
import { SearchType } from './components/SearchType';
import { UrlUpdater } from './components/SearchPage/SearchPage';
/** @alpha */
export const SearchApi = createApiExtension({
factory: {
api: searchApiRef,
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({
identityApi,
discoveryApi,
}: {
identityApi: IdentityApi;
discoveryApi: DiscoveryApi;
}) => new SearchClient({ discoveryApi, identityApi }),
},
});
const useSearchPageStyles = 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 = useSearchPageStyles();
const { isMobile } = useSidebarPinState();
const { types } = useSearch();
const catalogApi = useApi(catalogApiRef);
return (
<Page themeId="home">
{!isMobile && <Header title="Search" />}
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar debounceTime={100} />
</Grid>
{!isMobile && (
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
showCounts
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
{
value: 'adr',
name: 'Architecture Decision Records',
icon: <DocsIcon />,
},
]}
/>
<Paper className={classes.filters}>
{types.includes('techdocs') && (
<SearchFilter.Select
className={classes.filter}
label="Entity"
name="name"
values={async () => {
// 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;
}}
/>
)}
<SearchFilter.Select
className={classes.filter}
label="Kind"
name="kind"
values={['Component', 'Template']}
/>
<SearchFilter.Checkbox
className={classes.filter}
label="Lifecycle"
name="lifecycle"
values={['experimental', 'production']}
/>
</Paper>
</Grid>
)}
<Grid item xs>
<SearchPagination />
<SearchResults>
{({ results }) =>
results.map((result, index) => {
const SearchResultListItem =
getResultItemComponent(result);
return (
<SearchResultListItem
key={index}
rank={result.rank}
result={result.document}
noTrack={config.noTrack}
/>
);
})
}
</SearchResults>
<SearchResultPager />
</Grid>
</Grid>
</Content>
</Page>
);
};
return (
<SearchContextProvider>
<UrlUpdater />
<Component />
</SearchContextProvider>
);
},
});
/** @alpha */
export const SearchNavItem = createNavItemExtension({
id: 'plugin.search.nav.index',
routeRef: searchRouteRef,
title: 'Search',
icon: SearchIcon,
});
/** @alpha */
export default createPlugin({
id: 'plugin.search',
extensions: [SearchApi, SearchPage, SearchNavItem],
});
@@ -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;
};
+2
View File
@@ -6023,6 +6023,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
@@ -9117,6 +9118,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-catalog-react": "workspace:^"
"@backstage/plugin-search-common": "workspace:^"
"@backstage/plugin-search-react": "workspace:^"