diff --git a/.changeset/mighty-crews-attack.md b/.changeset/mighty-crews-attack.md new file mode 100644 index 0000000000..c0b8587fe0 --- /dev/null +++ b/.changeset/mighty-crews-attack.md @@ -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. diff --git a/.changeset/nice-apes-kneel.md b/.changeset/nice-apes-kneel.md new file mode 100644 index 0000000000..1ecff9d223 --- /dev/null +++ b/.changeset/nice-apes-kneel.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +Added entity page content for the new plugin exported via `/alpha`. diff --git a/.changeset/old-apricots-taste.md b/.changeset/old-apricots-taste.md new file mode 100644 index 0000000000..544ba11536 --- /dev/null +++ b/.changeset/old-apricots-taste.md @@ -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. diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index 6f6081fece..0c75e1c96c 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -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', diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 7ff47a3e89..1275722da4 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -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 () =>
Just a temporary mocked entity page
, -}); - 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 */ diff --git a/packages/app-next/src/examples/entityPages.tsx b/packages/app-next/src/examples/entityPages.tsx deleted file mode 100644 index 3b5b4cceb7..0000000000 --- a/packages/app-next/src/examples/entityPages.tsx +++ /dev/null @@ -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( - '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-react/alpha-api-report.md b/plugins/catalog-react/alpha-api-report.md index f9b740c07c..120e162c84 100644 --- a/plugins/catalog-react/alpha-api-report.md +++ b/plugins/catalog-react/alpha-api-report.md @@ -3,8 +3,73 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + +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; + loader: (options: { + config: TConfig; + inputs: Expand>; + }) => Promise; +}): Extension; + +// @alpha (undocumented) +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; + +// @alpha (undocumented) +export const entityContentTitleExtensionDataRef: ConfigurableExtensionDataRef< + string, + {} +>; // @alpha export function isOwnerOf(owner: Entity, entity: Entity): boolean; diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index bb18160595..70fa5e9614 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -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:^", diff --git a/plugins/catalog-react/src/alpha.tsx b/plugins/catalog-react/src/alpha.tsx new file mode 100644 index 0000000000..d2eeb8f5f3 --- /dev/null +++ b/plugins/catalog-react/src/alpha.tsx @@ -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('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; + loader: (options: { + config: TConfig; + inputs: Expand>; + }) => Promise; +}): Extension { + 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: ( + + + + ), + }); + }, + }); +} + +/** @alpha */ +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 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); + + 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: ( + + + + ), + routeRef: options.routeRef, + title: config.title, + }); + }, + }); +} diff --git a/plugins/catalog/alpha-api-report.md b/plugins/catalog/alpha-api-report.md index aba33fc092..43e589bdc3 100644 --- a/plugins/catalog/alpha-api-report.md +++ b/plugins/catalog/alpha-api-report.md @@ -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) ``` diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 214795e73c..9b879e3c50 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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" diff --git a/plugins/catalog-react/src/alpha.ts b/plugins/catalog/src/alpha.ts similarity index 85% rename from plugins/catalog-react/src/alpha.ts rename to plugins/catalog/src/alpha.ts index e8ff21609e..e80f131817 100644 --- a/plugins/catalog-react/src/alpha.ts +++ b/plugins/catalog/src/alpha.ts @@ -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'; diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx index 1716b85a81..d9d2878213 100644 --- a/plugins/catalog/src/alpha/plugin.tsx +++ b/plugins/catalog/src/alpha/plugin.tsx @@ -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 ( -
🚧 Work In Progress
+ + {inputs.contents.map(content => ( + + {content.element} + + ))} +
); }; @@ -114,6 +139,35 @@ const CatalogEntityPage = createPageExtension({ }, }); +const EntityAboutCard = createEntityCardExtension({ + id: 'about', + loader: async () => + import('../components/AboutCard').then(m => ( + + )), +}); + +const OverviewEntityContent = createEntityContentExtension({ + id: 'overview', + defaultPath: '/', + defaultTitle: 'Overview', + disabled: false, + inputs: { + cards: createExtensionInput({ + element: coreExtensionData.reactElement, + }), + }, + loader: async ({ inputs }) => ( + + {inputs.cards.map(card => ( + + {card.element} + + ))} + + ), +}); + const CatalogNavItem = createNavItemExtension({ id: 'catalog.nav.index', routeRef: convertLegacyRouteRef(rootRouteRef), @@ -140,6 +194,8 @@ export default createPlugin({ CatalogIndexPage, CatalogEntityPage, CatalogNavItem, + OverviewEntityContent, + EntityAboutCard, ...builtInFilterExtensions, ], }); diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha.tsx index b7ccb0b12c..ceb5f47364 100644 --- a/plugins/techdocs/src/alpha.tsx +++ b/plugins/techdocs/src/alpha.tsx @@ -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 => ), +}); + /** @alpha */ const TechDocsNavItem = createNavItemExtension({ id: 'plugin.techdocs.nav.index', @@ -158,6 +171,7 @@ export default createPlugin({ TechDocsNavItem, TechDocsIndexPage, TechDocsReaderPage, + TechDocsEntityContent, TechDocsSearchResultListItemExtension, ], routes: { diff --git a/yarn.lock b/yarn.lock index 059c18d859..3075745d85 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"