refactor(catalog-react): migrate InspectEntityDialog from MUI to BUI

Replace Material UI components with Backstage UI (BUI) equivalents
across the InspectEntityDialog and all its tab pages (Overview,
Ancestry, Colocated, JSON, YAML).

- Dialog shell uses BUI Dialog, DialogHeader, DialogBody, Tabs
- Horizontal tab bar replaces vertical MUI tabs
- Card sections use BUI Card, CardHeader, CardBody
- Key-value pairs rendered as semantic dl/dt/dd elements
- Copy buttons use BUI ButtonIcon with remixicon icons
- Help links use BUI ButtonLink
- Alerts use BUI Alert
- Tags use BUI TagGroup/Tag
- Accessible live region for copy confirmation
- Proper heading hierarchy (h2 for tab pages, h3 for cards, h4 for sections)
- Added OverviewPage tests for identity rendering, link detection, and tags

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-31 14:48:09 +02:00
parent 8632502abe
commit fa232da324
14 changed files with 654 additions and 471 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Migrated `InspectEntityDialog` from Material UI to Backstage UI components. Added new translation keys: `inspectEntityDialog.overviewPage.copyAriaLabel`, `inspectEntityDialog.overviewPage.copiedStatus`, `inspectEntityDialog.overviewPage.helpLinkAriaLabel`, and `inspectEntityDialog.colocatedPage.entityListAriaLabel`.
+1
View File
@@ -80,6 +80,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@react-hookz/web": "^24.0.0",
"@remixicon/react": "^4.6.0",
"classnames": "^2.2.6",
"lodash": "^4.17.21",
"material-ui-popup-state": "^5.3.6",
@@ -74,6 +74,7 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.colocatedPage.alertNoEntity': 'There were no other entities on this location.';
readonly 'inspectEntityDialog.colocatedPage.locationHeader': 'At the same location';
readonly 'inspectEntityDialog.colocatedPage.originHeader': 'At the same origin';
readonly 'inspectEntityDialog.colocatedPage.entityListAriaLabel': 'Colocated entities';
readonly 'inspectEntityDialog.jsonPage.title': 'Entity as JSON';
readonly 'inspectEntityDialog.jsonPage.description': 'This is the raw entity data as received from the catalog, on JSON form.';
readonly 'inspectEntityDialog.overviewPage.title': 'Overview';
@@ -83,6 +84,9 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}';
readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied';
readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML';
readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.';
+4
View File
@@ -196,6 +196,7 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.colocatedPage.alertNoEntity': 'There were no other entities on this location.';
readonly 'inspectEntityDialog.colocatedPage.locationHeader': 'At the same location';
readonly 'inspectEntityDialog.colocatedPage.originHeader': 'At the same origin';
readonly 'inspectEntityDialog.colocatedPage.entityListAriaLabel': 'Colocated entities';
readonly 'inspectEntityDialog.jsonPage.title': 'Entity as JSON';
readonly 'inspectEntityDialog.jsonPage.description': 'This is the raw entity data as received from the catalog, on JSON form.';
readonly 'inspectEntityDialog.overviewPage.title': 'Overview';
@@ -205,6 +206,9 @@ export const catalogReactTranslationRef: TranslationRef<
readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity';
readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations';
readonly 'inspectEntityDialog.overviewPage.tags': 'Tags';
readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}';
readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied';
readonly 'inspectEntityDialog.overviewPage.helpLinkAriaLabel': 'Learn more';
readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations';
readonly 'inspectEntityDialog.yamlPage.title': 'Entity as YAML';
readonly 'inspectEntityDialog.yamlPage.description': 'This is the raw entity data as received from the catalog, on YAML form.';
@@ -15,16 +15,16 @@
*/
import { Entity } from '@backstage/catalog-model';
import Box from '@material-ui/core/Box';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Tab from '@material-ui/core/Tab';
import Tabs from '@material-ui/core/Tabs';
import { makeStyles } from '@material-ui/core/styles';
import { ComponentProps, useEffect, useState, ReactNode, useMemo } from 'react';
import {
Dialog,
DialogHeader,
DialogBody,
Tabs,
TabList,
Tab,
TabPanel,
} from '@backstage/ui';
import { useMemo } from 'react';
import { AncestryPage } from './components/AncestryPage';
import { ColocatedPage } from './components/ColocatedPage';
import { JsonPage } from './components/JsonPage';
@@ -33,64 +33,68 @@ import { YamlPage } from './components/YamlPage';
import { catalogReactTranslationRef } from '../../translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
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?: 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}`,
};
}
type TabKey = 'overview' | 'ancestry' | 'colocated' | 'json' | 'yaml';
type TabNames = Record<
NonNullable<ComponentProps<typeof InspectEntityDialog>['initialTab']>,
string
>;
const TAB_KEYS: TabKey[] = [
'overview',
'ancestry',
'colocated',
'json',
'yaml',
];
function DialogContents(props: {
entity: Entity;
initialTab?: TabKey;
onSelect?: (tab: string) => void;
}) {
const { entity, initialTab, onSelect } = props;
const { t } = useTranslationRef(catalogReactTranslationRef);
const tabNames = useMemo(
() => ({
overview: t('inspectEntityDialog.tabNames.overview'),
ancestry: t('inspectEntityDialog.tabNames.ancestry'),
colocated: t('inspectEntityDialog.tabNames.colocated'),
json: t('inspectEntityDialog.tabNames.json'),
yaml: t('inspectEntityDialog.tabNames.yaml'),
}),
[t],
);
const tabContent: Record<TabKey, JSX.Element> = {
overview: <OverviewPage entity={entity} />,
ancestry: <AncestryPage entity={entity} />,
colocated: <ColocatedPage entity={entity} />,
json: <JsonPage entity={entity} />,
yaml: <YamlPage entity={entity} />,
};
return (
<>
<DialogHeader>{t('inspectEntityDialog.title')}</DialogHeader>
<DialogBody>
<Tabs
defaultSelectedKey={initialTab || 'overview'}
onSelectionChange={key => onSelect?.(key as string)}
>
<TabList aria-label={t('inspectEntityDialog.tabsAriaLabel')}>
{TAB_KEYS.map(tab => (
<Tab key={tab} id={tab}>
{tabNames[tab]}
</Tab>
))}
</TabList>
{TAB_KEYS.map(tab => (
<TabPanel key={tab} id={tab}>
{tabContent[tab]}
</TabPanel>
))}
</Tabs>
</DialogBody>
</>
);
}
/**
* A dialog that lets users inspect the low level details of their entities.
@@ -104,90 +108,26 @@ export function InspectEntityDialog(props: {
onClose: () => void;
onSelect?: (tab: string) => void;
}) {
const classes = useStyles();
const { t } = useTranslationRef(catalogReactTranslationRef);
const { open, entity, initialTab, onClose, onSelect } = props;
const tabNames: TabNames = useMemo(
() => ({
overview: t('inspectEntityDialog.tabNames.overview'),
ancestry: t('inspectEntityDialog.tabNames.ancestry'),
colocated: t('inspectEntityDialog.tabNames.colocated'),
json: t('inspectEntityDialog.tabNames.json'),
yaml: t('inspectEntityDialog.tabNames.yaml'),
}),
[t],
);
const tabs = Object.keys(tabNames) as TabKey[];
const [activeTab, setActiveTab] = useState(
getTabIndex(tabs, props.initialTab),
);
useEffect(() => {
getTabIndex(tabs, props.initialTab);
}, [props.open, props.initialTab, tabs]);
if (!props.entity) {
if (!entity) {
return null;
}
return (
<Dialog
fullWidth
maxWidth="xl"
open={props.open}
onClose={props.onClose}
aria-labelledby="entity-inspector-dialog-title"
PaperProps={{ className: classes.fullHeightDialog }}
isOpen={open}
onOpenChange={isOpen => !isOpen && onClose()}
width="940px"
height="100vh"
>
<DialogTitle id="entity-inspector-dialog-title">
{t('inspectEntityDialog.title')}
</DialogTitle>
<DialogContent dividers>
<div className={classes.root}>
<Tabs
orientation="vertical"
variant="scrollable"
value={activeTab}
onChange={(_, tabIndex) => {
setActiveTab(tabIndex);
props.onSelect?.(tabs[tabIndex]);
}}
aria-label={t('inspectEntityDialog.tabsAriaLabel')}
className={classes.tabs}
>
{tabs.map((tab, index) => (
<Tab key={tab} label={tabNames[tab]} {...a11yProps(index)} />
))}
</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">
{t('inspectEntityDialog.closeButtonTitle')}
</Button>
</DialogActions>
{open && (
<DialogContents
entity={entity}
initialTab={initialTab}
onSelect={onSelect}
/>
)}
</Dialog>
);
}
function getTabIndex(allTabs: string[], initialTab: TabKey | undefined) {
return initialTab ? allTabs.indexOf(initialTab) : 0;
}
@@ -27,8 +27,7 @@ import {
ResponseErrorPanel,
} from '@backstage/core-components';
import { useApi, useApp, useRouteRef } from '@backstage/core-plugin-api';
import Box from '@material-ui/core/Box';
import DialogContentText from '@material-ui/core/DialogContentText';
import { Text, Box } from '@backstage/ui';
import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import { useLayoutEffect, useRef, useState } from 'react';
@@ -201,19 +200,18 @@ export function AncestryPage(props: { entity: Entity }) {
return (
<>
<DialogContentText variant="h2">
{t('inspectEntityDialog.ancestryPage.title')}
</DialogContentText>
<DialogContentText gutterBottom>
{t('inspectEntityDialog.ancestryPage.description', {
processorsLink: (
<Link to="https://backstage.io/docs/features/software-catalog/life-of-an-entity">
{t('inspectEntityDialog.ancestryPage.processorsLink')}
</Link>
),
})}
</DialogContentText>
<Box mt={4}>
<Box mb="2">
<Text as="p">
{t('inspectEntityDialog.ancestryPage.description', {
processorsLink: (
<Link to="https://backstage.io/docs/features/software-catalog/life-of-an-entity">
{t('inspectEntityDialog.ancestryPage.processorsLink')}
</Link>
),
})}
</Text>
</Box>
<Box mt="8">
<DependencyGraph
nodes={nodes}
edges={edges}
@@ -14,6 +14,10 @@
* limitations under the License.
*/
// Native HTML elements like <p> and <header> are used intentionally for
// semantic markup. The react/forbid-elements rule predates the BUI migration.
/* eslint-disable react/forbid-elements */
import {
Entity,
ANNOTATION_LOCATION,
@@ -22,22 +26,40 @@ import {
} from '@backstage/catalog-model';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import DialogContentText from '@material-ui/core/DialogContentText';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import { makeStyles } from '@material-ui/core/styles';
import Alert from '@material-ui/lab/Alert';
import { Text, Alert } from '@backstage/ui';
import useAsync from 'react-use/esm/useAsync';
import { catalogApiRef } from '../../../api';
import { EntityRefLink } from '../../EntityRefLink';
import { KeyValueListItem, ListItemText } from './common';
import { makeStyles } from '@material-ui/core/styles';
import { ListSection, ListItemRow } from './common';
import { catalogReactTranslationRef } from '../../../translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
header: {
paddingLeft: 'var(--bui-space-4)',
marginTop: 'var(--bui-space-4)',
marginBottom: 'var(--bui-space-4)',
},
headerLabel: {
margin: 0,
fontFamily: 'monospace',
fontSize: 'var(--bui-font-size-3)',
fontWeight: 'var(--bui-font-weight-regular)' as any,
},
entityList: {
marginTop: 'var(--bui-space-4)',
},
headerValue: {
margin: 0,
marginTop: 'var(--bui-space-1)',
fontFamily: 'monospace',
fontSize: 'var(--bui-font-size-3)',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
});
@@ -63,7 +85,11 @@ function useColocated(entity: Entity): {
? [{ [`metadata.annotations.${ANNOTATION_LOCATION}`]: location }]
: []),
...(origin
? [{ [`metadata.annotations.${ANNOTATION_ORIGIN_LOCATION}`]: origin }]
? [
{
[`metadata.annotations.${ANNOTATION_ORIGIN_LOCATION}`]: origin,
},
]
: []),
],
});
@@ -82,15 +108,27 @@ function useColocated(entity: Entity): {
}
function EntityList(props: { entities: Entity[]; header?: [string, string] }) {
const classes = useStyles();
const { t } = useTranslationRef(catalogReactTranslationRef);
return (
<List dense>
{props.header && <KeyValueListItem key="header" entry={props.header} />}
{props.entities.map(entity => (
<ListItem key={stringifyEntityRef(entity)}>
<ListItemText primary={<EntityRefLink entityRef={entity} />} />
</ListItem>
))}
</List>
<>
{props.header && (
<header className={classes.header}>
<h4 className={classes.headerLabel}>{props.header[0]}</h4>
<p className={classes.headerValue}>{props.header[1]}</p>
</header>
)}
<ListSection
aria-label={t('inspectEntityDialog.colocatedPage.entityListAriaLabel')}
className={classes.entityList}
>
{props.entities.map(entity => (
<ListItemRow key={stringifyEntityRef(entity)}>
<EntityRefLink entityRef={entity} />
</ListItemRow>
))}
</ListSection>
</>
);
}
@@ -108,15 +146,19 @@ function Contents(props: { entity: Entity }) {
if (!location && !originLocation) {
return (
<Alert severity="warning">
{t('inspectEntityDialog.colocatedPage.alertNoLocation')}
</Alert>
<Alert
status="warning"
description={t('inspectEntityDialog.colocatedPage.alertNoLocation')}
mt="4"
/>
);
} else if (!colocatedEntities?.length) {
return (
<Alert severity="info">
{t('inspectEntityDialog.colocatedPage.alertNoEntity')}
</Alert>
<Alert
status="info"
description={t('inspectEntityDialog.colocatedPage.alertNoEntity')}
mt="4"
/>
);
}
@@ -157,19 +199,11 @@ function Contents(props: { entity: Entity }) {
}
export function ColocatedPage(props: { entity: Entity }) {
const classes = useStyles();
const { t } = useTranslationRef(catalogReactTranslationRef);
return (
<>
<DialogContentText variant="h2">
{t('inspectEntityDialog.colocatedPage.title')}
</DialogContentText>
<DialogContentText>
{t('inspectEntityDialog.colocatedPage.description')}
</DialogContentText>
<div className={classes.root}>
<Contents entity={props.entity} />
</div>
<Text as="p">{t('inspectEntityDialog.colocatedPage.description')}</Text>
<Contents entity={props.entity} />
</>
);
}
@@ -16,7 +16,7 @@
import { Entity } from '@backstage/catalog-model';
import { CodeSnippet } from '@backstage/core-components';
import DialogContentText from '@material-ui/core/DialogContentText';
import { Text } from '@backstage/ui';
import { sortKeys } from './util';
import { catalogReactTranslationRef } from '../../../translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -25,21 +25,14 @@ export function JsonPage(props: { entity: Entity }) {
const { t } = useTranslationRef(catalogReactTranslationRef);
return (
<>
<DialogContentText variant="h2">
{t('inspectEntityDialog.jsonPage.title')}
</DialogContentText>
<DialogContentText>
{t('inspectEntityDialog.jsonPage.description')}
</DialogContentText>
<DialogContentText>
<div style={{ fontSize: '75%' }} data-testid="code-snippet">
<CodeSnippet
text={JSON.stringify(sortKeys(props.entity), undefined, 2)}
language="json"
showCopyCodeButton
/>
</div>
</DialogContentText>
<Text as="p">{t('inspectEntityDialog.jsonPage.description')}</Text>
<div data-testid="code-snippet">
<CodeSnippet
text={JSON.stringify(sortKeys(props.entity), undefined, 2)}
language="json"
showCopyCodeButton
/>
</div>
</>
);
}
@@ -0,0 +1,103 @@
/*
* Copyright 2026 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 { renderInTestApp } from '@backstage/test-utils';
import { screen } from '@testing-library/react';
import { entityRouteRef } from '../../../routes';
import { OverviewPage } from './OverviewPage';
const mountedRoutes = {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
};
const entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
namespace: 'default',
name: 'test-component',
uid: 'test-uid-123',
etag: 'test-etag-456',
annotations: {
'backstage.io/source-location': 'url:https://github.com/example/repo',
'backstage.io/techdocs-ref': 'dir:.',
},
labels: {
'backstage.io/custom': 'value',
},
tags: ['java', 'data'],
},
spec: {
type: 'service',
lifecycle: 'production',
owner: 'team-a',
},
relations: [
{ type: 'ownedBy', targetRef: 'group:default/team-a' },
{ type: 'ownedBy', targetRef: 'group:default/team-b' },
{ type: 'dependsOn', targetRef: 'component:default/other' },
],
} as any;
describe('OverviewPage', () => {
it('renders identity key-value pairs', async () => {
await renderInTestApp(<OverviewPage entity={entity} />, { mountedRoutes });
const terms = screen.getAllByRole('term');
const definitions = screen.getAllByRole('definition');
const termTexts = terms.map(el => el.textContent);
expect(termTexts).toContain('apiVersion');
expect(termTexts).toContain('kind');
expect(termTexts).toContain('uid');
expect(termTexts).toContain('etag');
expect(termTexts).toContain('entityRef');
const defTexts = definitions.map(el => el.textContent);
expect(defTexts).toContain('backstage.io/v1alpha1');
expect(defTexts).toContain('Component');
expect(defTexts).toContain('test-uid-123');
expect(defTexts).toContain('test-etag-456');
expect(defTexts).toContain('component:default/test-component');
});
it('renders annotation values as links when they start with https:// or url:https://', async () => {
await renderInTestApp(<OverviewPage entity={entity} />, { mountedRoutes });
// url:https:// prefix is stripped from href but full value shown as link text
// The accessible name includes ", Opens in a new window" appended by the Link component
const sourceLocationLink = await screen.findByRole('link', {
name: /url:https:\/\/github\.com\/example\/repo/,
});
expect(sourceLocationLink).toHaveAttribute(
'href',
'https://github.com/example/repo',
);
// Plain non-URL annotation value renders as text, not a link
expect(
screen.queryByRole('link', { name: 'dir:.' }),
).not.toBeInTheDocument();
expect(screen.getByText('dir:.')).toBeInTheDocument();
});
it('renders tags', async () => {
await renderInTestApp(<OverviewPage entity={entity} />, { mountedRoutes });
expect(await screen.findByText('java')).toBeInTheDocument();
expect(screen.getByText('data')).toBeInTheDocument();
});
});
@@ -15,36 +15,228 @@
*/
import { AlphaEntity } from '@backstage/catalog-model/alpha';
import Box from '@material-ui/core/Box';
import DialogContentText from '@material-ui/core/DialogContentText';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import Typography from '@material-ui/core/Typography';
import { Link } from '@backstage/core-components';
import {
Text,
Box,
Flex,
Card,
CardHeader,
CardBody,
TagGroup,
Tag,
ButtonIcon,
ButtonLink,
VisuallyHidden,
} from '@backstage/ui';
import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import { RiFileCopyLine, RiCheckLine, RiQuestionLine } from '@remixicon/react';
import groupBy from 'lodash/groupBy';
import sortBy from 'lodash/sortBy';
import { ReactNode, useEffect, useRef, useState } from 'react';
import { EntityRefLink } from '../../EntityRefLink';
import {
Container,
HelpIcon,
KeyValueListItem,
ListItemText,
ListSubheader,
} from './common';
import { ListSection, ListItemRow } from './common';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { CopyTextButton } from '@backstage/core-components';
import { catalogReactTranslationRef } from '../../../translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
const useStyles = makeStyles({
root: {
headingWithIcon: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
},
definitionList: {
margin: 0,
padding: 0,
},
definitionItem: {
display: 'flex',
alignItems: 'flex-start',
marginTop: 'var(--bui-space-4)',
paddingLeft: 'var(--bui-space-4)',
fontFamily: 'monospace',
fontSize: 'var(--bui-font-size-3)',
'&:first-child': {
marginTop: 0,
},
},
definitionContent: {
flex: 1,
minWidth: 0,
},
definitionKey: {
fontWeight: 'bold',
},
definitionValue: {
margin: 0,
marginTop: 'var(--bui-space-1)',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
},
copyAction: {
marginLeft: 'var(--bui-space-2)',
flexShrink: 0,
},
relationGroup: {
'& + &': {
marginTop: 'var(--bui-space-4)',
},
},
monospace: {
fontFamily: 'monospace',
},
sectionHeading: {
marginTop: 'var(--bui-space-3)',
},
metadataList: {
marginTop: 'var(--bui-space-2)',
},
relationList: {
marginTop: 'var(--bui-space-2)',
},
tagGroup: {
marginTop: 'var(--bui-space-3)',
paddingLeft: 'var(--bui-space-4)',
},
});
// Extracts a link from a value, if possible
function findLink(value: string): string | undefined {
if (value.match(/^url:https?:\/\//)) {
return value.slice('url:'.length);
}
if (value.match(/^https?:\/\//)) {
return value;
}
return undefined;
}
function entriesToItems(entries: [string, string][]) {
return entries.map(([key, value]) => {
const link = findLink(value);
return {
key,
value: link ? <Link to={link}>{value}</Link> : value,
};
});
}
function CopyButton({ text, label }: { text: string; label: string }) {
const { t } = useTranslationRef(catalogReactTranslationRef);
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => () => clearTimeout(timerRef.current), []);
const handlePress = async () => {
try {
await window.navigator.clipboard.writeText(text);
setCopied(true);
timerRef.current = setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard access denied or unavailable
}
};
return (
<>
<ButtonIcon
icon={copied ? <RiCheckLine /> : <RiFileCopyLine />}
aria-label={t('inspectEntityDialog.overviewPage.copyAriaLabel', {
label,
})}
variant="tertiary"
size="small"
onPress={handlePress}
/>
<VisuallyHidden role="status">
{copied ? t('inspectEntityDialog.overviewPage.copiedStatus') : ''}
</VisuallyHidden>
</>
);
}
function HelpIcon(props: { to: string }) {
const { t } = useTranslationRef(catalogReactTranslationRef);
return (
<ButtonLink
href={props.to}
target="_blank"
rel="noopener noreferrer"
variant="tertiary"
size="small"
iconStart={<RiQuestionLine />}
aria-label={t('inspectEntityDialog.overviewPage.helpLinkAriaLabel')}
/>
);
}
function Container(props: {
title: ReactNode;
helpLink?: string;
children: ReactNode;
}) {
const classes = useStyles();
return (
<Card>
<CardHeader>
<Text
variant="title-x-small"
as="h3"
className={props.helpLink ? classes.headingWithIcon : undefined}
>
{props.title}
{props.helpLink && <HelpIcon to={props.helpLink} />}
</Text>
</CardHeader>
<CardBody>{props.children}</CardBody>
</Card>
);
}
function ListSubheader(props: { children: ReactNode; className?: string }) {
const classes = useStyles();
return (
<Text
variant="body-large"
as="h4"
className={classNames(classes.headingWithIcon, props.className)}
>
{props.children}
</Text>
);
}
function KeyValueList(props: {
items: { key: string; value: ReactNode; copyable?: boolean }[];
className?: string;
'aria-label'?: string;
}) {
const classes = useStyles();
return (
<dl
className={classNames(classes.definitionList, props.className)}
aria-label={props['aria-label']}
>
{props.items.map(item => (
<div key={item.key} className={classes.definitionItem}>
<div className={classes.definitionContent}>
<dt className={classes.definitionKey}>{item.key}</dt>
<dd className={classes.definitionValue}>{item.value}</dd>
</div>
{item.copyable && typeof item.value === 'string' && (
<div className={classes.copyAction}>
<CopyButton text={item.value} label={item.key} />
</div>
)}
</div>
))}
</dl>
);
}
export function OverviewPage(props: { entity: AlphaEntity }) {
const classes = useStyles();
const {
@@ -64,142 +256,109 @@ export function OverviewPage(props: { entity: AlphaEntity }) {
const entityRef = stringifyEntityRef(props.entity);
return (
<>
<DialogContentText variant="h2">
{t('inspectEntityDialog.overviewPage.title')}
</DialogContentText>
<div className={classes.root}>
<Container title={t('inspectEntityDialog.overviewPage.identity.title')}>
<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?.toString()}
/>
</ListItem>
)}
{metadata.uid && (
<ListItem>
<ListItemText primary="uid" secondary={metadata.uid} />
<ListItemSecondaryAction>
<CopyTextButton text={metadata.uid} />
</ListItemSecondaryAction>
</ListItem>
)}
{metadata.etag && (
<ListItem>
<ListItemText primary="etag" secondary={metadata.etag} />
<ListItemSecondaryAction>
<CopyTextButton text={metadata.etag} />
</ListItemSecondaryAction>
</ListItem>
)}
<ListItem>
<ListItemText primary="entityRef" secondary={entityRef} />
<ListItemSecondaryAction>
<CopyTextButton text={entityRef} />
</ListItemSecondaryAction>
</ListItem>
</List>
</Container>
<Flex direction="column" gap="4">
<Container title={t('inspectEntityDialog.overviewPage.identity.title')}>
<KeyValueList
aria-label={t('inspectEntityDialog.overviewPage.identity.title')}
items={[
{ key: 'apiVersion', value: apiVersion },
{ key: 'kind', value: kind },
...(spec?.type
? [{ key: 'spec.type', value: spec.type.toString() }]
: []),
...(metadata.uid
? [{ key: 'uid', value: metadata.uid, copyable: true }]
: []),
...(metadata.etag
? [{ key: 'etag', value: metadata.etag, copyable: true }]
: []),
{ key: 'entityRef', value: entityRef, copyable: true },
]}
/>
</Container>
<Container title={t('inspectEntityDialog.overviewPage.metadata.title')}>
{!!Object.keys(metadata.annotations || {}).length && (
<List
dense
subheader={
<ListSubheader>
{t('inspectEntityDialog.overviewPage.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>
{t('inspectEntityDialog.overviewPage.labels')}
</ListSubheader>
}
>
{Object.entries(metadata.labels!).map(entry => (
<KeyValueListItem key={entry[0]} indent entry={entry} />
))}
</List>
)}
{!!metadata.tags?.length && (
<List
dense
subheader={
<ListSubheader>
{t('inspectEntityDialog.overviewPage.tags')}
</ListSubheader>
}
>
{metadata.tags.map((tag, index) => (
<ListItem key={`${tag}-${index}`}>
<ListItemIcon />
<ListItemText primary={tag} />
</ListItem>
))}
</List>
)}
</Container>
{!!relations.length && (
<Container
title={t('inspectEntityDialog.overviewPage.relation.title')}
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={group.targetRef}>
<ListItemText
primary={
<EntityRefLink entityRef={group.targetRef} />
}
/>
</ListItem>
))}
</List>
</div>
),
)}
</Container>
<Container title={t('inspectEntityDialog.overviewPage.metadata.title')}>
{!!Object.keys(metadata.annotations || {}).length && (
<>
<ListSubheader>
{t('inspectEntityDialog.overviewPage.annotations')}
<HelpIcon to="https://backstage.io/docs/features/software-catalog/well-known-annotations" />
</ListSubheader>
<KeyValueList
items={entriesToItems(Object.entries(metadata.annotations!))}
aria-label={t('inspectEntityDialog.overviewPage.annotations')}
className={classes.metadataList}
/>
</>
)}
{!!status.items?.length && (
<Container
title={t('inspectEntityDialog.overviewPage.status.title')}
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>
{!!Object.keys(metadata.labels || {}).length && (
<>
<ListSubheader className={classes.sectionHeading}>
{t('inspectEntityDialog.overviewPage.labels')}
</ListSubheader>
<KeyValueList
items={entriesToItems(Object.entries(metadata.labels!))}
aria-label={t('inspectEntityDialog.overviewPage.labels')}
className={classes.metadataList}
/>
</>
)}
</div>
</>
{!!metadata.tags?.length && (
<>
<ListSubheader className={classes.sectionHeading}>
{t('inspectEntityDialog.overviewPage.tags')}
</ListSubheader>
<TagGroup
aria-label={t('inspectEntityDialog.overviewPage.tags')}
className={classes.tagGroup}
>
{metadata.tags.map(tag => (
<Tag key={tag} id={tag}>
{tag}
</Tag>
))}
</TagGroup>
</>
)}
</Container>
{!!relations.length && (
<Container
title={t('inspectEntityDialog.overviewPage.relation.title')}
helpLink="https://backstage.io/docs/features/software-catalog/well-known-relations"
>
{Object.entries(groupedRelations).map(([type, groupRelations]) => (
<div key={type} className={classes.relationGroup}>
<ListSubheader className={classes.monospace}>
{type}
</ListSubheader>
<ListSection aria-label={type} className={classes.relationList}>
{groupRelations.map(group => (
<ListItemRow key={group.targetRef}>
<EntityRefLink entityRef={group.targetRef} />
</ListItemRow>
))}
</ListSection>
</div>
))}
</Container>
)}
{!!status.items?.length && (
<Container
title={t('inspectEntityDialog.overviewPage.status.title')}
helpLink="https://backstage.io/docs/features/software-catalog/well-known-statuses"
>
{status.items.map((item, index) => (
<div key={index}>
<Text>
{item.level}: {item.type}
</Text>
<Box ml="4">{item.message}</Box>
</div>
))}
</Container>
)}
</Flex>
);
}
@@ -16,7 +16,7 @@
import { Entity } from '@backstage/catalog-model';
import { CodeSnippet } from '@backstage/core-components';
import DialogContentText from '@material-ui/core/DialogContentText';
import { Text } from '@backstage/ui';
import YAML from 'yaml';
import { sortKeys } from './util';
import { catalogReactTranslationRef } from '../../../translation';
@@ -26,21 +26,14 @@ export function YamlPage(props: { entity: Entity }) {
const { t } = useTranslationRef(catalogReactTranslationRef);
return (
<>
<DialogContentText variant="h2">
{t('inspectEntityDialog.yamlPage.title')}
</DialogContentText>
<DialogContentText>
{t('inspectEntityDialog.yamlPage.description')}
</DialogContentText>
<DialogContentText>
<div style={{ fontSize: '75%' }} data-testid="code-snippet">
<CodeSnippet
text={YAML.stringify(sortKeys(props.entity))}
language="yaml"
showCopyCodeButton
/>
</div>
</DialogContentText>
<Text as="p">{t('inspectEntityDialog.yamlPage.description')}</Text>
<div data-testid="code-snippet">
<CodeSnippet
text={YAML.stringify(sortKeys(props.entity))}
language="yaml"
showCopyCodeButton
/>
</div>
</>
);
}
@@ -14,113 +14,57 @@
* limitations under the License.
*/
import { Link } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import ListItem from '@material-ui/core/ListItem';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import MuiListItemText from '@material-ui/core/ListItemText';
import MuiListSubheader from '@material-ui/core/ListSubheader';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import HelpOutlineIcon from '@material-ui/icons/HelpOutline';
import classNames from 'classnames';
import { ReactNode } from 'react';
const useStyles = makeStyles(theme => ({
root: {
const useStyles = makeStyles({
list: {
listStyle: 'none',
margin: 0,
padding: 0,
},
indented: {
paddingLeft: 'var(--bui-space-4)',
},
listItem: {
display: 'flex',
flexDirection: 'column',
},
marginTop: {
marginTop: theme.spacing(2),
},
helpIcon: {
marginLeft: theme.spacing(1),
color: theme.palette.text.disabled,
},
monospace: {
alignItems: 'flex-start',
marginTop: 'var(--bui-space-1)',
paddingLeft: 'var(--bui-space-4)',
fontFamily: 'monospace',
fontSize: 'var(--bui-font-size-3)',
'&:first-child': {
marginTop: 0,
},
},
}));
});
export function ListItemText(props: {
primary: ReactNode;
secondary?: ReactNode;
}) {
const classes = useStyles();
return (
<MuiListItemText
{...props}
primaryTypographyProps={{ className: classes.monospace }}
secondaryTypographyProps={{ className: classes.monospace }}
/>
);
}
export function ListSubheader(props: { children?: ReactNode }) {
const classes = useStyles();
return (
<MuiListSubheader className={classes.monospace}>
{props.children}
</MuiListSubheader>
);
}
export function Container(props: {
title: ReactNode;
helpLink?: string;
export function ListSection(props: {
children: 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>
);
}
// Extracts a link from a value, if possible
function findLink(value: string): string | undefined {
if (value.match(/^url:https?:\/\//)) {
return value.slice('url:'.length);
}
if (value.match(/^https?:\/\//)) {
return value;
}
return undefined;
}
export function KeyValueListItem(props: {
indent?: boolean;
entry: [string, string];
className?: string;
'aria-label'?: string;
}) {
const [key, value] = props.entry;
const link = findLink(value);
return (
<ListItem>
{props.indent && <ListItemIcon />}
<ListItemText
primary={key}
secondary={link ? <Link to={link}>{value}</Link> : value}
/>
</ListItem>
);
}
export function HelpIcon(props: { to: string }) {
const classes = useStyles();
return (
<Link to={props.to} className={classes.helpIcon}>
<HelpOutlineIcon fontSize="inherit" />
</Link>
<ul
className={classNames(
classes.list,
props.indent && classes.indented,
props.className,
)}
aria-label={props['aria-label']}
>
{props.children}
</ul>
);
}
/**
* A dense monospace list item.
*/
export function ListItemRow(props: { children: ReactNode }) {
const classes = useStyles();
return <li className={classes.listItem}>{props.children}</li>;
}
+4
View File
@@ -82,6 +82,7 @@ export const catalogReactTranslationRef = createTranslationRef({
alertNoEntity: 'There were no other entities on this location.',
locationHeader: 'At the same location',
originHeader: 'At the same origin',
entityListAriaLabel: 'Colocated entities',
},
jsonPage: {
title: 'Entity as JSON',
@@ -105,6 +106,9 @@ export const catalogReactTranslationRef = createTranslationRef({
annotations: 'Annotations',
labels: 'Labels',
tags: 'Tags',
copyAriaLabel: 'Copy {{label}}',
copiedStatus: 'Copied',
helpLinkAriaLabel: 'Learn more',
},
yamlPage: {
title: 'Entity as YAML',
+1
View File
@@ -5342,6 +5342,7 @@ __metadata:
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"
"@react-hookz/web": "npm:^24.0.0"
"@remixicon/react": "npm:^4.6.0"
"@testing-library/dom": "npm:^10.0.0"
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^16.0.0"