Merge pull request #9375 from backstage/freben/inspect
catalog: add an entity inspection dialog
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
---
|
||||
'@backstage/catalog-client': minor
|
||||
---
|
||||
|
||||
Fixed the return type of the catalog API `getEntityAncestors`, to match the
|
||||
actual server response shape.
|
||||
|
||||
While this technically is a breaking change, the old shape has never worked at
|
||||
all if you tried to use it - so treating this as an immediately-shipped breaking
|
||||
bug fix.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
'@backstage/plugin-catalog-react': patch
|
||||
---
|
||||
|
||||
Added an "inspect" entry in the entity three-dots menu, for lower level catalog
|
||||
insights and debugging.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Deprecated the `EntityPageLayout`; please use the new extension based `CatalogEntityPage` instead
|
||||
@@ -133,10 +133,10 @@ export type CatalogEntityAncestorsRequest = {
|
||||
|
||||
// @public
|
||||
export type CatalogEntityAncestorsResponse = {
|
||||
root: EntityName;
|
||||
rootEntityRef: string;
|
||||
items: {
|
||||
entity: Entity;
|
||||
parents: EntityName[];
|
||||
parentEntityRefs: string[];
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -121,8 +121,8 @@ export type CatalogEntityAncestorsRequest = {
|
||||
* @public
|
||||
*/
|
||||
export type CatalogEntityAncestorsResponse = {
|
||||
root: EntityName;
|
||||
items: { entity: Entity; parents: EntityName[] }[];
|
||||
rootEntityRef: string;
|
||||
items: { entity: Entity; parentEntityRefs: string[] }[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -480,6 +480,13 @@ export function getEntitySourceLocation(
|
||||
scmIntegrationsApi: ScmIntegrationRegistry,
|
||||
): EntitySourceLocation | undefined;
|
||||
|
||||
// @public
|
||||
export function InspectEntityDialog(props: {
|
||||
open: boolean;
|
||||
entity: Entity;
|
||||
onClose: () => void;
|
||||
}): JSX.Element | null;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "isOwnerOf" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public
|
||||
|
||||
@@ -42,11 +42,13 @@
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"classnames": "^2.2.6",
|
||||
"jwt-decode": "^3.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.9.4",
|
||||
"react-router": "6.0.0-beta.0",
|
||||
"react-use": "^17.2.4",
|
||||
"yaml": "^1.10.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
makeStyles,
|
||||
Tab,
|
||||
Tabs,
|
||||
} from '@material-ui/core';
|
||||
import React, { useEffect } from 'react';
|
||||
import { AncestryPage } from './components/AncestryPage';
|
||||
import { ColocatedPage } from './components/ColocatedPage';
|
||||
import { JsonPage } from './components/JsonPage';
|
||||
import { OverviewPage } from './components/OverviewPage';
|
||||
import { YamlPage } from './components/YamlPage';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
fullHeightDialog: {
|
||||
height: 'calc(100% - 64px)',
|
||||
},
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
tabs: {
|
||||
borderRight: `1px solid ${theme.palette.divider}`,
|
||||
flexShrink: 0,
|
||||
},
|
||||
tabContents: {
|
||||
flexGrow: 1,
|
||||
overflowX: 'auto',
|
||||
},
|
||||
}));
|
||||
|
||||
function TabPanel(props: {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}) {
|
||||
const { children, value, index, ...other } = props;
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`vertical-tabpanel-${index}`}
|
||||
aria-labelledby={`vertical-tab-${index}`}
|
||||
className={classes.tabContents}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box pl={3} pr={3}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function a11yProps(index: number) {
|
||||
return {
|
||||
id: `vertical-tab-${index}`,
|
||||
'aria-controls': `vertical-tabpanel-${index}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A dialog that lets users inspect the low level details of their entities.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function InspectEntityDialog(props: {
|
||||
open: boolean;
|
||||
entity: Entity;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
const [activeTab, setActiveTab] = React.useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(0);
|
||||
}, [props.open]);
|
||||
|
||||
if (!props.entity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
fullWidth
|
||||
maxWidth="xl"
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
aria-labelledby="entity-inspector-dialog-title"
|
||||
PaperProps={{ className: classes.fullHeightDialog }}
|
||||
>
|
||||
<DialogTitle id="entity-inspector-dialog-title">
|
||||
Entity Inspector
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<div className={classes.root}>
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="scrollable"
|
||||
value={activeTab}
|
||||
onChange={(_, newValue) => setActiveTab(newValue)}
|
||||
aria-label="Inspector options"
|
||||
className={classes.tabs}
|
||||
>
|
||||
<Tab label="Overview" {...a11yProps(0)} />
|
||||
<Tab label="Ancestry" {...a11yProps(1)} />
|
||||
<Tab label="Colocated" {...a11yProps(2)} />
|
||||
<Tab label="Raw JSON" {...a11yProps(3)} />
|
||||
<Tab label="Raw YAML" {...a11yProps(4)} />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<OverviewPage entity={props.entity} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<AncestryPage entity={props.entity} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<ColocatedPage entity={props.entity} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<JsonPage entity={props.entity} />
|
||||
</TabPanel>
|
||||
<TabPanel value={activeTab} index={4}>
|
||||
<YamlPage entity={props.entity} />
|
||||
</TabPanel>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={props.onClose} color="primary">
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
Entity,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import {
|
||||
DependencyGraph,
|
||||
DependencyGraphTypes,
|
||||
Link,
|
||||
Progress,
|
||||
ResponseErrorPanel,
|
||||
} from '@backstage/core-components';
|
||||
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
|
||||
import { Box, DialogContentText, makeStyles } from '@material-ui/core';
|
||||
import classNames from 'classnames';
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../../api';
|
||||
import { formatEntityRefTitle } from '../../../components/EntityRefLink/format';
|
||||
import { entityRouteRef } from '../../../routes';
|
||||
import { EntityKindIcon } from './EntityKindIcon';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
node: {
|
||||
fill: theme.palette.grey[300],
|
||||
stroke: theme.palette.grey[300],
|
||||
'&.primary': {
|
||||
fill: theme.palette.primary.light,
|
||||
stroke: theme.palette.primary.light,
|
||||
},
|
||||
'&.secondary': {
|
||||
fill: theme.palette.secondary.light,
|
||||
stroke: theme.palette.secondary.light,
|
||||
},
|
||||
},
|
||||
text: {
|
||||
fill: theme.palette.getContrastText(theme.palette.grey[300]),
|
||||
'&.primary': {
|
||||
fill: theme.palette.primary.contrastText,
|
||||
},
|
||||
'&.secondary': {
|
||||
fill: theme.palette.secondary.contrastText,
|
||||
},
|
||||
'&.focused': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
},
|
||||
clickable: {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
}));
|
||||
|
||||
type NodeType = Entity & { root: boolean };
|
||||
|
||||
function useAncestry(root: Entity): {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
nodes: DependencyGraphTypes.DependencyNode<NodeType>[];
|
||||
edges: DependencyGraphTypes.DependencyEdge[];
|
||||
} {
|
||||
const catalogClient = useApi(catalogApiRef);
|
||||
const entityRef = stringifyEntityRef(root);
|
||||
|
||||
const { loading, error, value } = useAsync(async () => {
|
||||
const response = await catalogClient.getEntityAncestors({ entityRef });
|
||||
const nodes = new Array<DependencyGraphTypes.DependencyNode<NodeType>>();
|
||||
const edges = new Array<DependencyGraphTypes.DependencyEdge>();
|
||||
for (const current of response.items) {
|
||||
const currentRef = stringifyEntityRef(current.entity);
|
||||
const isRootNode = currentRef === response.rootEntityRef;
|
||||
nodes.push({ id: currentRef, root: isRootNode, ...current.entity });
|
||||
for (const parentRef of current.parentEntityRefs) {
|
||||
edges.push({ from: currentRef, to: parentRef });
|
||||
}
|
||||
}
|
||||
return { nodes, edges };
|
||||
}, [entityRef]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
nodes: value?.nodes || [],
|
||||
edges: value?.edges || [],
|
||||
};
|
||||
}
|
||||
|
||||
function CustomNode({ node }: DependencyGraphTypes.RenderNodeProps<NodeType>) {
|
||||
const classes = useStyles();
|
||||
const navigate = useNavigate();
|
||||
const entityRoute = useRouteRef(entityRouteRef);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [height, setHeight] = useState(0);
|
||||
const idRef = useRef<SVGTextElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// set the width to the length of the ID
|
||||
if (idRef.current) {
|
||||
let { height: renderedHeight, width: renderedWidth } =
|
||||
idRef.current.getBBox();
|
||||
renderedHeight = Math.round(renderedHeight);
|
||||
renderedWidth = Math.round(renderedWidth);
|
||||
if (renderedHeight !== height || renderedWidth !== width) {
|
||||
setWidth(renderedWidth);
|
||||
setHeight(renderedHeight);
|
||||
}
|
||||
}
|
||||
}, [width, height]);
|
||||
|
||||
const padding = 10;
|
||||
const iconSize = height;
|
||||
const paddedIconWidth = iconSize + padding;
|
||||
const paddedWidth = paddedIconWidth + width + padding * 2;
|
||||
const paddedHeight = height + padding * 2;
|
||||
|
||||
const displayTitle =
|
||||
node.metadata.title ||
|
||||
(node.kind && node.metadata.name && node.metadata.namespace
|
||||
? formatEntityRefTitle({
|
||||
kind: node.kind,
|
||||
name: node.metadata.name,
|
||||
namespace: node.metadata.namespace || '',
|
||||
})
|
||||
: node.id);
|
||||
|
||||
const onClick = () => {
|
||||
navigate(
|
||||
entityRoute({
|
||||
kind: node.kind,
|
||||
namespace: node.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
|
||||
name: node.metadata.name,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<g onClick={onClick} className={classes.clickable}>
|
||||
<rect
|
||||
className={classNames(
|
||||
classes.node,
|
||||
node.root ? 'secondary' : 'primary',
|
||||
)}
|
||||
width={paddedWidth}
|
||||
height={paddedHeight}
|
||||
rx={10}
|
||||
/>
|
||||
<EntityKindIcon
|
||||
kind={node.kind}
|
||||
y={padding}
|
||||
x={padding}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className={classNames(
|
||||
classes.text,
|
||||
node.root ? 'secondary' : 'primary',
|
||||
)}
|
||||
/>
|
||||
<text
|
||||
ref={idRef}
|
||||
className={classNames(
|
||||
classes.text,
|
||||
node.root ? 'secondary' : 'primary',
|
||||
)}
|
||||
y={paddedHeight / 2}
|
||||
x={paddedIconWidth + (width + padding * 2) / 2}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="middle"
|
||||
>
|
||||
{displayTitle}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export function AncestryPage(props: { entity: Entity }) {
|
||||
const { loading, error, nodes, edges } = useAncestry(props.entity);
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContentText variant="h2">Ancestry</DialogContentText>
|
||||
<DialogContentText gutterBottom>
|
||||
This is the ancestry of entities above the current one - as in, the
|
||||
chain(s) of entities down to the current one, where{' '}
|
||||
<Link to="https://backstage.io/docs/features/software-catalog/life-of-an-entity">
|
||||
processors emitted
|
||||
</Link>{' '}
|
||||
child entities that ultimately led to the current one existing. Note
|
||||
that this is a completely different mechanism from relations.
|
||||
</DialogContentText>
|
||||
<Box mt={4}>
|
||||
<DependencyGraph
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
renderNode={CustomNode}
|
||||
direction={DependencyGraphTypes.Direction.BOTTOM_TOP}
|
||||
zoom="enable-on-click"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2022 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 {
|
||||
Entity,
|
||||
LOCATION_ANNOTATION,
|
||||
ORIGIN_LOCATION_ANNOTATION,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
DialogContentText,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React from 'react';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../../api';
|
||||
import { EntityRefLink } from '../../EntityRefLink';
|
||||
import { KeyValueListItem, ListItemText } from './common';
|
||||
import { EntityKindIcon } from './EntityKindIcon';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
});
|
||||
|
||||
function useColocated(entity: Entity): {
|
||||
loading: boolean;
|
||||
error?: Error;
|
||||
location?: string;
|
||||
originLocation?: string;
|
||||
colocatedEntities?: Entity[];
|
||||
} {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const currentEntityRef = stringifyEntityRef(entity);
|
||||
const location = entity.metadata.annotations?.[LOCATION_ANNOTATION];
|
||||
const origin = entity.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION];
|
||||
|
||||
const { loading, error, value } = useAsync(async () => {
|
||||
if (!location && !origin) {
|
||||
return [];
|
||||
}
|
||||
const response = await catalogApi.getEntities({
|
||||
filter: [
|
||||
...(location
|
||||
? [{ [`metadata.annotations.${LOCATION_ANNOTATION}`]: location }]
|
||||
: []),
|
||||
...(origin
|
||||
? [{ [`metadata.annotations.${ORIGIN_LOCATION_ANNOTATION}`]: origin }]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
return response.items;
|
||||
}, [location, origin]);
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
location,
|
||||
originLocation: origin,
|
||||
colocatedEntities: value?.filter(
|
||||
colocated => stringifyEntityRef(colocated) !== currentEntityRef,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function EntityList(props: { entities: Entity[]; header?: [string, string] }) {
|
||||
return (
|
||||
<List dense>
|
||||
{props.header && <KeyValueListItem key="header" entry={props.header} />}
|
||||
{props.entities.map(entity => (
|
||||
<ListItem key={stringifyEntityRef(entity)}>
|
||||
<ListItemIcon>
|
||||
<EntityKindIcon kind={entity.kind} />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={<EntityRefLink entityRef={entity} />} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
function Contents(props: { entity: Entity }) {
|
||||
const { entity } = props;
|
||||
|
||||
const { loading, error, location, originLocation, colocatedEntities } =
|
||||
useColocated(entity);
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
} else if (error) {
|
||||
return <ResponseErrorPanel error={error} />;
|
||||
}
|
||||
|
||||
if (!location && !originLocation) {
|
||||
return (
|
||||
<Alert severity="warning">Entity had no location information.</Alert>
|
||||
);
|
||||
} else if (!colocatedEntities?.length) {
|
||||
return (
|
||||
<Alert severity="info">
|
||||
There were no other entities on this location.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (location === originLocation) {
|
||||
return <EntityList entities={colocatedEntities} />;
|
||||
}
|
||||
|
||||
const atLocation = colocatedEntities.filter(
|
||||
e => e.metadata.annotations?.[LOCATION_ANNOTATION] === location,
|
||||
);
|
||||
const atOrigin = colocatedEntities.filter(
|
||||
e =>
|
||||
e.metadata.annotations?.[ORIGIN_LOCATION_ANNOTATION] === originLocation,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{atLocation.length > 0 && (
|
||||
<EntityList
|
||||
entities={atLocation}
|
||||
header={['At the same location', location!]}
|
||||
/>
|
||||
)}
|
||||
{atOrigin.length > 0 && (
|
||||
<EntityList
|
||||
entities={atOrigin}
|
||||
header={['At the same origin', originLocation!]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ColocatedPage(props: { entity: Entity }) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<>
|
||||
<DialogContentText variant="h2">Colocated</DialogContentText>
|
||||
<DialogContentText>
|
||||
These are the entities that are colocated with this entity - as in, they
|
||||
originated from the same data source (e.g. came from the same YAML
|
||||
file), or from the same origin (e.g. the originally registered URL).
|
||||
</DialogContentText>
|
||||
<div className={classes.root}>
|
||||
<Contents entity={props.entity} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2022 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 { useApp } from '@backstage/core-plugin-api';
|
||||
import WorkIcon from '@material-ui/icons/Work';
|
||||
import React from 'react';
|
||||
|
||||
export function EntityKindIcon(props: {
|
||||
kind: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const app = useApp();
|
||||
|
||||
const { kind, ...otherProps } = props;
|
||||
const Icon =
|
||||
app.getSystemIcon(`kind:${kind.toLocaleLowerCase('en-US')}`) ?? WorkIcon;
|
||||
|
||||
return <Icon {...otherProps} />;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ComponentEntity } from '@backstage/catalog-model';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { JsonPage } from './JsonPage';
|
||||
|
||||
describe('JsonPage', () => {
|
||||
it('renders', async () => {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'c1',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'ops',
|
||||
},
|
||||
};
|
||||
|
||||
await renderInTestApp(<JsonPage entity={entity} />);
|
||||
|
||||
expect(screen.getByTestId('code-snippet')).toHaveTextContent(
|
||||
'"lifecycle": "production"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
|
||||
import { CodeSnippet } from '@backstage/core-components';
|
||||
import { DialogContentText } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { sortKeys } from './util';
|
||||
|
||||
export function JsonPage(props: { entity: Entity }) {
|
||||
return (
|
||||
<>
|
||||
<DialogContentText variant="h2">Entity as JSON</DialogContentText>
|
||||
<DialogContentText>
|
||||
This is the raw entity data as received from the catalog, on JSON form.
|
||||
</DialogContentText>
|
||||
<DialogContentText>
|
||||
<div style={{ fontSize: '75%' }} data-testid="code-snippet">
|
||||
<CodeSnippet
|
||||
text={JSON.stringify(sortKeys(props.entity), undefined, 2)}
|
||||
language="json"
|
||||
showCopyCodeButton
|
||||
/>
|
||||
</div>
|
||||
</DialogContentText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2022 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 { AlphaEntity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import {
|
||||
Box,
|
||||
DialogContentText,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import React from 'react';
|
||||
import { EntityRefLink } from '../../EntityRefLink';
|
||||
import {
|
||||
Container,
|
||||
HelpIcon,
|
||||
KeyValueListItem,
|
||||
ListItemText,
|
||||
ListSubheader,
|
||||
} from './common';
|
||||
import { EntityKindIcon } from './EntityKindIcon';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
});
|
||||
|
||||
export function OverviewPage(props: { entity: AlphaEntity }) {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
apiVersion,
|
||||
kind,
|
||||
metadata,
|
||||
spec,
|
||||
relations = [],
|
||||
status = {},
|
||||
} = props.entity;
|
||||
|
||||
const groupedRelations = groupBy(
|
||||
sortBy(relations, r => stringifyEntityRef(r.target)),
|
||||
'type',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogContentText variant="h2">Overview</DialogContentText>
|
||||
<div className={classes.root}>
|
||||
<Container title="Identity">
|
||||
<List dense>
|
||||
<ListItem>
|
||||
<ListItemText primary="apiVersion" secondary={apiVersion} />
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText primary="kind" secondary={kind} />
|
||||
</ListItem>
|
||||
{spec?.type && (
|
||||
<ListItem>
|
||||
<ListItemText primary="spec.type" secondary={spec.type} />
|
||||
</ListItem>
|
||||
)}
|
||||
{metadata.uid && (
|
||||
<ListItem>
|
||||
<ListItemText primary="uid" secondary={metadata.uid} />
|
||||
</ListItem>
|
||||
)}
|
||||
{metadata.etag && (
|
||||
<ListItem>
|
||||
<ListItemText primary="etag" secondary={metadata.etag} />
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</Container>
|
||||
|
||||
<Container title="Metadata">
|
||||
{!!Object.keys(metadata.annotations || {}).length && (
|
||||
<List
|
||||
dense
|
||||
subheader={
|
||||
<ListSubheader>
|
||||
Annotations
|
||||
<HelpIcon to="https://backstage.io/docs/features/software-catalog/well-known-annotations" />
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{Object.entries(metadata.annotations!).map(entry => (
|
||||
<KeyValueListItem key={entry[0]} indent entry={entry} />
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
{!!Object.keys(metadata.labels || {}).length && (
|
||||
<List
|
||||
dense
|
||||
subheader={
|
||||
<ListSubheader>
|
||||
Labels
|
||||
<HelpIcon to="https://backstage.io/docs/features/software-catalog/well-known-labels" />
|
||||
</ListSubheader>
|
||||
}
|
||||
>
|
||||
{Object.entries(metadata.labels!).map(entry => (
|
||||
<KeyValueListItem key={entry[0]} indent entry={entry} />
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
{!!metadata.tags?.length && (
|
||||
<List dense subheader={<ListSubheader>Tags</ListSubheader>}>
|
||||
{metadata.tags.map((tag, index) => (
|
||||
<ListItem key={`${tag}-${index}`}>
|
||||
<ListItemIcon />
|
||||
<ListItemText primary={tag} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</Container>
|
||||
|
||||
{!!relations.length && (
|
||||
<Container
|
||||
title="Relations"
|
||||
helpLink="https://backstage.io/docs/features/software-catalog/well-known-relations"
|
||||
>
|
||||
{Object.entries(groupedRelations).map(
|
||||
([type, groupRelations], index) => (
|
||||
<div key={index}>
|
||||
<List dense subheader={<ListSubheader>{type}</ListSubheader>}>
|
||||
{groupRelations.map(group => (
|
||||
<ListItem key={stringifyEntityRef(group.target)}>
|
||||
<ListItemIcon>
|
||||
<EntityKindIcon kind={group.target.kind} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={<EntityRefLink entityRef={group.target} />}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</Container>
|
||||
)}
|
||||
|
||||
{!!status.items?.length && (
|
||||
<Container
|
||||
title="Status"
|
||||
helpLink="https://backstage.io/docs/features/software-catalog/well-known-statuses"
|
||||
>
|
||||
{status.items.map((item, index) => (
|
||||
<div key={index}>
|
||||
<Typography>
|
||||
{item.level}: {item.type}
|
||||
</Typography>
|
||||
<Box ml={2}>{item.message}</Box>
|
||||
</div>
|
||||
))}
|
||||
</Container>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2022 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 { ComponentEntity } from '@backstage/catalog-model';
|
||||
import { renderInTestApp } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { YamlPage } from './YamlPage';
|
||||
|
||||
describe('YamlPage', () => {
|
||||
it('renders', async () => {
|
||||
const entity: ComponentEntity = {
|
||||
apiVersion: 'backstage.io/v1alpha1',
|
||||
kind: 'Component',
|
||||
metadata: {
|
||||
namespace: 'default',
|
||||
name: 'c1',
|
||||
},
|
||||
spec: {
|
||||
type: 'service',
|
||||
lifecycle: 'production',
|
||||
owner: 'ops',
|
||||
},
|
||||
};
|
||||
|
||||
await renderInTestApp(<YamlPage entity={entity} />);
|
||||
|
||||
expect(screen.getByTestId('code-snippet')).toHaveTextContent(
|
||||
'lifecycle: production',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Entity } from '@backstage/catalog-model';
|
||||
import { CodeSnippet } from '@backstage/core-components';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import React from 'react';
|
||||
import YAML from 'yaml';
|
||||
import { sortKeys } from './util';
|
||||
|
||||
export function YamlPage(props: { entity: Entity }) {
|
||||
return (
|
||||
<>
|
||||
<DialogContentText variant="h2">Entity as YAML</DialogContentText>
|
||||
<DialogContentText>
|
||||
This is the raw entity data as received from the catalog, on YAML form.
|
||||
</DialogContentText>
|
||||
<DialogContentText>
|
||||
<div style={{ fontSize: '75%' }} data-testid="code-snippet">
|
||||
<CodeSnippet
|
||||
text={YAML.stringify(sortKeys(props.entity))}
|
||||
language="yaml"
|
||||
showCopyCodeButton
|
||||
/>
|
||||
</div>
|
||||
</DialogContentText>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2022 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 { Link } from '@backstage/core-components';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText as MuiListItemText,
|
||||
ListSubheader as MuiListSubheader,
|
||||
makeStyles,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import HelpOutlineIcon from '@material-ui/icons/HelpOutline';
|
||||
import React from 'react';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
marginTop: {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
helpIcon: {
|
||||
marginLeft: theme.spacing(1),
|
||||
color: theme.palette.text.disabled,
|
||||
},
|
||||
monospace: {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
}));
|
||||
|
||||
export function ListItemText(props: {
|
||||
primary: React.ReactNode;
|
||||
secondary?: React.ReactNode;
|
||||
}) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<MuiListItemText
|
||||
{...props}
|
||||
primaryTypographyProps={{ className: classes.monospace }}
|
||||
secondaryTypographyProps={{ className: classes.monospace }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListSubheader(props: { children?: React.ReactNode }) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<MuiListSubheader className={classes.monospace}>
|
||||
{props.children}
|
||||
</MuiListSubheader>
|
||||
);
|
||||
}
|
||||
|
||||
export function Container(props: {
|
||||
title: React.ReactNode;
|
||||
helpLink?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box mt={2}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{props.title}
|
||||
{props.helpLink && <HelpIcon to={props.helpLink} />}
|
||||
</Typography>
|
||||
{props.children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function KeyValueListItem(props: {
|
||||
indent?: boolean;
|
||||
entry: [string, string];
|
||||
}) {
|
||||
const [key, value] = props.entry;
|
||||
return (
|
||||
<ListItem>
|
||||
{props.indent && <ListItemIcon />}
|
||||
<ListItemText
|
||||
primary={key}
|
||||
secondary={
|
||||
!value.match(/^url:https?:\/\//) ? (
|
||||
value
|
||||
) : (
|
||||
<>
|
||||
{value.substring(0, 4)}
|
||||
<Link to={value.substring(4)}>{value.substring(4)}</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function HelpIcon(props: { to: string }) {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Link to={props.to} className={classes.helpIcon}>
|
||||
<HelpOutlineIcon fontSize="inherit" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2022 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 { JsonObject } from '@backstage/types';
|
||||
|
||||
export function sortKeys(data: JsonObject): JsonObject {
|
||||
// we could do something custom, but lexicographical sorting is actually a
|
||||
// good choice at least for the default set of keys
|
||||
return Object.fromEntries(
|
||||
[...Object.entries(data)].sort((a, b) => (a[0] < b[0] ? -1 : 1)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2022 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.
|
||||
*/
|
||||
|
||||
export { InspectEntityDialog } from './InspectEntityDialog';
|
||||
@@ -23,5 +23,6 @@ export * from './EntityTable';
|
||||
export * from './EntityTagPicker';
|
||||
export * from './EntityTypePicker';
|
||||
export * from './FavoriteEntity';
|
||||
export * from './InspectEntityDialog';
|
||||
export * from './UnregisterEntityDialog';
|
||||
export * from './UserListPicker';
|
||||
|
||||
@@ -351,7 +351,7 @@ export const EntityOrphanWarning: () => JSX.Element;
|
||||
|
||||
// Warning: (ae-missing-release-tag) "EntityPageLayout" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
|
||||
//
|
||||
// @public (undocumented)
|
||||
// @public @deprecated
|
||||
export const EntityPageLayout: {
|
||||
({
|
||||
children,
|
||||
@@ -481,6 +481,6 @@ export type SystemDiagramCardClassKey =
|
||||
// src/components/CatalogTable/CatalogTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
|
||||
// src/components/EntityLayout/EntityLayout.d.ts:43:5 - (ae-forgotten-export) The symbol "EntityLayoutProps" needs to be exported by the entry point index.d.ts
|
||||
// src/components/EntityLayout/EntityLayout.d.ts:44:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts
|
||||
// src/components/EntityPageLayout/EntityPageLayout.d.ts:17:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts
|
||||
// src/components/EntityPageLayout/EntityPageLayout.d.ts:22:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts
|
||||
// src/plugin.d.ts:22:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts
|
||||
```
|
||||
|
||||
@@ -43,7 +43,12 @@ describe('ComponentContextMenu', () => {
|
||||
it('should call onUnregisterEntity on button click', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
await render(<EntityContextMenu onUnregisterEntity={mockCallback} />);
|
||||
await render(
|
||||
<EntityContextMenu
|
||||
onUnregisterEntity={mockCallback}
|
||||
onInspectEntity={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = await screen.findByTestId('menu-button');
|
||||
expect(button).toBeInTheDocument();
|
||||
@@ -56,6 +61,27 @@ describe('ComponentContextMenu', () => {
|
||||
expect(mockCallback).toBeCalled();
|
||||
});
|
||||
|
||||
it('should call onInspectEntity on button click', async () => {
|
||||
const mockCallback = jest.fn();
|
||||
|
||||
await render(
|
||||
<EntityContextMenu
|
||||
onUnregisterEntity={() => {}}
|
||||
onInspectEntity={mockCallback}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = await screen.findByTestId('menu-button');
|
||||
expect(button).toBeInTheDocument();
|
||||
fireEvent.click(button);
|
||||
|
||||
const unregister = await screen.findByText('Inspect entity');
|
||||
expect(unregister).toBeInTheDocument();
|
||||
fireEvent.click(unregister);
|
||||
|
||||
expect(mockCallback).toBeCalled();
|
||||
});
|
||||
|
||||
it('supports extra items', async () => {
|
||||
const extra = {
|
||||
title: 'HELLO',
|
||||
@@ -66,6 +92,7 @@ describe('ComponentContextMenu', () => {
|
||||
await render(
|
||||
<EntityContextMenu
|
||||
onUnregisterEntity={jest.fn()}
|
||||
onInspectEntity={jest.fn()}
|
||||
UNSTABLE_extraContextMenuItems={[extra]}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
Popover,
|
||||
} from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import Cancel from '@material-ui/icons/Cancel';
|
||||
import CancelIcon from '@material-ui/icons/Cancel';
|
||||
import BugReportIcon from '@material-ui/icons/BugReport';
|
||||
import MoreVert from '@material-ui/icons/MoreVert';
|
||||
import React, { useState } from 'react';
|
||||
import { IconComponent } from '@backstage/core-plugin-api';
|
||||
@@ -55,12 +56,14 @@ type Props = {
|
||||
UNSTABLE_extraContextMenuItems?: ExtraContextMenuItem[];
|
||||
UNSTABLE_contextMenuOptions?: contextMenuOptions;
|
||||
onUnregisterEntity: () => void;
|
||||
onInspectEntity: () => void;
|
||||
};
|
||||
|
||||
export const EntityContextMenu = ({
|
||||
UNSTABLE_extraContextMenuItems,
|
||||
UNSTABLE_contextMenuOptions,
|
||||
onUnregisterEntity,
|
||||
onInspectEntity,
|
||||
}: Props) => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const classes = useStyles();
|
||||
@@ -128,10 +131,21 @@ export const EntityContextMenu = ({
|
||||
disabled={disableUnregister}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<Cancel fontSize="small" />
|
||||
<CancelIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Unregister entity" />
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onInspectEntity();
|
||||
}}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<BugReportIcon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Inspect entity" />
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</>
|
||||
|
||||
@@ -39,13 +39,14 @@ import {
|
||||
EntityRefLinks,
|
||||
FavoriteEntity,
|
||||
getEntityRelations,
|
||||
InspectEntityDialog,
|
||||
UnregisterEntityDialog,
|
||||
useEntityCompoundName,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
import { Box, TabProps } from '@material-ui/core';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
|
||||
|
||||
type SubRoute = {
|
||||
@@ -177,6 +178,7 @@ export const EntityLayout = ({
|
||||
}: EntityLayoutProps) => {
|
||||
const { kind, namespace, name } = useEntityCompoundName();
|
||||
const { entity, loading, error } = useContext(EntityContext);
|
||||
const location = useLocation();
|
||||
const routes = useElementFilter(
|
||||
children,
|
||||
elements =>
|
||||
@@ -212,13 +214,21 @@ export const EntityLayout = ({
|
||||
);
|
||||
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
const [inspectionDialogOpen, setInspectionDialogOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const cleanUpAfterRemoval = async () => {
|
||||
setConfirmationDialogOpen(false);
|
||||
setInspectionDialogOpen(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const showRemovalDialog = () => setConfirmationDialogOpen(true);
|
||||
// Make sure to close the dialog if the user clicks links in it that navigate
|
||||
// to another entity.
|
||||
useEffect(() => {
|
||||
setConfirmationDialogOpen(false);
|
||||
setInspectionDialogOpen(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
|
||||
@@ -233,7 +243,8 @@ export const EntityLayout = ({
|
||||
<EntityContextMenu
|
||||
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
|
||||
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
|
||||
onUnregisterEntity={showRemovalDialog}
|
||||
onUnregisterEntity={() => setConfirmationDialogOpen(true)}
|
||||
onInspectEntity={() => setInspectionDialogOpen(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -267,6 +278,11 @@ export const EntityLayout = ({
|
||||
onConfirm={cleanUpAfterRemoval}
|
||||
onClose={() => setConfirmationDialogOpen(false)}
|
||||
/>
|
||||
<InspectEntityDialog
|
||||
open={inspectionDialogOpen}
|
||||
entity={entity!}
|
||||
onClose={() => setInspectionDialogOpen(false)}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
EntityRefLinks,
|
||||
FavoriteEntity,
|
||||
getEntityRelations,
|
||||
InspectEntityDialog,
|
||||
UnregisterEntityDialog,
|
||||
useEntityCompoundName,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
@@ -123,6 +124,11 @@ type EntityPageLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Old entity page, only used by the old router based hierarchies.
|
||||
*
|
||||
* @deprecated Please use CatalogEntityPage instead
|
||||
*/
|
||||
export const EntityPageLayout = ({
|
||||
children,
|
||||
UNSTABLE_extraContextMenuItems,
|
||||
@@ -138,14 +144,13 @@ export const EntityPageLayout = ({
|
||||
);
|
||||
|
||||
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
|
||||
const [inspectionDialogOpen, setInspectionDialogOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const cleanUpAfterRemoval = async () => {
|
||||
setConfirmationDialogOpen(false);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const showRemovalDialog = () => setConfirmationDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
|
||||
<Header
|
||||
@@ -160,7 +165,8 @@ export const EntityPageLayout = ({
|
||||
<EntityContextMenu
|
||||
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
|
||||
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
|
||||
onUnregisterEntity={showRemovalDialog}
|
||||
onUnregisterEntity={() => setConfirmationDialogOpen(true)}
|
||||
onInspectEntity={() => setInspectionDialogOpen(true)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -198,6 +204,11 @@ export const EntityPageLayout = ({
|
||||
onConfirm={cleanUpAfterRemoval}
|
||||
onClose={() => setConfirmationDialogOpen(false)}
|
||||
/>
|
||||
<InspectEntityDialog
|
||||
open={inspectionDialogOpen}
|
||||
entity={entity!}
|
||||
onClose={() => setInspectionDialogOpen(false)}
|
||||
/>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
+7
-8
@@ -14,18 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AlphaEntity, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
import {
|
||||
CatalogApi,
|
||||
catalogApiRef,
|
||||
EntityProvider,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
|
||||
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { EntityProcessingErrorsPanel } from './EntityProcessingErrorsPanel';
|
||||
import { AlphaEntity, getEntityName } from '@backstage/catalog-model';
|
||||
import { ApiProvider } from '@backstage/core-app-api';
|
||||
|
||||
describe('<EntityProcessErrors />', () => {
|
||||
const getEntityAncestors: jest.MockedFunction<
|
||||
@@ -98,8 +97,8 @@ describe('<EntityProcessErrors />', () => {
|
||||
};
|
||||
|
||||
getEntityAncestors.mockResolvedValue({
|
||||
root: getEntityName(entity),
|
||||
items: [{ entity, parents: [] }],
|
||||
rootEntityRef: stringifyEntityRef(entity),
|
||||
items: [{ entity, parentEntityRefs: [] }],
|
||||
});
|
||||
const { getByText, queryByText } = await renderInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
@@ -199,10 +198,10 @@ describe('<EntityProcessErrors />', () => {
|
||||
},
|
||||
};
|
||||
getEntityAncestors.mockResolvedValue({
|
||||
root: getEntityName(entity),
|
||||
rootEntityRef: stringifyEntityRef(entity),
|
||||
items: [
|
||||
{ entity, parents: [getEntityName(parent)] },
|
||||
{ entity: parent, parents: [] },
|
||||
{ entity, parentEntityRefs: [stringifyEntityRef(parent)] },
|
||||
{ entity: parent, parentEntityRefs: [] },
|
||||
],
|
||||
});
|
||||
const { getByText, queryByText } = await renderInTestApp(
|
||||
|
||||
Reference in New Issue
Block a user