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:
@@ -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`.
|
||||
@@ -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.';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+13
-15
@@ -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}
|
||||
|
||||
+68
-34
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+103
@@ -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();
|
||||
});
|
||||
});
|
||||
+310
-151
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user