diff --git a/app-config.yaml b/app-config.yaml index d4aa0694c8..9896b2feec 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -407,14 +407,14 @@ badges: pingback: kind: 'entity' description: 'Link back to _{app.title}' - target: '_{entity_url}' + link: '_{entity_url}' label: 'View _{entity.metadata.name} in' message: '_{app.title}' lifecycle: kind: 'entity' description: 'Entity lifecycle badge' - target: '_{entity_url}' + link: '_{entity_url}' label: 'Lifecycle' message: '_{entity.spec.lifecycle}' @@ -422,14 +422,14 @@ badges: kind: 'entity' title: 'Resource Owner' description: 'Entity owner badge' - target: '_{entity_url}' + link: '_{entity_url}' label: 'Owner' message: '_{entity.spec.owner}' color: 'blue' docs: kind: 'entity' - target: '_{entity_url}/docs' + link: '_{entity_url}/docs' label: 'Documentation' message: '_{entity.metadata.name}' color: 'navyblue' diff --git a/plugins/badges-backend/config.d.ts b/plugins/badges-backend/config.d.ts index 6ec56daf23..b13f46e1fa 100644 --- a/plugins/badges-backend/config.d.ts +++ b/plugins/badges-backend/config.d.ts @@ -72,6 +72,31 @@ export interface Config { * 'for-the-badge' or 'social'. */ style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + + /** + * 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 link 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 + */ + link?: string; }; }; } diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts index 38a06ed0cd..9b136dcf22 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/DefaultBadgeBuilder.ts @@ -54,6 +54,15 @@ export class DefaultBadgeBuilder implements BadgeBuilder { switch (options.format) { case 'json': + if (badge.title) { + params.title = this.render(badge.title, context); + } + if (badge.description) { + params.description = this.render(badge.description, context); + } + if (badge.link) { + params.link = this.render(badge.link, context); + } return JSON.stringify( { badge: params, diff --git a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts index f58e35ec1d..ae898d645d 100644 --- a/plugins/badges-backend/src/lib/BadgeBuilder/types.ts +++ b/plugins/badges-backend/src/lib/BadgeBuilder/types.ts @@ -21,7 +21,9 @@ export interface BadgeConfig { color?: string; labelColor?: string; style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; - links?: [string, string]; + title?: string; + description?: string; + link?: string; } export type BadgeOptions = { diff --git a/plugins/badges/config.d.ts b/plugins/badges/config.d.ts index 371444ab77..000eaa0b9b 100644 --- a/plugins/badges/config.d.ts +++ b/plugins/badges/config.d.ts @@ -19,29 +19,9 @@ export interface Config { 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; + kind?: 'entity'; }; }; } diff --git a/plugins/badges/dev/index.tsx b/plugins/badges/dev/index.tsx index 8a289a51dc..1f545a777e 100644 --- a/plugins/badges/dev/index.tsx +++ b/plugins/badges/dev/index.tsx @@ -15,14 +15,6 @@ */ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { badgesPlugin, EntityBadgesCard } from '../src/plugin'; -import { badgesDevPlugin, BadgesDevPage } from './plugin'; +import { badgesPlugin } from '../src/plugin'; -createDevApp() - .registerPlugin(badgesPlugin) - .registerPlugin(badgesDevPlugin) - .addPage({ - element: , - title: 'Dev Page', - }) - .render(); +createDevApp().registerPlugin(badgesPlugin).render(); diff --git a/plugins/badges/dev/page.tsx b/plugins/badges/dev/page.tsx deleted file mode 100644 index b6ff91538a..0000000000 --- a/plugins/badges/dev/page.tsx +++ /dev/null @@ -1,38 +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 React from 'react'; -import { Typography, Grid } from '@material-ui/core'; -import { Content, Header, Page, ContentHeader } from '@backstage/core'; -import { EntityBadgesCard } from '../src'; - -export default () => { - return ( - -
- - - - - - - - - - ); -}; diff --git a/plugins/badges/src/BadgesClientApi.ts b/plugins/badges/src/BadgesClientApi.ts deleted file mode 100644 index 79749e4933..0000000000 --- a/plugins/badges/src/BadgesClientApi.ts +++ /dev/null @@ -1,119 +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 { generatePath } from 'react-router'; -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; - - constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { - this.configApi = options.configApi; - this.discoveryApi = options.discoveryApi; - } - - async getDefaultContext(entity: Entity) { - return { - app: { - title: this.configApi.getString('app.title'), - }, - entity, - entity_url: entity && (await this.getEntityLink(entity)), - }; - } - - 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}/${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 { - const routeParams = this.getEntityRouteParams(entity); - const path = generatePath(entityRoute.path, routeParams); - return `${this.configApi.getString('app.baseUrl')}/catalog/${path}`; - } - - private getEntityRouteParams(entity: Entity) { - return { - kind: entity.kind.toLowerCase(), - namespace: - entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, - name: entity.metadata.name, - }; - } -} - -export const badgesClientApiRef = createApiRef({ - id: 'plugin.badges.client', - description: 'Used by the badges plugin to create badge URLs', -}); diff --git a/plugins/badges/src/api/BadgesClient.ts b/plugins/badges/src/api/BadgesClient.ts new file mode 100644 index 0000000000..9c0387b58e --- /dev/null +++ b/plugins/badges/src/api/BadgesClient.ts @@ -0,0 +1,86 @@ +/* + * 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 { generatePath } from 'react-router'; +import { ConfigApi, DiscoveryApi } from '@backstage/core'; +import { Entity, ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; +import { entityRoute } from '@backstage/plugin-catalog-react'; +import { BadgesApi, BadgeConfig, BadgeSpec } from './types'; + +export class BadgesClient implements BadgesApi { + private readonly configApi: ConfigApi; + private readonly discoveryApi: DiscoveryApi; + + constructor(options: { configApi: ConfigApi; discoveryApi: DiscoveryApi }) { + this.configApi = options.configApi; + this.discoveryApi = options.discoveryApi; + } + + public async getDefinedEntityBadges(entity: Entity): Badge[] { + const badges = []; + const badgesConfig = this.configApi.getOptional('badges') ?? {}; + const entityBadgeUri = await this.getEntityBadgeUri(entity); + + for (const [badgeId, badge] of Object.entries(badgesConfig)) { + if (!badge.kind || badge.kind === 'entity') { + const info = await this.getBadgeInfo(entityBadgeUri, badgeId); + if (info) { + badges.push(info); + } + } + } + + return badges; + } + + private async getBadgeInfo(entityBadgeUri: string, badgeId: string): Badge { + const badgeUrl = `${entityBadgeUri}/${badgeId}`; + const spec = (await ( + await fetch(`${badgeUrl}?format=json`) + ).json()) as BadgeSpec; + + return { + id: badgeId, + markdown: this.getBadgeMarkdownCode(badgeUrl, spec.badge), + spec, + url: badgeUrl, + }; + } + + private async getEntityBadgeUri(entity: Entity): string { + const routeParams = this.getEntityRouteParams(entity); + const path = generatePath(entityRoute.path, routeParams); + return `${await this.discoveryApi.getBaseUrl('badges')}/entity/${path}`; + } + + private getEntityRouteParams(entity: Entity) { + return { + kind: entity.kind.toLowerCase(), + namespace: + entity.metadata.namespace?.toLowerCase() ?? ENTITY_DEFAULT_NAMESPACE, + name: entity.metadata.name, + }; + } + + private getBadgeMarkdownCode(badgeUrl: string, badge: BadgeConfig): string { + const title = badge.title ? ` "${badge.title}"` : ''; + const img = `![${badge.description || 'badge'}](${badgeUrl}${title})`; + if (!badge.link) { + return img; + } + return `[${img}](${badge.link})`; + } +} diff --git a/plugins/badges/dev/plugin.ts b/plugins/badges/src/api/index.ts similarity index 54% rename from plugins/badges/dev/plugin.ts rename to plugins/badges/src/api/index.ts index 7f1574f999..1c6a381916 100644 --- a/plugins/badges/dev/plugin.ts +++ b/plugins/badges/src/api/index.ts @@ -13,27 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - createPlugin, - createRoutableExtension, - createRouteRef, -} from '@backstage/core'; -import BadgesDevPageComponent from './page'; -const devRouteRef = createRouteRef({ - title: 'Badges (Dev Page)', -}); - -export const badgesDevPlugin = createPlugin({ - id: 'badges-dev', - routes: { - root: devRouteRef, - }, -}); - -export const BadgesDevPage = badgesDevPlugin.provide( - createRoutableExtension({ - component: () => import('./page').then(m => m.default), - mountPoint: devRouteRef, - }), -); +export * from './BadgesClient'; +export * from './types'; diff --git a/plugins/badges/src/api/types.ts b/plugins/badges/src/api/types.ts new file mode 100644 index 0000000000..5b03f43a58 --- /dev/null +++ b/plugins/badges/src/api/types.ts @@ -0,0 +1,56 @@ +/* + * 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 { createApiRef } from '@backstage/core'; + +export const badgesApiRef = createApiRef({ + id: 'plugin.badges.client', + description: 'Used to make requests to the badges backend', +}); + +// should probably have this in a "badges-common" package +interface BadgeConfig { + kind?: 'entity'; + label: string; + message: string; + color?: string; + labelColor?: string; + style?: 'plastic' | 'flat' | 'flat-square' | 'for-the-badge' | 'social'; + title?: string; + description?: string; + link?: string; +} + +export interface BadgeSpec { + /** The rendered data */ + badge: BadgeConfig; + /** The configuration data, with placeholders and all */ + config: BadgeConfig; + /** The context used when rendering config -> badge */ + context: object; +} + +export interface Badge { + id: string; + markdown: string; + spec: BadgeSpec; + url: string; +} + +export interface BadgesApi { + getDefinedEntityBadges(entity: Entity): Promise; +} diff --git a/plugins/badges/src/components/BadgesDrawer.tsx b/plugins/badges/src/components/BadgesDrawer.tsx deleted file mode 100644 index 10ae0f861f..0000000000 --- a/plugins/badges/src/components/BadgesDrawer.tsx +++ /dev/null @@ -1,63 +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 React, { useState } from 'react'; -import { Button, Drawer, Grid, Typography } from '@material-ui/core'; -import { CodeSnippet, Content, InfoCard, useApi } from '@backstage/core'; - -const BadgeCard = ({ title, badgeUrl, markdownCode }) => ( - - - - Paste the following snippet in your README.md or other - markdown file for this badge: - - Badge preview - - - -); - -const BadgesDrawerContent = ({ badges, toggleDrawer }) => ( - - - {badges.map((badge, idx) => ( - - ))} - - - - - -); - -export const BadgesDrawer = ({ badges }) => { - const [isOpen, toggleDrawer] = useState(false); - - return ( - <> - - toggleDrawer(false)}> - - - - ); -}; diff --git a/plugins/badges/src/components/EntityBadgesCard.tsx b/plugins/badges/src/components/EntityBadgesCard.tsx deleted file mode 100644 index 5fc0f47556..0000000000 --- a/plugins/badges/src/components/EntityBadgesCard.tsx +++ /dev/null @@ -1,44 +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 React from 'react'; -import { useAsync } from 'react-use'; -import { Typography } from '@material-ui/core'; -import { CodeSnippet, InfoCard, useApi } from '@backstage/core'; -import { ENTITY_DEFAULT_NAMESPACE } from '@backstage/catalog-model'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { badgesClientApiRef } from '../BadgesClientApi'; - -export const EntityBadgesCard = () => { - const badgesClientApi = useApi(badgesClientApiRef); - const { entity } = useEntity(); - const { value, loading, error } = useAsync(async () => { - return { - badge: await badgesClientApi.getEntityPoweredByBadgeURL(entity), - markdown: await badgesClientApi.getEntityPoweredByMarkdownCode(entity), - }; - }, [badgesClientApi, entity]); - - return ( - - - Paste the following snippet in your README.md or other - markdown file, to get a powered by backstage badge: - - {value && Powered by Backstage badge} - {value && } - - ); -}; diff --git a/plugins/badges/src/components/EntityBadgesDialog.tsx b/plugins/badges/src/components/EntityBadgesDialog.tsx index 8b32bb8d4e..ec9bd35221 100644 --- a/plugins/badges/src/components/EntityBadgesDialog.tsx +++ b/plugins/badges/src/components/EntityBadgesDialog.tsx @@ -31,7 +31,7 @@ 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'; +import { badgesApiRef } from '../api'; type Props = { open: boolean; @@ -50,30 +50,31 @@ const useStyles = makeStyles(theme => ({ export const EntityBadgesDialog = ({ open, onClose, entity }: Props) => { const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); - const badgesClientApi = useApi(badgesClientApiRef); + const badgesApi = useApi(badgesApiRef); const classes = useStyles(); const { value: badges, loading, error } = useAsync(async () => { - const context = await badgesClientApi.getDefaultContext(entity); - return await badgesClientApi.getDefinedBadges('entity', context); - }); + if (open) { + return await badgesApi.getDefinedEntityBadges(entity); + } - const content = (badges || []).map( - ({ id, title, description, url, target, markdown }) => ( -
- - {title || description} -
- {description} -
- - Copy the following snippet of markdown code for the badge: - - -
-
- ), - ); + return []; + }, [badgesApi, entity, open]); + + const content = (badges || []).map(({ id, markdown, spec, url }) => ( +
+ + {spec.badge.title || spec.badge.description || id} +
+ {spec.badge.description} +
+ + Copy the following snippet of markdown code for the badge: + + +
+
+ )); return ( diff --git a/plugins/badges/src/components/EntityBadgesField.tsx b/plugins/badges/src/components/EntityBadgesField.tsx deleted file mode 100644 index 586f9d366d..0000000000 --- a/plugins/badges/src/components/EntityBadgesField.tsx +++ /dev/null @@ -1,52 +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 React from 'react'; -import { useAsync } from 'react-use'; -import { Progress, useApi, WarningPanel } from '@backstage/core'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { badgesClientApiRef } from '../BadgesClientApi'; -import { BadgesDrawer } from './BadgesDrawer'; - -export const EntityBadgesField = () => { - const badgesClientApi = useApi(badgesClientApiRef); - const { entity } = useEntity(); - const { value, loading, error } = useAsync(async () => { - return { - badge: await badgesClientApi.getEntityPoweredByBadgeURL(entity), - markdown: await badgesClientApi.getEntityPoweredByMarkdownCode(entity), - }; - }, [badgesClientApi, entity]); - - const badges = []; - - if (value) { - badges.push({ - title: 'Powered by Backstage', - badgeUrl: value.badge, - markdownCode: value.markdown, - }); - } - - return ( -
- {loading && } - {error && ( - - )} - {badges.length > 0 && } -
- ); -}; diff --git a/plugins/badges/src/plugin.ts b/plugins/badges/src/plugin.ts index e4831158bc..8a617171de 100644 --- a/plugins/badges/src/plugin.ts +++ b/plugins/badges/src/plugin.ts @@ -20,38 +20,20 @@ import { configApiRef, discoveryApiRef, } from '@backstage/core'; -import { badgesClientApiRef, BadgesClientApi } from './BadgesClientApi'; +import { badgesApiRef, BadgesClient } from './api'; export const badgesPlugin = createPlugin({ id: 'badges', apis: [ createApiFactory({ - api: badgesClientApiRef, + api: badgesApiRef, deps: { configApi: configApiRef, discoveryApi: discoveryApiRef }, factory: ({ configApi, discoveryApi }) => - new BadgesClientApi({ configApi, discoveryApi }), + new BadgesClient({ configApi, discoveryApi }), }), ], }); -export const EntityBadgesField = badgesPlugin.provide( - createComponentExtension({ - component: { - lazy: () => - import('./components/EntityBadgesField').then(m => m.EntityBadgesField), - }, - }), -); - -export const EntityBadgesCard = badgesPlugin.provide( - createComponentExtension({ - component: { - lazy: () => - import('./components/EntityBadgesCard').then(m => m.EntityBadgesCard), - }, - }), -); - export const EntityBadgesDialog = badgesPlugin.provide( createComponentExtension({ component: {