From b99231c0bce8739a0626888a30b2bd9d54dd9e32 Mon Sep 17 00:00:00 2001 From: Andreas Stenius Date: Fri, 19 Feb 2021 21:45:47 +0100 Subject: [PATCH] badges: move to context menu. support for multiple badges from config. #4457. Signed-off-by: Andreas Stenius --- app-config.yaml | 18 ++++ plugins/badges-backend/config.d.ts | 2 + plugins/badges-backend/src/api/BadgesApi.ts | 2 +- plugins/badges-backend/src/index.ts | 1 + plugins/badges/config.d.ts | 47 ++++++++ plugins/badges/package.json | 6 +- plugins/badges/src/BadgesClientApi.ts | 69 ++++++++++-- .../src/components/EntityBadgesDialog.tsx | 101 ++++++++++++++++++ plugins/badges/src/plugin.ts | 11 ++ plugins/badges/src/routes.ts | 21 ---- .../EntityContextMenu/EntityContextMenu.tsx | 16 ++- .../EntityPageLayout/EntityPageLayout.tsx | 13 ++- 12 files changed, 275 insertions(+), 32 deletions(-) create mode 100644 plugins/badges/config.d.ts create mode 100644 plugins/badges/src/components/EntityBadgesDialog.tsx delete mode 100644 plugins/badges/src/routes.ts diff --git a/app-config.yaml b/app-config.yaml index 8bf6868353..5efebaab91 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -401,8 +401,26 @@ homepage: timezone: 'Asia/Tokyo' pagerduty: eventsBaseUrl: 'https://events.pagerduty.com/v2' + +# sample badges badges: pingback: kind: 'entity' + description: 'Link back to $${app.title}' + target: '$${window.location.href}' label: 'View $${entity.metadata.name} in' message: '$${app.title}' + + lifecycle: + kind: 'entity' + description: 'Entity lifecycle badge' + target: '$${window.location.href}' + label: 'Lifecycle' + message: '$${entity.spec.lifecycle}' + + owner: + kind: 'entity' + description: 'Entity owner badge' + target: '$${window.location.href}' + label: 'Owner' + message: '$${entity.spec.owner}' diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 2fa3296104..298c72be6e 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -40,11 +40,13 @@ export interface Config { * * * `app.title` As configured or defaults to "Backstage". * + * @visibility: frontend */ kind?: 'entity'; /** * The badge label. + * */ label: string; diff --git a/plugins/badges-backend/src/api/BadgesApi.ts b/plugins/badges-backend/src/api/BadgesApi.ts index 5dd68e852d..17613b4284 100644 --- a/plugins/badges-backend/src/api/BadgesApi.ts +++ b/plugins/badges-backend/src/api/BadgesApi.ts @@ -32,7 +32,7 @@ export class BadgesApi { private readonly config: { [id: string]: BadgeConfig }, ) {} - public getBadge(badgeKind: string, badgeId: string, context: any) { + public getBadge(badgeKind: string, badgeId: string, context: object) { const badge = this.config[badgeId] || this.config.default; if (!badge) { diff --git a/plugins/badges-backend/src/index.ts b/plugins/badges-backend/src/index.ts index 8c88c101af..d82b8a9c43 100644 --- a/plugins/badges-backend/src/index.ts +++ b/plugins/badges-backend/src/index.ts @@ -16,3 +16,4 @@ export * from './api'; export * from './service/router'; +export * from './utils'; diff --git a/plugins/badges/config.d.ts b/plugins/badges/config.d.ts new file mode 100644 index 0000000000..371444ab77 --- /dev/null +++ b/plugins/badges/config.d.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 Spotify AB + * + * 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. + */ + +export interface Config { + // defined with doc in badges-backend + badges?: { + [badgeId: string]: { + /** + * Badge title, used as tooltip text in the markdown code. + * + * @visibility frontend + */ + title?: string; + + /** + * Badge description, used as alt text in the markdown code. + * Defaults to badge id. + * + * @visibility frontend + */ + description?: string; + + /** + * Badge target URL, turns badge into a link in the markdown code. + * + * For `entity` badges, there is a `entity_url` in the context + * that could be appropriate to use here. + * + * @visibility frontend + */ + target?: string; + }; + }; +} diff --git a/plugins/badges/package.json b/plugins/badges/package.json index 65e09e2524..39a220a534 100644 --- a/plugins/badges/package.json +++ b/plugins/badges/package.json @@ -45,6 +45,8 @@ "msw": "^0.21.2" }, "files": [ - "dist" - ] + "dist", + "config.d.ts" + ], + "configSchema": "config.d.ts" } diff --git a/plugins/badges/src/BadgesClientApi.ts b/plugins/badges/src/BadgesClientApi.ts index dfb1205784..14f4adebc8 100644 --- a/plugins/badges/src/BadgesClientApi.ts +++ b/plugins/badges/src/BadgesClientApi.ts @@ -19,6 +19,15 @@ import { createApiRef, ConfigApi, DiscoveryApi } from '@backstage/core'; import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; import { entityRoute } from '@backstage/plugin-catalog-react'; +// TODO: place interpolate code someplace re-usable... +// import { interpolate } from '@backstage/plugin-badges-backend'; +function interpolate(template, params) { + const names = Object.keys(params); + const vals = Object.values(params); + // eslint-disable-next-line no-new-func + return new Function(...names, `return \`${template}\`;`)(...vals); +} + export class BadgesClientApi { private readonly configApi: ConfigApi; private readonly discoveryApi: DiscoveryApi; @@ -28,16 +37,64 @@ export class BadgesClientApi { this.discoveryApi = options.discoveryApi; } - async getEntityPoweredByMarkdownCode(entity: Entity): string { - const badge = await this.getEntityPoweredByBadgeURL(entity); - const target = this.getEntityLink(entity); - return `[![Powered by Backstage](${badge})](${target})`; + async getDefaultContext(entity: Entity) { + return { + app: { + title: this.configApi.getString('app.title'), + }, + entity, + entity_url: entity && (await this.getEntityLink(entity)), + }; } - async getEntityPoweredByBadgeURL(entity: Entity): string { + async getDefinedBadges(badgeKind: string, context: object) { + const badges = []; + const badgesConfig = this.configApi.getOptional('badges') ?? {}; + + for (const [badgeId, badge] of Object.entries(badgesConfig)) { + if (!badgeKind || !badge.kind || badgeKind === badge.kind) { + badges.push(await this.renderBadgeInfo(badgeId, badge, context)); + } + } + + return badges; + } + + private async renderBadgeInfo(id, badge, context) { + const info = { + id, + url: context.entity && (await this.getEntityBadgeURL(id, context.entity)), + title: badge.title && this.render(badge.title, context), + description: this.render(badge.description || id, context), + target: badge.target && this.render(badge.target, context), + }; + info.markdown = this.getBadgeMarkdownCode(info); + return info; + } + + private async getEntityBadgeURL(badgeId: string, entity: Entity): string { const routeParams = this.getEntityRouteParams(entity); const path = generatePath(entityRoute.path, routeParams); - return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; + return `${await this.discoveryApi.getBaseUrl( + 'badges', + )}/entity/${path}/${badgeId}`; + } + + private getBadgeMarkdownCode(badge: object): string { + const title = badge.title && ` "${badge.title}"`; + const img = `![${badge.description}](${badge.url}${title})`; + if (!badge.target) { + return img; + } + return `[${img}](${badge.target})`; + } + + private render(template, context) { + try { + return interpolate(template.replace('$$', '$'), context); + } catch (err) { + return `${err} [${template}]`; + } } private getEntityLink(entity: Entity): string { diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx new file mode 100644 index 0000000000..8b32bb8d4e --- /dev/null +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -0,0 +1,101 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { Entity } from '@backstage/catalog-model'; +import { CodeSnippet, Progress, useApi } from '@backstage/core'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, + useMediaQuery, + useTheme, +} from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import Alert from '@material-ui/lab/Alert'; +import React from 'react'; +import { useAsync } from 'react-use'; +import { badgesClientApiRef } from '../BadgesClientApi'; + +type Props = { + open: boolean; + onClose: () => any; + entity: Entity; +}; + +const useStyles = makeStyles(theme => ({ + codeBlock: { + '& code': { + whiteSpace: 'pre-wrap', + }, + }, +})); + +export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { + const theme = useTheme(); + const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const badgesClientApi = useApi(badgesClientApiRef); + const classes = useStyles(); + + const { value: badges, loading, error } = useAsync(async () => { + const context = await badgesClientApi.getDefaultContext(entity); + return await badgesClientApi.getDefinedBadges('entity', context); + }); + + const content = (badges || []).map( + ({ id, title, description, url, target, markdown }) => ( +
+ + {title || description} +
+ {description} +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ ), + ); + + return ( + + Entity Badges + + + {loading ? : null} + + {error ? ( + + {error.toString()} + + ) : null} + + {content} + + + + + + + ); +}; diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index 7acaaeaeef..e4831158bc 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -51,3 +51,14 @@ export const EntityBadgesCard = badgesPlugin.provide( }, }), ); + +export const EntityBadgesDialog = badgesPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/EntityBadgesDialog').then( + m => m.EntityBadgesDialog, + ), + }, + }), +); diff --git a/plugins/badges/src/routes.ts b/plugins/badges/src/routes.ts deleted file mode 100644 index ed6fa151f5..0000000000 --- a/plugins/badges/src/routes.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2021 Spotify AB - * - * 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 { createRouteRef } from '@backstage/core'; - -export const badgeRouteRef = createRouteRef({ - title: 'Powered By Badge', - path: 'badges/:kind/:namespace/:entityname', -}); diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 82cf556281..a8da917fd8 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -35,10 +35,14 @@ const useStyles = makeStyles({ }); type Props = { + onShowBadgesDialog: () => void; onUnregisterEntity: () => void; }; -export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { +export const EntityContextMenu = ({ + onShowBadgesDialog, + onUnregisterEntity, +}: Props) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); @@ -70,6 +74,16 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > + {onShowBadgesDialog && ( + { + onClose(); + onShowBadgesDialog(); + }} + > + Badges + + )} { onClose(); diff --git a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx index 5cd66fca51..663825b409 100644 --- a/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx +++ b/plugins/catalog/src/components/EntityPageLayout/EntityPageLayout.tsx @@ -40,6 +40,7 @@ import { useNavigate } from 'react-router'; import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity'; import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; +import { EntityBadgesDialog } from '@backstage/plugin-badges'; import { Tabbed } from './Tabbed'; const EntityPageTitle = ({ @@ -108,6 +109,7 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { entity!, ); + const [badgesDialogOpen, setBadgesDialogOpen] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); const navigate = useNavigate(); const cleanUpAfterRemoval = async () => { @@ -128,7 +130,9 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { {entity && ( <> - + setBadgesDialogOpen(true)} + onUnregisterEntity={showRemovalDialog} /> )} @@ -159,6 +163,13 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => { )} + {entity && ( + setBadgesDialogOpen(false)} + /> + )}