diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 4e42d9b268..7ff47a3e89 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -17,6 +17,7 @@ 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'; @@ -120,6 +121,7 @@ const app = createApp({ techdocsPlugin, userSettingsPlugin, homePlugin, + ...entityPagePlugins, ...collectedLegacyPlugins, createExtensionOverrides({ extensions: [ diff --git a/packages/app-next/src/examples/entityPages.tsx b/packages/app-next/src/examples/entityPages.tsx new file mode 100644 index 0000000000..3b5b4cceb7 --- /dev/null +++ b/packages/app-next/src/examples/entityPages.tsx @@ -0,0 +1,290 @@ +/* + * 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( + '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 ( + + + {inputs.contents.map(content => ( + + {content.element} + + ))} + + + ); + }; + return ; + }, +}); + +export function createEntityCardExtension< + TConfig, + TInputs extends AnyExtensionInputMap, +>(options: { + id: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + configSchema?: PortableSchema; + loader: (options: { + config: TConfig; + inputs: Expand>; + }) => Promise; +}): Extension { + 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: ( + + + + + + ), + }); + }, + }); +} + +export function createEntityContentExtension< + TConfig extends { path: string; title: string }, + TInputs extends AnyExtensionInputMap, +>( + options: ( + | { + defaultPath: string; + defaultTitle: string; + } + | { + configSchema: PortableSchema; + } + ) & { + id: string; + attachTo?: { id: string; input: string }; + disabled?: boolean; + inputs?: TInputs; + routeRef?: RouteRef; + loader: (options: { + config: TConfig; + inputs: Expand>; + }) => Promise; + }, +): Extension { + 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); + + 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: ( + + + + + + ), + routeRef: options.routeRef, + title: config.title, + }); + }, + }); +} + +const entityAboutCardExtension = createEntityCardExtension({ + id: 'about', + disabled: false, + loader: async () => , + // entityFilter: isDerp, +}); + +const overviewContentExtension = createEntityContentExtension({ + id: 'overview', + defaultPath: '/', + defaultTitle: 'Overview', + disabled: false, + inputs: { + cards: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + loader: async ({ inputs }) => ( + + {inputs.cards.map(card => ( + + {card.element} + + ))} + + ), +}); + +const bonusTechdocsPlugin = createPlugin({ + id: 'techdocs-entity', + extensions: [ + createEntityContentExtension({ + id: 'techdocs', + defaultPath: 'docs', + defaultTitle: 'TechDocs', + disabled: false, + loader: () => + import('@backstage/plugin-techdocs').then(m => ( + + )), + // entityFilter: isPullRequestsAvailable, + }), + ], +}); + +export const entityPagePlugins = [ + createPlugin({ + id: 'entity-pages', + extensions: [ + CatalogEntityPage, + overviewContentExtension, + entityAboutCardExtension, + ], + }), + bonusTechdocsPlugin, + // deploymentsPlugin, +]; diff --git a/plugins/catalog/src/alpha.tsx b/plugins/catalog/src/alpha.tsx index 0753737740..415525ab74 100644 --- a/plugins/catalog/src/alpha.tsx +++ b/plugins/catalog/src/alpha.tsx @@ -231,22 +231,6 @@ const CatalogIndexPage = createPageExtension({ }, }); -const CatalogEntityPage = createPageExtension({ - id: 'plugin.catalog.page.entity', - defaultPath: '/catalog/:namespace/:kind/:name', - routeRef: convertLegacyRouteRef(entityRouteRef), - loader: async () => { - const Component = () => { - return ( - -
🚧 Work In Progress
-
- ); - }; - return ; - }, -}); - const CatalogNavItem = createNavItemExtension({ id: 'catalog.nav.index', routeRef: convertLegacyRouteRef(rootRouteRef), @@ -279,55 +263,7 @@ export default createPlugin({ CatalogEntityProcessingStatusFilter, CatalogEntityNamespaceFilter, CatalogIndexPage, - CatalogEntityPage, + // CatalogEntityPage, CatalogNavItem, ], }); - -/// IMAGINE THIS IS IN A DIFFERENT PLUGIN - -// inside the @backstage/plugin-github-pull-requests plugin -import { - createEntityCardExtension, - createEntityContentExtension, -} from '@backstage/plugin-catalog-react'; - -const githubPullRequestsPlugin = createPlugin({ - id: 'github-pull-requests', - extensions: [ - createEntityCardExtension({ - id: 'github-pull-requests', - loader: () => import('./PullRequestsCard').then(m => m.PullRequestsCard), - entityFilter: isPullRequestsAvailable, - }), - createEntityContentExtension({ - id: 'github-pull-requests', - defaultPath: 'github-pull-requests', - defaultTitle: 'GitHub Pull Requests', - loader: () => - import('./PullRequestsContent').then(m => m.PullRequestsContent), - entityFilter: isPullRequestsAvailable, - }), - ], -}); - -// /deployments -const deploymentsPlugin = createPlugin({ - id: 'github-pull-requests', - extensions: [ - createEntityCardExtension({ - id: 'github-pull-requests', - attachTo: { id: 'plugin.deployments.content', input: 'cards' }, - loader: () => import('./PullRequestsCard').then(m => m.PullRequestsCard), - entityFilter: isPullRequestsAvailable, - }), - createEntityContentExtension({ - id: 'github-pull-requests', - defaultPath: 'github-pull-requests', - defaultTitle: 'GitHub Pull Requests', - loader: () => - import('./PullRequestsContent').then(m => m.PullRequestsContent), - entityFilter: isPullRequestsAvailable, - }), - ], -});