catalog,catalog-react,techdocs: extract experimental entity page implementation from app-next

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-10-19 17:04:30 +02:00
committed by Camila Belo
parent c82fc7e672
commit 0bf6ebda88
15 changed files with 326 additions and 329 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Initial entity page implementation for new frontend system at `/alpha`, with an overview page enabled by default and the about card available as an optional card.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Added entity page content for the new plugin exported via `/alpha`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Added new APIs at the `/alpha` subpath for creating entity page cards and content for the new frontend system.
+7 -2
View File
@@ -4,12 +4,17 @@ app:
routes:
bindings:
plugin.pages.externalRoutes.pageX: plugin.pages.routes.pageX
# waiting for https://github.com/backstage/backstage/pull/20605
# catalog.externalRoutes.viewTechDoc: techdocs.routes.docRoot
plugin.catalog.externalRoutes.viewTechDoc: plugin.techdocs.routes.docRoot
extensions:
- apis.plugin.graphiql.browse.gitlab: true
# Entity page cards
- 'entity.cards.about'
# Entity page content
- 'entity.content.techdocs'
# scmAuthExtension: >-
# createScmAuthExtension({
# id: 'apis.scmAuth.addons.ghe',
+1 -18
View File
@@ -17,7 +17,6 @@
import React from 'react';
import { createApp } from '@backstage/frontend-app-api';
import { pagesPlugin } from './examples/pagesPlugin';
import { entityPagePlugins } from './examples/entityPages';
import graphiqlPlugin from '@backstage/plugin-graphiql/alpha';
import techRadarPlugin from '@backstage/plugin-tech-radar/alpha';
import userSettingsPlugin from '@backstage/plugin-user-settings/alpha';
@@ -30,11 +29,8 @@ import {
createExtension,
createApiExtension,
createExtensionOverrides,
createPageExtension,
} from '@backstage/frontend-plugin-api';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import { homePage } from './HomePage';
import { collectLegacyRoutes } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -76,13 +72,6 @@ TODO:
/* app.tsx */
const entityPageExtension = createPageExtension({
id: 'catalog:entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => <div>Just a temporary mocked entity page</div>,
});
const homePageExtension = createExtension({
id: 'myhomepage',
attachTo: { id: 'home', input: 'props' },
@@ -121,15 +110,9 @@ const app = createApp({
techdocsPlugin,
userSettingsPlugin,
homePlugin,
...entityPagePlugins,
...collectedLegacyPlugins,
createExtensionOverrides({
extensions: [
entityPageExtension,
homePageExtension,
scmAuthExtension,
scmIntegrationApi,
],
extensions: [homePageExtension, scmAuthExtension, scmIntegrationApi],
}),
],
/* Handled through config instead */
@@ -1,290 +0,0 @@
/*
* 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, { useEffect } from 'react';
import { convertLegacyRouteRef } from '@backstage/core-plugin-api/alpha';
import {
AnyExtensionInputMap,
Extension,
ExtensionBoundary,
ExtensionInputValues,
PortableSchema,
RouteRef,
coreExtensionData,
createExtension,
createExtensionDataRef,
createExtensionInput,
createPageExtension,
createPlugin,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
import {
AsyncEntityProvider,
EntityLoadingStatus,
catalogApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { Expand } from '../../../frontend-plugin-api/src/types';
import { EntityAboutCard, EntityLayout } from '@backstage/plugin-catalog';
import {
useApi,
errorApiRef,
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { useNavigate } from 'react-router';
import useAsyncRetry from 'react-use/lib/useAsyncRetry';
import Grid from '@material-ui/core/Grid';
export const useEntityFromUrl = (): EntityLoadingStatus => {
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const navigate = useNavigate();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const {
value: entity,
error,
loading,
retry: refresh,
} = useAsyncRetry(
() => catalogApi.getEntityByRef({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (!name) {
errorApi.post(new Error('No name provided!'));
navigate('/');
}
}, [errorApi, navigate, error, loading, entity, name]);
return { entity, loading, error, refresh };
};
export const titleExtensionDataRef = createExtensionDataRef<string>(
'plugin.catalog.entity.content.title',
);
const CatalogEntityPage = createPageExtension({
id: 'plugin.catalog.page.entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
inputs: {
contents: createExtensionInput({
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: titleExtensionDataRef,
}),
},
loader: async ({ inputs }) => {
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<EntityLayout>
{inputs.contents.map(content => (
<EntityLayout.Route
key={content.path}
path={content.path}
title={content.title}
>
{content.element}
</EntityLayout.Route>
))}
</EntityLayout>
</AsyncEntityProvider>
);
};
return <Component />;
},
});
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig> {
return createExtension({
id: `entity.content.${options.id}`,
attachTo: options.attachTo ?? {
id: 'entity.content.overview',
input: 'cards',
},
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
},
inputs: options.inputs,
configSchema: options.configSchema,
factory({ bind, config, inputs, source }) {
const LazyComponent = React.lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
element: (
<ExtensionBoundary source={source}>
<React.Suspense fallback="...">
<LazyComponent />
</React.Suspense>
</ExtensionBoundary>
),
});
},
});
}
export function createEntityContentExtension<
TConfig extends { path: string; title: string },
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({
path: z.string().default(options.defaultPath),
title: z.string().default(options.defaultTitle),
}),
) as PortableSchema<TConfig>);
return createExtension({
id: `entity.content.${options.id}`,
attachTo: options.attachTo ?? {
id: 'plugin.catalog.page.entity',
input: 'contents',
},
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: titleExtensionDataRef,
},
inputs: options.inputs,
configSchema,
factory({ bind, config, inputs, source }) {
const LazyComponent = React.lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
path: config.path,
element: (
<ExtensionBoundary source={source}>
<React.Suspense fallback="...">
<LazyComponent />
</React.Suspense>
</ExtensionBoundary>
),
routeRef: options.routeRef,
title: config.title,
});
},
});
}
const entityAboutCardExtension = createEntityCardExtension({
id: 'about',
disabled: false,
loader: async () => <EntityAboutCard variant="gridItem" />,
// entityFilter: isDerp,
});
const overviewContentExtension = createEntityContentExtension({
id: 'overview',
defaultPath: '/',
defaultTitle: 'Overview',
disabled: false,
inputs: {
cards: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async ({ inputs }) => (
<Grid container spacing={3} alignItems="stretch">
{inputs.cards.map(card => (
<Grid item md={6} xs={12}>
{card.element}
</Grid>
))}
</Grid>
),
});
const bonusTechdocsPlugin = createPlugin({
id: 'techdocs-entity',
extensions: [
createEntityContentExtension({
id: 'techdocs',
defaultPath: 'docs',
defaultTitle: 'TechDocs',
disabled: false,
loader: () =>
import('@backstage/plugin-techdocs').then(m => (
<m.EmbeddedDocsRouter />
)),
// entityFilter: isPullRequestsAvailable,
}),
],
});
export const entityPagePlugins = [
createPlugin({
id: 'entity-pages',
extensions: [
CatalogEntityPage,
overviewContentExtension,
entityAboutCardExtension,
],
}),
bonusTechdocsPlugin,
// deploymentsPlugin,
];
+65
View File
@@ -3,8 +3,73 @@
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { AnyExtensionInputMap } from '@backstage/frontend-plugin-api';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { Extension } from '@backstage/frontend-plugin-api';
import { ExtensionInputValues } from '@backstage/frontend-plugin-api';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig>;
// @alpha (undocumented)
export function createEntityContentExtension<
TConfig extends {
path: string;
title: string;
},
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
attachTo?: {
id: string;
input: string;
};
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig>;
// @alpha (undocumented)
export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef<
string,
{}
>;
// @alpha
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
+3 -2
View File
@@ -10,13 +10,13 @@
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.ts",
"./alpha": "./src/alpha.tsx",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha.ts"
"src/alpha.tsx"
],
"package.json": [
"package.json"
@@ -51,6 +51,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
+158
View File
@@ -0,0 +1,158 @@
/*
* 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 {
AnyExtensionInputMap,
Extension,
ExtensionBoundary,
ExtensionInputValues,
PortableSchema,
RouteRef,
coreExtensionData,
createExtension,
createExtensionDataRef,
createSchemaFromZod,
} from '@backstage/frontend-plugin-api';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { Expand } from '../../../packages/frontend-plugin-api/src/types';
export { isOwnerOf } from './utils';
export { useEntityPermission } from './hooks/useEntityPermission';
/** @alpha */
export const entityContentTitleExtensionDataRef =
createExtensionDataRef<string>('plugin.catalog.entity.content.title');
/** @alpha */
export function createEntityCardExtension<
TConfig,
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
configSchema?: PortableSchema<TConfig>;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
}): Extension<TConfig> {
const id = `entity.cards.${options.id}`;
return createExtension({
id,
attachTo: options.attachTo ?? {
id: 'entity.content.overview',
input: 'cards',
},
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
},
inputs: options.inputs,
configSchema: options.configSchema,
factory({ bind, config, inputs, source }) {
const ExtensionComponent = React.lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
element: (
<ExtensionBoundary id={id} source={source}>
<ExtensionComponent />
</ExtensionBoundary>
),
});
},
});
}
/** @alpha */
export function createEntityContentExtension<
TConfig extends { path: string; title: string },
TInputs extends AnyExtensionInputMap,
>(
options: (
| {
defaultPath: string;
defaultTitle: string;
}
| {
configSchema: PortableSchema<TConfig>;
}
) & {
id: string;
attachTo?: { id: string; input: string };
disabled?: boolean;
inputs?: TInputs;
routeRef?: RouteRef;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<JSX.Element>;
},
): Extension<TConfig> {
const id = `entity.content.${options.id}`;
const configSchema =
'configSchema' in options
? options.configSchema
: (createSchemaFromZod(z =>
z.object({
path: z.string().default(options.defaultPath),
title: z.string().default(options.defaultTitle),
}),
) as PortableSchema<TConfig>);
return createExtension({
id,
attachTo: options.attachTo ?? {
id: 'plugin.catalog.page.entity',
input: 'contents',
},
disabled: options.disabled ?? true,
output: {
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: entityContentTitleExtensionDataRef,
},
inputs: options.inputs,
configSchema,
factory({ bind, config, inputs, source }) {
const LazyComponent = React.lazy(() =>
options
.loader({ config, inputs })
.then(element => ({ default: () => element })),
);
bind({
path: config.path,
element: (
<ExtensionBoundary id={id} source={source}>
<LazyComponent />
</ExtensionBoundary>
),
routeRef: options.routeRef,
title: config.title,
});
},
});
}
-11
View File
@@ -12,14 +12,6 @@ import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { PortableSchema } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @alpha (undocumented)
export const CatalogApi: Extension<{}>;
// @alpha (undocumented)
export const CatalogSearchResultListItemExtension: Extension<{
noTrack?: boolean | undefined;
}>;
// @alpha (undocumented)
export function createCatalogFilterExtension<
TInputs extends AnyExtensionInputMap,
@@ -62,8 +54,5 @@ const _default: BackstagePlugin<
>;
export default _default;
// @alpha (undocumented)
export const StarredEntitiesApi: Extension<{}>;
// (No @packageDocumentation comment for this package)
```
+2 -2
View File
@@ -10,13 +10,13 @@
},
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha/index.ts",
"./alpha": "./src/alpha.ts",
"./package.json": "./package.json"
},
"typesVersions": {
"*": {
"alpha": [
"src/alpha/index.ts"
"src/alpha.ts"
],
"package.json": [
"package.json"
@@ -14,5 +14,5 @@
* limitations under the License.
*/
export { isOwnerOf } from './utils';
export { useEntityPermission } from './hooks/useEntityPermission';
export * from './alpha/index';
export { default } from './alpha/index';
+58 -2
View File
@@ -38,6 +38,11 @@ import {
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import {
createEntityContentExtension,
createEntityCardExtension,
entityContentTitleExtensionDataRef,
} from '@backstage/plugin-catalog-react/alpha';
import { createSearchResultListItemExtension } from '@backstage/plugin-search-react/alpha';
import { DefaultStarredEntitiesApi } from '../apis';
import {
@@ -48,6 +53,7 @@ import {
} from '../routes';
import { builtInFilterExtensions } from './builtInFilterExtensions';
import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl';
import Grid from '@material-ui/core/Grid';
/** @alpha */
export const CatalogApi = createApiExtension({
@@ -102,11 +108,30 @@ const CatalogEntityPage = createPageExtension({
id: 'plugin.catalog.page.entity',
defaultPath: '/catalog/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(entityRouteRef),
loader: async () => {
inputs: {
contents: createExtensionInput({
element: coreExtensionData.reactElement,
path: coreExtensionData.routePath,
routeRef: coreExtensionData.routeRef.optional(),
title: entityContentTitleExtensionDataRef,
}),
},
loader: async ({ inputs }) => {
const { EntityLayout } = await import('../components/EntityLayout');
const Component = () => {
return (
<AsyncEntityProvider {...useEntityFromUrl()}>
<div>🚧 Work In Progress</div>
<EntityLayout>
{inputs.contents.map(content => (
<EntityLayout.Route
key={content.path}
path={content.path}
title={content.title}
>
{content.element}
</EntityLayout.Route>
))}
</EntityLayout>
</AsyncEntityProvider>
);
};
@@ -114,6 +139,35 @@ const CatalogEntityPage = createPageExtension({
},
});
const EntityAboutCard = createEntityCardExtension({
id: 'about',
loader: async () =>
import('../components/AboutCard').then(m => (
<m.AboutCard variant="gridItem" />
)),
});
const OverviewEntityContent = createEntityContentExtension({
id: 'overview',
defaultPath: '/',
defaultTitle: 'Overview',
disabled: false,
inputs: {
cards: createExtensionInput({
element: coreExtensionData.reactElement,
}),
},
loader: async ({ inputs }) => (
<Grid container spacing={3} alignItems="stretch">
{inputs.cards.map(card => (
<Grid item md={6} xs={12}>
{card.element}
</Grid>
))}
</Grid>
),
});
const CatalogNavItem = createNavItemExtension({
id: 'catalog.nav.index',
routeRef: convertLegacyRouteRef(rootRouteRef),
@@ -140,6 +194,8 @@ export default createPlugin({
CatalogIndexPage,
CatalogEntityPage,
CatalogNavItem,
OverviewEntityContent,
EntityAboutCard,
...builtInFilterExtensions,
],
});
+14
View File
@@ -42,6 +42,7 @@ import {
rootDocsRouteRef,
rootRouteRef,
} from './routes';
import { createEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
/** @alpha */
const techDocsStorage = createApiExtension({
@@ -141,6 +142,18 @@ const TechDocsReaderPage = createPageExtension({
)),
});
/**
* Component responsible for rendering techdocs on entity pages
*
* @alpha
*/
const TechDocsEntityContent = createEntityContentExtension({
id: 'techdocs',
defaultPath: 'docs',
defaultTitle: 'TechDocs',
loader: () => import('./Router').then(m => <m.EmbeddedDocsRouter />),
});
/** @alpha */
const TechDocsNavItem = createNavItemExtension({
id: 'plugin.techdocs.nav.index',
@@ -158,6 +171,7 @@ export default createPlugin({
TechDocsNavItem,
TechDocsIndexPage,
TechDocsReaderPage,
TechDocsEntityContent,
TechDocsSearchResultListItemExtension,
],
routes: {
+1
View File
@@ -5837,6 +5837,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/integration": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"