Merge pull request #20169 from backstage/camilaibs/frontend-di-plugin-search-migration
[Search] Migrate search plugin to frontend DI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
---
|
||||
|
||||
Add support for `SidebarGroup` on the sidebar item extension.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-app-api': patch
|
||||
---
|
||||
|
||||
Register default api implementations when creating declarative integrated apps.
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user