Merge pull request #33082 from backstage/bui-migrate-catalog-entity-cards

feat(catalog,org): migrate entity cards from MUI/InfoCard to BUI
This commit is contained in:
Patrik Oldsberg
2026-03-04 11:50:41 +01:00
committed by GitHub
33 changed files with 528 additions and 373 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---
Added `EntityInfoCard` component to `@backstage/plugin-catalog-react` as a BUI-based card wrapper for entity page cards.
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/plugin-catalog': major
'@backstage/plugin-org': minor
---
Migrated `EntityAboutCard`, `EntityLinksCard`, `EntityLabelsCard`, `GroupProfileCard`, and `UserProfileCard` from MUI/InfoCard to use the new BUI card layout and BUI components where possible.
**BREAKING**: Removed `variant` prop from EntityAboutCard, EntityUserProfileCard, EntityGroupProfileCard, EntityLabelsCard, EntityLinksCard. Removed `gridSizes` prop from `AboutField`.
**Migration:**
Simply delete the obsolete `variant` and `gridSizes` props, e.g:
```diff
- <EntityAboutCard variant="gridItem" />
+ <EntityAboutCard />
```
```diff
- <AboutField label="Owner" gridSizes={{ xs: 12, sm: 6, lg: 4 }} />
+ <AboutField label="Owner" />
```
@@ -94,7 +94,7 @@ const systemPage = (
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6}>
<EntityHasComponentsCard variant="gridItem" />
@@ -166,7 +166,7 @@ const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6} xs={12}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
@@ -330,7 +330,7 @@ const userPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityUserProfileCard variant="gridItem" />
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
@@ -349,7 +349,7 @@ const groupPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
@@ -374,7 +374,7 @@ const systemPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -418,7 +418,7 @@ const domainPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -437,7 +437,7 @@ const resourcePage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -156,7 +156,7 @@ describe('collectLegacyRoutes', () => {
if={isKind('component')}
children={
<EntityLayout>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</EntityLayout>
}
/>
@@ -128,7 +128,7 @@ const overviewContent = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -298,7 +298,7 @@ const userPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityUserProfileCard variant="gridItem" />
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard variant="gridItem" />
@@ -314,7 +314,7 @@ const groupPage = (
<Grid container spacing={3}>
{entityWarningContent}
<Grid item xs={12} md={6}>
<EntityGroupProfileCard variant="gridItem" />
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard variant="gridItem" />
@@ -336,7 +336,7 @@ const systemPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
@@ -383,7 +383,7 @@ const domainPage = (
<Grid container spacing={3} alignItems="stretch">
{entityWarningContent}
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
+17
View File
@@ -277,6 +277,23 @@ export type EntityFilter = {
toQueryValue?: () => string | string[];
};
// @public (undocumented)
export function EntityInfoCard(props: EntityInfoCardProps): JSX_2.Element;
// @public (undocumented)
export interface EntityInfoCardProps {
// (undocumented)
children?: ReactNode;
// (undocumented)
className?: string;
// (undocumented)
footerActions?: ReactNode;
// (undocumented)
headerActions?: ReactNode;
// (undocumented)
title?: ReactNode;
}
// @public
export class EntityKindFilter implements EntityFilter {
constructor(value: string, label: string);
@@ -0,0 +1,102 @@
/*
* 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 { EntityInfoCard } from './EntityInfoCard';
describe('<EntityInfoCard />', () => {
it('renders children in the card body', async () => {
await renderInTestApp(
<EntityInfoCard>
<div>Body content</div>
</EntityInfoCard>,
);
expect(screen.getByText('Body content')).toBeInTheDocument();
});
it('renders the title when provided', async () => {
await renderInTestApp(
<EntityInfoCard title="My Card Title">
<div>Body</div>
</EntityInfoCard>,
);
expect(
screen.getByRole('heading', { name: 'My Card Title' }),
).toBeInTheDocument();
});
it('does not render a heading when title is not provided', async () => {
await renderInTestApp(
<EntityInfoCard>
<div>Body</div>
</EntityInfoCard>,
);
expect(screen.queryByRole('heading')).not.toBeInTheDocument();
});
it('renders header actions next to the title', async () => {
await renderInTestApp(
<EntityInfoCard title="Title" headerActions={<button>Edit</button>}>
<div>Body</div>
</EntityInfoCard>,
);
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument();
});
it('renders footer actions when provided', async () => {
await renderInTestApp(
<EntityInfoCard title="Title" footerActions={<button>Next Page</button>}>
<div>Body</div>
</EntityInfoCard>,
);
expect(
screen.getByRole('button', { name: 'Next Page' }),
).toBeInTheDocument();
});
it('does not render footer when footerActions is not provided', async () => {
const { container } = await renderInTestApp(
<EntityInfoCard title="Title">
<div>Body</div>
</EntityInfoCard>,
);
expect(container.querySelector('.bui-CardFooter')).not.toBeInTheDocument();
});
it('renders JSX titles (e.g., icon + text)', async () => {
await renderInTestApp(
<EntityInfoCard
title={
<span>
<span aria-hidden>🏠</span> Home Card
</span>
}
>
<div>Body</div>
</EntityInfoCard>,
);
expect(screen.getByText('Home Card')).toBeInTheDocument();
});
});
@@ -0,0 +1,69 @@
/*
* 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 { ReactNode } from 'react';
import {
Card,
CardHeader,
CardBody,
CardFooter,
Text,
Flex,
} from '@backstage/ui';
import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
const useStyles = makeStyles({
root: {
height: '100%',
},
});
/** @public */
export interface EntityInfoCardProps {
title?: ReactNode;
headerActions?: ReactNode;
footerActions?: ReactNode;
children?: ReactNode;
className?: string;
}
/** @public */
export function EntityInfoCard(props: EntityInfoCardProps) {
const { title, headerActions, footerActions, children, className } = props;
const classes = useStyles();
return (
<Card className={classNames(classes.root, className)}>
{title && (
<CardHeader>
<Flex justify="between" align="center">
<Text as="h3" variant="title-x-small" weight="bold">
{title}
</Text>
{headerActions && (
<Flex align="center" gap="1">
{headerActions}
</Flex>
)}
</Flex>
</CardHeader>
)}
<CardBody>{children}</CardBody>
{footerActions && <CardFooter>{footerActions}</CardFooter>}
</Card>
);
}
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityInfoCard } from './EntityInfoCard';
export type { EntityInfoCardProps } from './EntityInfoCard';
@@ -35,3 +35,4 @@ export * from './EntityProcessingStatusPicker';
export * from './EntityNamespacePicker';
export * from './EntityAutocompletePicker';
export * from './MissingAnnotationEmptyState';
export * from './EntityInfoCard';
+2
View File
@@ -96,6 +96,8 @@ export const catalogTranslationRef: TranslationRef<
readonly 'entityContextMenu.moreButtonAriaLabel': 'more';
readonly 'entityLabelsCard.title': 'Labels';
readonly 'entityLabelsCard.readMoreButtonTitle': 'Read more';
readonly 'entityLabelsCard.columnKeyLabel': 'Label';
readonly 'entityLabelsCard.columnValueLabel': 'Value';
readonly 'entityLabelsCard.emptyDescription': 'No labels defined for this entity. You can add labels to your entity YAML as shown in the highlighted example below:';
readonly 'entityLabels.ownerLabel': 'Owner';
readonly 'entityLabels.warningPanelTitle': 'Entity not found';
+1 -12
View File
@@ -40,11 +40,6 @@ import { TableProps } from '@backstage/core-components';
import { TabProps } from '@material-ui/core/Tab';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// @public
export type AboutCardProps = {
variant?: InfoCardVariants;
};
// @public (undocumented)
export function AboutContent(props: AboutContentProps): JSX_2.Element;
@@ -64,8 +59,6 @@ export interface AboutFieldProps {
// (undocumented)
className?: string;
// (undocumented)
gridSizes?: Record<string, number>;
// (undocumented)
label: string;
// (undocumented)
value?: string;
@@ -334,7 +327,7 @@ export interface DependsOnResourcesCardProps {
}
// @public
export const EntityAboutCard: (props: AboutCardProps) => JSX.Element;
export const EntityAboutCard: () => JSX.Element;
// @public (undocumented)
export type EntityContextMenuClassKey = 'button';
@@ -384,8 +377,6 @@ export const EntityLabelsCard: (props: EntityLabelsCardProps) => JSX_2.Element;
export interface EntityLabelsCardProps {
// (undocumented)
title?: string;
// (undocumented)
variant?: InfoCardVariants;
}
// @public (undocumented)
@@ -435,8 +426,6 @@ export const EntityLinksCard: (props: EntityLinksCardProps) => JSX_2.Element;
export interface EntityLinksCardProps {
// (undocumented)
cols?: ColumnBreakpoints | number;
// (undocumented)
variant?: InfoCardVariants;
}
// @public (undocumented)
+3 -7
View File
@@ -66,9 +66,7 @@ export const catalogAboutEntityCard = EntityCardBlueprint.makeWithOverrides({
const { InternalAboutCard } = await import(
'../components/AboutCard/AboutCard'
);
return (
<InternalAboutCard variant="gridItem" subheader={<Subheader />} />
);
return <InternalAboutCard iconLinks={<Subheader />} />;
},
});
},
@@ -80,9 +78,7 @@ export const catalogLinksEntityCard = EntityCardBlueprint.make({
type: 'info',
filter: { 'metadata.links': { $exists: true } },
loader: async () =>
import('../components/EntityLinksCard').then(m => (
<m.EntityLinksCard variant="gridItem" />
)),
import('../components/EntityLinksCard').then(m => <m.EntityLinksCard />),
},
});
@@ -93,7 +89,7 @@ export const catalogLabelsEntityCard = EntityCardBlueprint.make({
filter: { 'metadata.labels': { $exists: true } },
loader: async () =>
import('../components/EntityLabelsCard').then(m => (
<m.EntityLabelsCard variant="gridItem" />
<m.EntityLabelsCard />
)),
},
});
+2
View File
@@ -111,6 +111,8 @@ export const catalogTranslationRef = createTranslationRef({
},
entityLabelsCard: {
title: 'Labels',
columnKeyLabel: 'Label',
columnValueLabel: 'Value',
emptyDescription:
'No labels defined for this entity. You can add labels to your entity YAML as shown in the highlighted example below:',
readMoreButtonTitle: 'Read more',
@@ -16,11 +16,6 @@
import { useCallback } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Divider from '@material-ui/core/Divider';
import IconButton from '@material-ui/core/IconButton';
import CachedIcon from '@material-ui/icons/Cached';
import EditIcon from '@material-ui/icons/Edit';
@@ -31,9 +26,9 @@ import {
AppIcon,
HeaderIconLinkRow,
IconLinkVerticalProps,
InfoCardVariants,
Link,
} from '@backstage/core-components';
import { EntityInfoCard } from '@backstage/plugin-catalog-react';
import {
alertApiRef,
errorApiRef,
@@ -77,6 +72,16 @@ import { createFromTemplateRouteRef, viewTechDocRouteRef } from '../../routes';
import { catalogTranslationRef } from '../../alpha/translation';
import { useSourceTemplateCompoundEntityRef } from './hooks';
import { AboutContent } from './AboutContent';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles({
linkContainer: {
border: '1px solid var(--bui-border-1)',
borderLeft: 'none',
borderRight: 'none',
marginBottom: 'var(--bui-space-6)',
},
});
export function useCatalogSourceIconLinkProps() {
const { entity } = useEntity();
@@ -152,41 +157,13 @@ function DefaultAboutCardSubheader() {
return <HeaderIconLinkRow links={links} />;
}
const useStyles = makeStyles({
gridItemCard: {
display: 'flex',
flexDirection: 'column',
height: 'calc(100% - 10px)', // for pages without content header
marginBottom: '10px',
},
fullHeightCard: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
gridItemCardContent: {
flex: 1,
},
fullHeightCardContent: {
flex: 1,
},
});
/**
* Props for {@link EntityAboutCard}.
*
* @public
*/
export type AboutCardProps = {
variant?: InfoCardVariants;
};
export interface InternalAboutCardProps extends AboutCardProps {
subheader?: JSX.Element;
export interface InternalAboutCardProps {
/** Icon link row rendered at the top of the card body. */
iconLinks?: JSX.Element;
}
export function InternalAboutCard(props: InternalAboutCardProps) {
const { variant, subheader } = props;
const { iconLinks } = props;
const classes = useStyles();
const { entity } = useEntity();
const catalogApi = useApi(catalogApiRef);
@@ -202,20 +179,6 @@ export function InternalAboutCard(props: InternalAboutCardProps) {
const entityMetadataEditUrl =
entity.metadata.annotations?.[ANNOTATION_EDIT_URL];
let cardClass = '';
if (variant === 'gridItem') {
cardClass = classes.gridItemCard;
} else if (variant === 'fullHeight') {
cardClass = classes.fullHeightCard;
}
let cardContentClass = '';
if (variant === 'gridItem') {
cardContentClass = classes.gridItemCardContent;
} else if (variant === 'fullHeight') {
cardContentClass = classes.fullHeightCardContent;
}
const entityLocation = entity.metadata.annotations?.[ANNOTATION_LOCATION];
// Limiting the ability to manually refresh to the less expensive locations
const allowRefresh =
@@ -234,60 +197,58 @@ export function InternalAboutCard(props: InternalAboutCardProps) {
}, [catalogApi, entity, alertApi, t, errorApi]);
return (
<Card className={cardClass}>
<CardHeader
title={t('aboutCard.title')}
action={
<>
{allowRefresh && canRefresh && (
<IconButton
aria-label={t('aboutCard.refreshButtonAriaLabel')}
title={t('aboutCard.refreshButtonTitle')}
onClick={refreshEntity}
>
<CachedIcon />
</IconButton>
)}
<EntityInfoCard
title={t('aboutCard.title')}
headerActions={
<>
{allowRefresh && canRefresh && (
<IconButton
aria-label={t('aboutCard.refreshButtonAriaLabel')}
title={t('aboutCard.refreshButtonTitle')}
onClick={refreshEntity}
>
<CachedIcon />
</IconButton>
)}
<IconButton
component={Link}
aria-label={t('aboutCard.editButtonAriaLabel')}
disabled={!entityMetadataEditUrl}
title={t('aboutCard.editButtonTitle')}
to={entityMetadataEditUrl ?? '#'}
>
<EditIcon />
</IconButton>
{sourceTemplateRef && templateRoute && (
<IconButton
component={Link}
aria-label={t('aboutCard.editButtonAriaLabel')}
disabled={!entityMetadataEditUrl}
title={t('aboutCard.editButtonTitle')}
to={entityMetadataEditUrl ?? '#'}
title={t('aboutCard.createSimilarButtonTitle')}
to={templateRoute({
namespace: sourceTemplateRef.namespace,
templateName: sourceTemplateRef.name,
})}
>
<EditIcon />
<AppIcon id="scaffolder" />
</IconButton>
{sourceTemplateRef && templateRoute && (
<IconButton
component={Link}
title={t('aboutCard.createSimilarButtonTitle')}
to={templateRoute({
namespace: sourceTemplateRef.namespace,
templateName: sourceTemplateRef.name,
})}
>
<AppIcon id="scaffolder" />
</IconButton>
)}
</>
}
subheader={subheader ?? <DefaultAboutCardSubheader />}
/>
<Divider />
<CardContent className={cardContentClass}>
<AboutContent entity={entity} />
</CardContent>
</Card>
)}
</>
}
>
<div className={classes.linkContainer}>
{iconLinks ?? <DefaultAboutCardSubheader />}
</div>
<AboutContent entity={entity} />
</EntityInfoCard>
);
}
/**
* Exported publicly via the EntityAboutCard
*
* NOTE: We generally do not accept pull requests to extend this class with more
* props and customizability. If you need to tweak it, consider making a bespoke
* card in your own repository instead, that is perfect for your own needs.
* NOTE: We generally do not accept pull requests to extend this class with props
* and customizability. If you need to tweak it, consider making a bespoke card
* in your own repository instead, that is perfect for your own needs.
*/
export function AboutCard(props: AboutCardProps) {
return <InternalAboutCard {...props} />;
export function AboutCard() {
return <InternalAboutCard />;
}
@@ -26,8 +26,8 @@ import {
} from '@backstage/plugin-catalog-react';
import { JsonArray } from '@backstage/types';
import Chip from '@material-ui/core/Chip';
import Grid from '@material-ui/core/Grid';
import { makeStyles } from '@material-ui/core/styles';
import { Grid } from '@backstage/ui';
import { MarkdownContent } from '@backstage/core-components';
import { AboutField } from './AboutField';
import { LinksGridList } from '../EntityLinksCard/LinksGridList';
@@ -114,25 +114,25 @@ export function AboutContent(props: AboutContentProps) {
entitySourceLocation = undefined;
}
const columns = { initial: '1', sm: '2', lg: '3' } as const;
return (
<Grid container>
<AboutField
label={t('aboutCard.descriptionField.label')}
gridSizes={{ xs: 12 }}
>
<MarkdownContent
className={classes.description}
content={
entity?.metadata?.description ||
t('aboutCard.descriptionField.value')
}
/>
</AboutField>
<Grid.Root columns={columns} gap="3">
<Grid.Item colSpan={columns}>
<AboutField label={t('aboutCard.descriptionField.label')}>
<MarkdownContent
className={classes.description}
content={
entity?.metadata?.description ||
t('aboutCard.descriptionField.value')
}
/>
</AboutField>
</Grid.Item>
<AboutField
label={t('aboutCard.ownerField.label')}
value={t('aboutCard.ownerField.value')}
className={classes.description}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{ownedByRelations.length > 0 && (
<EntityRefLinks entityRefs={ownedByRelations} defaultKind="group" />
@@ -142,7 +142,6 @@ export function AboutContent(props: AboutContentProps) {
<AboutField
label={t('aboutCard.domainField.label')}
value={t('aboutCard.domainField.value')}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{partOfDomainRelations.length > 0 && (
<EntityRefLinks
@@ -159,7 +158,6 @@ export function AboutContent(props: AboutContentProps) {
<AboutField
label={t('aboutCard.systemField.label')}
value={t('aboutCard.systemField.value')}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{partOfSystemRelations.length > 0 && (
<EntityRefLinks
@@ -173,7 +171,6 @@ export function AboutContent(props: AboutContentProps) {
<AboutField
label={t('aboutCard.parentComponentField.label')}
value={t('aboutCard.parentComponentField.value')}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
<EntityRefLinks
entityRefs={partOfComponentRelations}
@@ -191,7 +188,6 @@ export function AboutContent(props: AboutContentProps) {
<AboutField
label={t('aboutCard.typeField.label')}
value={entity?.spec?.type as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
)}
{(isAPI ||
@@ -200,38 +196,37 @@ export function AboutContent(props: AboutContentProps) {
<AboutField
label={t('aboutCard.lifecycleField.label')}
value={entity?.spec?.lifecycle as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
)}
<AboutField
label={t('aboutCard.tagsField.label')}
value={t('aboutCard.tagsField.value')}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{(entity?.metadata?.tags || []).map(tag => (
<Chip key={tag} size="small" label={tag} />
))}
</AboutField>
{isLocation && (entity?.spec?.targets || entity?.spec?.target) && (
<AboutField
label={t('aboutCard.targetsField.label')}
gridSizes={{ xs: 12 }}
>
<LinksGridList
cols={1}
items={((entity.spec.targets as JsonArray) || [entity.spec.target])
.map(target => target as string)
.map(target => ({
text: target,
href: getLocationTargetHref(
target,
(entity?.spec?.type || t('aboutCard.unknown')) as string,
entitySourceLocation!,
),
}))}
/>
</AboutField>
<Grid.Item colSpan={columns}>
<AboutField label={t('aboutCard.targetsField.label')}>
<LinksGridList
cols={1}
items={(
(entity.spec.targets as JsonArray) || [entity.spec.target]
)
.map(target => target as string)
.map(target => ({
text: target,
href: getLocationTargetHref(
target,
(entity?.spec?.type || t('aboutCard.unknown')) as string,
entitySourceLocation!,
),
}))}
/>
</AboutField>
</Grid.Item>
)}
</Grid>
</Grid.Root>
);
}
@@ -15,7 +15,6 @@
*/
import { useElementFilter } from '@backstage/core-plugin-api';
import Grid from '@material-ui/core/Grid';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import { ReactNode } from 'react';
@@ -48,14 +47,13 @@ const useStyles = makeStyles(theme => ({
export interface AboutFieldProps {
label: string;
value?: string;
gridSizes?: Record<string, number>;
children?: ReactNode;
className?: string;
}
/** @public */
export function AboutField(props: AboutFieldProps) {
const { label, value, gridSizes, children, className } = props;
const { label, value, children, className } = props;
const classes = useStyles();
const { t } = useTranslationRef(catalogTranslationRef);
@@ -71,11 +69,11 @@ export function AboutField(props: AboutFieldProps) {
</Typography>
);
return (
<Grid item {...gridSizes} className={className}>
<div className={className}>
<Typography variant="h2" className={classes.label}>
{label}
</Typography>
{content}
</Grid>
</div>
);
}
@@ -15,7 +15,6 @@
*/
export { AboutCard } from './AboutCard';
export type { AboutCardProps } from './AboutCard';
export { AboutContent } from './AboutContent';
export type { AboutContentProps } from './AboutContent';
export { AboutField } from './AboutField';
@@ -14,77 +14,77 @@
* limitations under the License.
*/
import { useEntity } from '@backstage/plugin-catalog-react';
import {
InfoCard,
InfoCardVariants,
Table,
TableColumn,
} from '@backstage/core-components';
import { useEntity, EntityInfoCard } from '@backstage/plugin-catalog-react';
import { EntityLabelsEmptyState } from './EntityLabelsEmptyState';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import {
Table,
CellText,
useTable,
type ColumnConfig,
type TableItem,
} from '@backstage/ui';
import { catalogTranslationRef } from '../../alpha/translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { useMemo } from 'react';
/** @public */
export interface EntityLabelsCardProps {
variant?: InfoCardVariants;
title?: string;
}
const useStyles = makeStyles(_ => ({
key: {
fontWeight: 'bold',
},
}));
interface LabelItem extends TableItem {
id: string;
key: string;
value: string;
}
export const EntityLabelsCard = (props: EntityLabelsCardProps) => {
const { variant, title } = props;
const { title } = props;
const { entity } = useEntity();
const classes = useStyles();
const { t } = useTranslationRef(catalogTranslationRef);
const columns: TableColumn<{ key: string; value: string }>[] = [
{
render: row => {
return (
<Typography className={classes.key} variant="body2">
{row.key}
</Typography>
);
},
},
{
field: 'value',
},
];
const labels = entity?.metadata?.labels;
const columnConfig: ColumnConfig<LabelItem>[] = useMemo(
() => [
{
id: 'key',
label: t('entityLabelsCard.columnKeyLabel'),
isRowHeader: true,
cell: item => <CellText title={item.key} />,
},
{
id: 'value',
label: t('entityLabelsCard.columnValueLabel'),
cell: item => <CellText title={item.value} />,
},
],
[t],
);
const data = useMemo(
() =>
Object.keys(labels ?? {}).map(labelKey => ({
id: labelKey,
key: labelKey,
value: labels![labelKey],
})),
[labels],
);
const { tableProps } = useTable({
mode: 'complete',
data,
paginationOptions: { pageSize: 5 },
});
return (
<InfoCard title={title || t('entityLabelsCard.title')} variant={variant}>
<EntityInfoCard title={title || t('entityLabelsCard.title')}>
{!labels || Object.keys(labels).length === 0 ? (
<EntityLabelsEmptyState />
) : (
<Table
columns={columns}
data={Object.keys(labels).map(labelKey => ({
key: labelKey,
value: labels[labelKey],
}))}
options={{
search: false,
showTitle: true,
loadingType: 'linear',
header: false,
padding: 'dense',
pageSize: 5,
toolbar: false,
paging: Object.keys(labels).length > 5,
}}
/>
<Table columnConfig={columnConfig} {...tableProps} />
)}
</InfoCard>
</EntityInfoCard>
);
};
@@ -14,24 +14,22 @@
* limitations under the License.
*/
import { useEntity } from '@backstage/plugin-catalog-react';
import { useEntity, EntityInfoCard } from '@backstage/plugin-catalog-react';
import LanguageIcon from '@material-ui/icons/Language';
import { EntityLinksEmptyState } from './EntityLinksEmptyState';
import { LinksGridList } from './LinksGridList';
import { ColumnBreakpoints } from './types';
import { IconComponent, useApp } from '@backstage/core-plugin-api';
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { catalogTranslationRef } from '../../alpha/translation';
/** @public */
export interface EntityLinksCardProps {
cols?: ColumnBreakpoints | number;
variant?: InfoCardVariants;
}
export const EntityLinksCard = (props: EntityLinksCardProps) => {
const { cols = undefined, variant } = props;
const { cols = undefined } = props;
const { entity } = useEntity();
const app = useApp();
const { t } = useTranslationRef(catalogTranslationRef);
@@ -42,7 +40,7 @@ export const EntityLinksCard = (props: EntityLinksCardProps) => {
const links = entity?.metadata?.links;
return (
<InfoCard title={t('entityLinksCard.title')} variant={variant}>
<EntityInfoCard title={t('entityLinksCard.title')}>
{!links || links.length === 0 ? (
<EntityLinksEmptyState />
) : (
@@ -55,6 +53,6 @@ export const EntityLinksCard = (props: EntityLinksCardProps) => {
}))}
/>
)}
</InfoCard>
</EntityInfoCard>
);
};
@@ -14,8 +14,7 @@
* limitations under the License.
*/
import ImageList from '@material-ui/core/ImageList';
import ImageListItem from '@material-ui/core/ImageListItem';
import { Grid, type Columns } from '@backstage/ui';
import { IconLink } from './IconLink';
import { ColumnBreakpoints } from './types';
import { useDynamicColumns } from './useDynamicColumns';
@@ -37,12 +36,13 @@ export function LinksGridList(props: LinksGridListProps) {
const numOfCols = useDynamicColumns(cols);
return (
<ImageList rowHeight="auto" cols={numOfCols}>
<Grid.Root
columns={String(Math.min(Math.max(numOfCols, 1), 12)) as Columns}
gap="2"
>
{items.map(({ text, href, Icon }, i) => (
<ImageListItem key={i}>
<IconLink href={href} text={text ?? href} Icon={Icon} />
</ImageListItem>
<IconLink key={i} href={href} text={text ?? href} Icon={Icon} />
))}
</ImageList>
</Grid.Root>
);
}
-1
View File
@@ -23,7 +23,6 @@
export * from './apis';
export type {
AboutCardProps,
AboutContentProps,
AboutFieldProps,
} from './components/AboutCard';
+8 -10
View File
@@ -43,7 +43,6 @@ import {
SearchResultListItemExtensionProps,
} from '@backstage/plugin-search-react';
import { DefaultStarredEntitiesApi } from './apis';
import { AboutCardProps } from './components/AboutCard';
import { DefaultCatalogPageProps } from './components/CatalogPage';
import { DependencyOfComponentsCardProps } from './components/DependencyOfComponentsCard';
import { DependsOnComponentsCardProps } from './components/DependsOnComponentsCard';
@@ -128,15 +127,14 @@ export const CatalogEntityPage: () => JSX.Element = catalogPlugin.provide(
* not extremely customizable; feel free to make a copy of it as a starting
* point if you like.
*/
export const EntityAboutCard: (props: AboutCardProps) => JSX.Element =
catalogPlugin.provide(
createComponentExtension({
name: 'EntityAboutCard',
component: {
lazy: () => import('./components/AboutCard').then(m => m.AboutCard),
},
}),
);
export const EntityAboutCard: () => JSX.Element = catalogPlugin.provide(
createComponentExtension({
name: 'EntityAboutCard',
component: {
lazy: () => import('./components/AboutCard').then(m => m.AboutCard),
},
}),
);
/** @public */
export const EntityLinksCard = catalogPlugin.provide(
+1
View File
@@ -57,6 +57,7 @@
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
-4
View File
@@ -18,7 +18,6 @@ export type ComponentsGridClassKey =
// @public (undocumented)
export const EntityGroupProfileCard: (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
}) => JSX_2.Element;
@@ -48,7 +47,6 @@ export type EntityRelationAggregation = 'direct' | 'aggregated';
// @public (undocumented)
export const EntityUserProfileCard: (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
maxRelations?: number;
hideIcons?: boolean;
@@ -56,7 +54,6 @@ export const EntityUserProfileCard: (props: {
// @public (undocumented)
export const GroupProfileCard: (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
}) => JSX_2.Element;
@@ -116,7 +113,6 @@ export type OwnershipCardClassKey =
// @public (undocumented)
export const UserProfileCard: (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
maxRelations?: number;
hideIcons?: boolean;
@@ -22,13 +22,7 @@ import {
RELATION_PARENT_OF,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
Avatar,
InfoCard,
InfoCardVariants,
Link,
} from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import { Link } from '@backstage/core-components';
import IconButton from '@material-ui/core/IconButton';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
@@ -36,6 +30,7 @@ import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import Tooltip from '@material-ui/core/Tooltip';
import {
EntityInfoCard,
EntityRefLinks,
catalogApiRef,
getEntityRelations,
@@ -57,6 +52,7 @@ import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { orgTranslationRef } from '../../../../translation';
import { makeStyles } from '@material-ui/core/styles';
import { Avatar, Flex, Box, Text } from '@backstage/ui';
const useStyles = makeStyles(theme => ({
container: {
@@ -71,18 +67,21 @@ const useStyles = makeStyles(theme => ({
},
}));
const CardTitle = (props: { title: string }) => (
<Box display="flex" alignItems="center">
<GroupIcon fontSize="inherit" />
<Box ml={1}>{props.title}</Box>
</Box>
const CardTitle = (props: { title: string; pictureSrc?: string }) => (
<Flex align="center" gap="2">
<Avatar
size="small"
purpose="decoration"
name={props.title}
src={props.pictureSrc || ''}
/>
{props.title}
</Flex>
);
/** @public */
export const GroupProfileCard = (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
}) => {
export const GroupProfileCard = (props: { showLinks?: boolean }) => {
const { showLinks } = props;
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const { entity: group } = useEntity<GroupEntity>();
@@ -148,11 +147,9 @@ export const GroupProfileCard = (props: {
);
return (
<InfoCard
title={<CardTitle title={displayName} />}
subheader={description}
variant={props.variant}
action={
<EntityInfoCard
title={<CardTitle title={displayName} pictureSrc={profile?.picture} />}
headerActions={
<>
{allowRefresh && canRefresh && (
<IconButton
@@ -167,8 +164,8 @@ export const GroupProfileCard = (props: {
</>
}
>
<Box className={classes.container}>
<Avatar displayName={displayName} picture={profile?.picture} />
{description && <Text color="secondary">{description}</Text>}
<Box p="2">
<List className={classes.list}>
<ListItem>
<ListItemIcon>
@@ -234,9 +231,9 @@ export const GroupProfileCard = (props: {
secondary={t('groupProfileCard.listItemTitle.childGroups')}
/>
</ListItem>
{props?.showLinks && <LinksGroup links={links} />}
{showLinks && <LinksGroup links={links} />}
</List>
</Box>
</InfoCard>
</EntityInfoCard>
);
};
@@ -55,7 +55,7 @@ export const Default = () => (
<EntityProvider entity={defaultEntity}>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</Grid>
</Grid>
</EntityProvider>
@@ -82,7 +82,7 @@ export const NoImage = () => (
<EntityProvider entity={noImageEntity}>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</Grid>
</Grid>
</EntityProvider>
@@ -134,7 +134,7 @@ export const ExtraDetails = () => (
<EntityProvider entity={extraDetailsEntity}>
<Grid container spacing={4}>
<Grid item xs={12} md={4}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</Grid>
</Grid>
</EntityProvider>
@@ -51,7 +51,7 @@ describe('UserSummary Test', () => {
it('Display Profile Card', async () => {
await renderInTestApp(
<EntityProvider entity={userEntity}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</EntityProvider>,
{
mountedRoutes: {
@@ -61,10 +61,10 @@ describe('UserSummary Test', () => {
);
expect(screen.getByText('calum-leavy@example.com')).toBeInTheDocument();
expect(screen.getByAltText('Calum Leavy')).toHaveAttribute(
'src',
'https://example.com/staff/calum.jpeg',
);
// BUI Avatar is decorative (aria-hidden), because the name is
// present as text right beside it.
expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument();
expect(screen.getByText('Calum Leavy')).toBeInTheDocument();
expect(screen.getByText('examplegroup').closest('a')).toHaveAttribute(
'href',
'/catalog/default/group/examplegroup',
@@ -87,7 +87,7 @@ describe('UserSummary Test', () => {
it('Should limit the number of displayed groups in the card', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={3} />
<UserProfileCard maxRelations={3} />
</EntityProvider>,
{
mountedRoutes: {
@@ -109,7 +109,7 @@ describe('UserSummary Test', () => {
it('Show more groups button when there are more user relations than the maximum', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={3} />
<UserProfileCard maxRelations={3} />
</EntityProvider>,
{
mountedRoutes: {
@@ -128,7 +128,7 @@ describe('UserSummary Test', () => {
it('Hide more groups button when limit value is less than or equal to user relations', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={5} />
<UserProfileCard maxRelations={5} />
</EntityProvider>,
{
mountedRoutes: {
@@ -148,7 +148,7 @@ describe('UserSummary Test', () => {
it('Hide all groups when max relations is equals to zero', async () => {
await renderInTestApp(
<EntityProvider entity={entity}>
<UserProfileCard variant="gridItem" maxRelations={0} />
<UserProfileCard maxRelations={0} />
</EntityProvider>,
{
mountedRoutes: {
@@ -193,7 +193,7 @@ describe('Edit Button', () => {
await renderInTestApp(
<EntityProvider entity={userEntity}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</EntityProvider>,
{
mountedRoutes: {
@@ -234,7 +234,7 @@ describe('Edit Button', () => {
await renderInTestApp(
<EntityProvider entity={userEntity}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</EntityProvider>,
{
mountedRoutes: {
@@ -285,7 +285,7 @@ describe('Edit Button', () => {
await renderInTestApp(
<EntityProvider entity={userEntity}>
<UserProfileCard variant="gridItem" />
<UserProfileCard />
</EntityProvider>,
{
mountedRoutes: {
@@ -337,7 +337,7 @@ describe('Edit Button', () => {
await renderInTestApp(
<EntityProvider entity={userEntity}>
<UserProfileCard showLinks variant="gridItem" />
<UserProfileCard showLinks />
</EntityProvider>,
{
mountedRoutes: {
@@ -19,15 +19,10 @@ import {
RELATION_MEMBER_OF,
UserEntity,
} from '@backstage/catalog-model';
import {
Avatar,
InfoCard,
InfoCardVariants,
Link,
} from '@backstage/core-components';
import { Link } from '@backstage/core-components';
import { EntityInfoCard } from '@backstage/plugin-catalog-react';
import { Avatar, Box, Flex, Text } from '@backstage/ui';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import BaseButton from '@material-ui/core/ButtonBase';
import IconButton from '@material-ui/core/IconButton';
import List from '@material-ui/core/List';
@@ -52,7 +47,6 @@ import EditIcon from '@material-ui/icons/Edit';
import EmailIcon from '@material-ui/icons/Email';
import GroupIcon from '@material-ui/icons/Group';
import { LinksGroup } from '../../Meta';
import PersonIcon from '@material-ui/icons/Person';
import { useCallback, useState } from 'react';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
@@ -84,17 +78,21 @@ const useStyles = makeStyles(
{ name: 'PluginOrgUserProfileCard' },
);
const CardTitle = (props: { title?: string }) =>
const CardTitle = (props: { title: string; pictureSrc?: string }) =>
props.title ? (
<Box display="flex" alignItems="center">
<PersonIcon fontSize="inherit" />
<Box ml={1}>{props.title}</Box>
</Box>
<Flex align="center" gap="2">
<Avatar
size="small"
purpose="decoration"
name={props.title}
src={props.pictureSrc || ''}
/>
{props.title}
</Flex>
) : null;
/** @public */
export const UserProfileCard = (props: {
variant?: InfoCardVariants;
showLinks?: boolean;
maxRelations?: number;
hideIcons?: boolean;
@@ -132,11 +130,9 @@ export const UserProfileCard = (props: {
});
return (
<InfoCard
title={<CardTitle title={displayName} />}
subheader={description}
variant={props.variant}
action={
<EntityInfoCard
title={<CardTitle title={displayName} pictureSrc={profile?.picture} />}
headerActions={
<>
{entityMetadataEditUrl && (
<IconButton
@@ -151,62 +147,55 @@ export const UserProfileCard = (props: {
</>
}
>
<Grid container spacing={3} alignItems="flex-start">
<Grid item xs={12} sm={2} xl={1}>
<Avatar displayName={displayName} picture={profile?.picture} />
</Grid>
{description && <Text color="secondary">{description}</Text>}
<Box p="2">
<List>
{profile?.email && (
<ListItem>
<ListItemIcon>
<Tooltip title={t('userProfileCard.listItemTitle.email')}>
<EmailIcon />
</Tooltip>
</ListItemIcon>
<ListItemText>
<Link to={emailHref ?? ''}>{profile.email}</Link>
</ListItemText>
</ListItem>
)}
<Grid item md={10} xl={11}>
<List>
{profile?.email && (
<ListItem>
<ListItemIcon>
<Tooltip title={t('userProfileCard.listItemTitle.email')}>
<EmailIcon />
</Tooltip>
</ListItemIcon>
<ListItemText>
<Link to={emailHref ?? ''}>{profile.email}</Link>
</ListItemText>
</ListItem>
)}
{maxRelations === undefined || maxRelations > 0 ? (
<ListItem>
<ListItemIcon>
<Tooltip title={t('userProfileCard.listItemTitle.memberOf')}>
<GroupIcon />
</Tooltip>
</ListItemIcon>
<ListItemText>
<EntityRefLinks
entityRefs={memberOfRelations.slice(0, maxRelations)}
defaultKind="Group"
hideIcons={hideIcons}
/>
{maxRelations && memberOfRelations.length > maxRelations ? (
<>
,
<BaseButton
className={classes.moreButton}
onClick={toggleAllGroupsDialog}
disableRipple
>
{t('userProfileCard.moreGroupButtonTitle', {
number: String(
memberOfRelations.length - maxRelations,
),
})}
</BaseButton>
</>
) : null}
</ListItemText>
</ListItem>
) : null}
{props?.showLinks && <LinksGroup links={links} />}
</List>
</Grid>
</Grid>
{maxRelations === undefined || maxRelations > 0 ? (
<ListItem>
<ListItemIcon>
<Tooltip title={t('userProfileCard.listItemTitle.memberOf')}>
<GroupIcon />
</Tooltip>
</ListItemIcon>
<ListItemText>
<EntityRefLinks
entityRefs={memberOfRelations.slice(0, maxRelations)}
defaultKind="Group"
hideIcons={hideIcons}
/>
{maxRelations && memberOfRelations.length > maxRelations ? (
<>
,
<BaseButton
className={classes.moreButton}
onClick={toggleAllGroupsDialog}
disableRipple
>
{t('userProfileCard.moreGroupButtonTitle', {
number: String(memberOfRelations.length - maxRelations),
})}
</BaseButton>
</>
) : null}
</ListItemText>
</ListItem>
) : null}
{props?.showLinks && <LinksGroup links={links} />}
</List>
</Box>
<Dialog
classes={{ paper: classes.dialogPaper }}
@@ -238,6 +227,6 @@ export const UserProfileCard = (props: {
</Button>
</DialogActions>
</Dialog>
</InfoCard>
</EntityInfoCard>
);
};
@@ -10,7 +10,7 @@ const serviceEntityPage = (
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
</Grid>
</EntityLayout.Route>
@@ -10,7 +10,7 @@ const serviceEntityPage = (
<EntityLayout.Route path="/" title="Overview">
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<EntityAboutCard variant="gridItem" />
<EntityAboutCard />
</Grid>
</Grid>
</EntityLayout.Route>
+1
View File
@@ -6525,6 +6525,7 @@ __metadata:
"@backstage/plugin-permission-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@backstage/ui": "workspace:^"
"@material-ui/core": "npm:^4.12.2"
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"