diff --git a/.changeset/witty-tools-cough.md b/.changeset/witty-tools-cough.md new file mode 100644 index 0000000000..71b1a8a453 --- /dev/null +++ b/.changeset/witty-tools-cough.md @@ -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`. diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 04b05330ff..b8b1e73f98 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -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", diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 19f1b2ce3a..8cc0c5a84e 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -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.'; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index 419a3c3f94..befbd1faf8 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -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.'; diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx index 2b047bf762..fa5e87fcd8 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/InspectEntityDialog.tsx @@ -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 ( - - ); -} - -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['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 = { + overview: , + ancestry: , + colocated: , + json: , + yaml: , + }; + + return ( + <> + {t('inspectEntityDialog.title')} + + onSelect?.(key as string)} + > + + {TAB_KEYS.map(tab => ( + + {tabNames[tab]} + + ))} + + {TAB_KEYS.map(tab => ( + + {tabContent[tab]} + + ))} + + + + ); +} /** * 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 ( !isOpen && onClose()} + width="940px" + height="100vh" > - - {t('inspectEntityDialog.title')} - - -
- { - setActiveTab(tabIndex); - props.onSelect?.(tabs[tabIndex]); - }} - aria-label={t('inspectEntityDialog.tabsAriaLabel')} - className={classes.tabs} - > - {tabs.map((tab, index) => ( - - ))} - - - - - - - - - - - - - - - - - -
-
- - - + {open && ( + + )}
); } - -function getTabIndex(allTabs: string[], initialTab: TabKey | undefined) { - return initialTab ? allTabs.indexOf(initialTab) : 0; -} diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx index ec7c0dd54f..d7b36882e8 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/AncestryPage.tsx @@ -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 ( <> - - {t('inspectEntityDialog.ancestryPage.title')} - - - {t('inspectEntityDialog.ancestryPage.description', { - processorsLink: ( - - {t('inspectEntityDialog.ancestryPage.processorsLink')} - - ), - })} - - + + + {t('inspectEntityDialog.ancestryPage.description', { + processorsLink: ( + + {t('inspectEntityDialog.ancestryPage.processorsLink')} + + ), + })} + + + and
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 ( - - {props.header && } - {props.entities.map(entity => ( - - } /> - - ))} - + <> + {props.header && ( +
+

{props.header[0]}

+

{props.header[1]}

+
+ )} + + {props.entities.map(entity => ( + + + + ))} + + ); } @@ -108,15 +146,19 @@ function Contents(props: { entity: Entity }) { if (!location && !originLocation) { return ( - - {t('inspectEntityDialog.colocatedPage.alertNoLocation')} - + ); } else if (!colocatedEntities?.length) { return ( - - {t('inspectEntityDialog.colocatedPage.alertNoEntity')} - + ); } @@ -157,19 +199,11 @@ function Contents(props: { entity: Entity }) { } export function ColocatedPage(props: { entity: Entity }) { - const classes = useStyles(); const { t } = useTranslationRef(catalogReactTranslationRef); return ( <> - - {t('inspectEntityDialog.colocatedPage.title')} - - - {t('inspectEntityDialog.colocatedPage.description')} - -
- -
+ {t('inspectEntityDialog.colocatedPage.description')} + ); } diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx index b5cfdfa219..e238cd0475 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/JsonPage.tsx @@ -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 ( <> - - {t('inspectEntityDialog.jsonPage.title')} - - - {t('inspectEntityDialog.jsonPage.description')} - - -
- -
-
+ {t('inspectEntityDialog.jsonPage.description')} +
+ +
); } diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.test.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.test.tsx new file mode 100644 index 0000000000..077e3c1be5 --- /dev/null +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.test.tsx @@ -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(, { 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(, { 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(, { mountedRoutes }); + + expect(await screen.findByText('java')).toBeInTheDocument(); + expect(screen.getByText('data')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx index 4feabf9c38..c7ff12a486 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx @@ -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 ? {value} : value, + }; + }); +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const { t } = useTranslationRef(catalogReactTranslationRef); + const [copied, setCopied] = useState(false); + const timerRef = useRef>(); + + 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 ( + <> + : } + aria-label={t('inspectEntityDialog.overviewPage.copyAriaLabel', { + label, + })} + variant="tertiary" + size="small" + onPress={handlePress} + /> + + {copied ? t('inspectEntityDialog.overviewPage.copiedStatus') : ''} + + + ); +} + +function HelpIcon(props: { to: string }) { + const { t } = useTranslationRef(catalogReactTranslationRef); + return ( + } + aria-label={t('inspectEntityDialog.overviewPage.helpLinkAriaLabel')} + /> + ); +} + +function Container(props: { + title: ReactNode; + helpLink?: string; + children: ReactNode; +}) { + const classes = useStyles(); + return ( + + + + {props.title} + {props.helpLink && } + + + {props.children} + + ); +} + +function ListSubheader(props: { children: ReactNode; className?: string }) { + const classes = useStyles(); + return ( + + {props.children} + + ); +} + +function KeyValueList(props: { + items: { key: string; value: ReactNode; copyable?: boolean }[]; + className?: string; + 'aria-label'?: string; +}) { + const classes = useStyles(); + return ( +
+ {props.items.map(item => ( +
+
+
{item.key}
+
{item.value}
+
+ {item.copyable && typeof item.value === 'string' && ( +
+ +
+ )} +
+ ))} +
+ ); +} + 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 ( - <> - - {t('inspectEntityDialog.overviewPage.title')} - -
- - - - - - - - - {spec?.type && ( - - - - )} - {metadata.uid && ( - - - - - - - )} - {metadata.etag && ( - - - - - - - )} - - - - - - - - + + + + - - {!!Object.keys(metadata.annotations || {}).length && ( - - {t('inspectEntityDialog.overviewPage.annotations')} - - - } - > - {Object.entries(metadata.annotations!).map(entry => ( - - ))} - - )} - {!!Object.keys(metadata.labels || {}).length && ( - - {t('inspectEntityDialog.overviewPage.labels')} - - } - > - {Object.entries(metadata.labels!).map(entry => ( - - ))} - - )} - {!!metadata.tags?.length && ( - - {t('inspectEntityDialog.overviewPage.tags')} - - } - > - {metadata.tags.map((tag, index) => ( - - - - - ))} - - )} - - - {!!relations.length && ( - - {Object.entries(groupedRelations).map( - ([type, groupRelations], index) => ( -
- {type}}> - {groupRelations.map(group => ( - - - } - /> - - ))} - -
- ), - )} -
+ + {!!Object.keys(metadata.annotations || {}).length && ( + <> + + {t('inspectEntityDialog.overviewPage.annotations')} + + + + )} - - {!!status.items?.length && ( - - {status.items.map((item, index) => ( -
- - {item.level}: {item.type} - - {item.message} -
- ))} -
+ {!!Object.keys(metadata.labels || {}).length && ( + <> + + {t('inspectEntityDialog.overviewPage.labels')} + + + )} -
- + {!!metadata.tags?.length && ( + <> + + {t('inspectEntityDialog.overviewPage.tags')} + + + {metadata.tags.map(tag => ( + + {tag} + + ))} + + + )} + + + {!!relations.length && ( + + {Object.entries(groupedRelations).map(([type, groupRelations]) => ( +
+ + {type} + + + {groupRelations.map(group => ( + + + + ))} + +
+ ))} +
+ )} + + {!!status.items?.length && ( + + {status.items.map((item, index) => ( +
+ + {item.level}: {item.type} + + {item.message} +
+ ))} +
+ )} + ); } diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx index 6763707810..e04fa3ae0b 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/YamlPage.tsx @@ -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 ( <> - - {t('inspectEntityDialog.yamlPage.title')} - - - {t('inspectEntityDialog.yamlPage.description')} - - -
- -
-
+ {t('inspectEntityDialog.yamlPage.description')} +
+ +
); } diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx index ff8a66e10d..b335ff8e9f 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/common.tsx @@ -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 ( - - ); -} - -export function ListSubheader(props: { children?: ReactNode }) { - const classes = useStyles(); - return ( - - {props.children} - - ); -} - -export function Container(props: { - title: ReactNode; - helpLink?: string; +export function ListSection(props: { children: ReactNode; -}) { - return ( - - - - - {props.title} - {props.helpLink && } - - {props.children} - - - - ); -} - -// 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 ( - - {props.indent && } - {value} : value} - /> - - ); -} - -export function HelpIcon(props: { to: string }) { const classes = useStyles(); return ( - - - +
    + {props.children} +
); } + +/** + * A dense monospace list item. + */ +export function ListItemRow(props: { children: ReactNode }) { + const classes = useStyles(); + return
  • {props.children}
  • ; +} diff --git a/plugins/catalog-react/src/translation.ts b/plugins/catalog-react/src/translation.ts index db0c796424..b9a43eb1cf 100644 --- a/plugins/catalog-react/src/translation.ts +++ b/plugins/catalog-react/src/translation.ts @@ -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', diff --git a/yarn.lock b/yarn.lock index 23863cdf4e..f1edc76f1d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"