badges: move to context menu. support for multiple badges from config. #4457.

Signed-off-by: Andreas Stenius <git@astekk.se>
This commit is contained in:
Andreas Stenius
2021-02-19 21:45:47 +01:00
committed by Fredrik Adelöw
parent 798002ee03
commit b99231c0bc
12 changed files with 275 additions and 32 deletions
+18
View File
@@ -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}'
+2
View File
@@ -40,11 +40,13 @@ export interface Config {
*
* * `app.title` As configured or defaults to "Backstage".
*
* @visibility: frontend
*/
kind?: 'entity';
/**
* The badge label.
*
*/
label: string;
+1 -1
View File
@@ -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) {
+1
View File
@@ -16,3 +16,4 @@
export * from './api';
export * from './service/router';
export * from './utils';
+47
View File
@@ -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;
};
};
}
+4 -2
View File
@@ -45,6 +45,8 @@
"msw": "^0.21.2"
},
"files": [
"dist"
]
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts"
}
+63 -6
View File
@@ -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 {
@@ -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 }) => (
<div key={id}>
<DialogContentText>
{title || description}
<br />
<img alt={description} src={url} />
</DialogContentText>
<Typography component="div" className={classes.codeBlock}>
Copy the following snippet of markdown code for the badge:
<CodeSnippet text={markdown} showCopyCodeButton />
</Typography>
<hr />
</div>
),
);
return (
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
<DialogTitle id="badges-dialog-title">Entity Badges</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
{error ? (
<Alert severity="error" style={{ wordBreak: 'break-word' }}>
{error.toString()}
</Alert>
) : null}
{content}
</DialogContent>
<DialogActions>
<Button onClick={onClose} color="primary">
Close
</Button>
</DialogActions>
</Dialog>
);
};
+11
View File
@@ -51,3 +51,14 @@ export const EntityBadgesCard = badgesPlugin.provide(
},
}),
);
export const EntityBadgesDialog = badgesPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/EntityBadgesDialog').then(
m => m.EntityBadgesDialog,
),
},
}),
);
-21
View File
@@ -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',
});
@@ -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<HTMLButtonElement>();
const classes = useStyles();
@@ -70,6 +74,16 @@ export const EntityContextMenu = ({ onUnregisterEntity }: Props) => {
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuList>
{onShowBadgesDialog && (
<MenuItem
onClick={() => {
onClose();
onShowBadgesDialog();
}}
>
<Typography variant="inherit">Badges</Typography>
</MenuItem>
)}
<MenuItem
onClick={() => {
onClose();
@@ -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 && (
<>
<EntityLabels entity={entity} />
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
<EntityContextMenu
onShowBadgesDialog={() => setBadgesDialogOpen(true)}
onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
@@ -159,6 +163,13 @@ export const EntityPageLayout = ({ children }: PropsWithChildren<{}>) => {
</Content>
)}
{entity && (
<EntityBadgesDialog
open={badgesDialogOpen}
entity={entity}
onClose={() => setBadgesDialogOpen(false)}
/>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}