feat(org,catalog-graph)!: migrate MembersListCard, OwnershipCard, and CatalogGraphCard to BUI (#33177)

* feat(org): migrate MembersListCard to BUI EntityInfoCard

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(org)!: migrate OwnershipCard to BUI EntityInfoCard

Replaced MUI InfoCard with BUI EntityInfoCard in OwnershipCard.
Removed the `variant` and `maxScrollHeight` props since card sizing
and scrolling are now handled by the BUI layout. Wrapped body content
in a flex column div to keep the relations toggle pinned while the
grid scrolls. Updated app-legacy and create-app templates to remove
the dropped `variant` prop.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(catalog-graph)!: migrate CatalogGraphCard to BUI EntityInfoCard

Replace MUI InfoCard with BUI EntityInfoCard in CatalogGraphCard.
The `variant` prop is removed — card sizing is now handled by the
BUI layout system. The deep link is replaced with a footer Link.

Remove `variant="gridItem"` from all EntityCatalogGraphCard usages
in app-legacy and create-app templates.

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(catalog-react): export useEntityRoute hook

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* fix(cli): update translation extraction test for renamed org keys

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-06 16:19:31 +01:00
committed by GitHub
parent 7dc54e2267
commit d14b6e07f1
21 changed files with 322 additions and 438 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': minor
---
Exported `useEntityRefLink` hook that returns a function for generating entity page URLs from entity references.
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/plugin-org': minor
'@backstage/plugin-catalog-graph': minor
'@backstage/create-app': patch
---
**BREAKING**: Migrated `MembersListCard`, `OwnershipCard`, and `CatalogGraphCard` to use BUI card primitives via `EntityInfoCard`.
- `OwnershipCard`: Removed `variant` and `maxScrollHeight` props. Card height and scrolling are now controlled by the parent container — the card fills its container and the body scrolls automatically when content overflows.
- `CatalogGraphCard`: Removed `variant` prop.
- `MembersListCard`: Translation keys `subtitle`, `paginationLabel`, `aggregateMembersToggle.directMembers`, `aggregateMembersToggle.aggregatedMembers`, and `aggregateMembersToggle.ariaLabel` have been removed. The `title` key now includes `{{groupName}}`. New keys added: `cardLabel`, `noSearchResult`, `aggregateMembersToggle.label`.
- `OwnershipCard`: Translation keys `aggregateRelationsToggle.directRelations`, `aggregateRelationsToggle.aggregatedRelations`, and `aggregateRelationsToggle.ariaLabel` have been removed. New key added: `aggregateRelationsToggle.label`.
- Removed `MemberComponentClassKey` export, and `root` and `cardContent` from `MembersListCardClassKey`, `card` from `OwnershipCardClassKey`, and `card` from `CatalogGraphCardClassKey`.
**Migration:**
```diff
- <EntityOwnershipCard variant="gridItem" />
+ <EntityOwnershipCard />
```
```diff
- <EntityCatalogGraphCard variant="gridItem" height={400} />
+ <EntityCatalogGraphCard height={400} />
```
@@ -170,7 +170,7 @@ const overviewContent = (
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
@@ -299,7 +299,7 @@ const apiPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item xs={12}>
<Grid container>
@@ -333,10 +333,7 @@ const userPage = (
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
<EntityOwnershipCard entityFilterKind={customEntityFilterKind} />
</Grid>
</Grid>
</EntityLayout.Route>
@@ -352,10 +349,7 @@ const groupPage = (
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard
variant="gridItem"
entityFilterKind={customEntityFilterKind}
/>
<EntityOwnershipCard entityFilterKind={customEntityFilterKind} />
</Grid>
<Grid item xs={12} md={6}>
<EntityMembersListCard />
@@ -377,7 +371,7 @@ const systemPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasComponentsCard variant="gridItem" />
@@ -392,7 +386,6 @@ const systemPage = (
</EntityLayout.Route>
<EntityLayout.Route path="/diagram" title="Diagram">
<EntityCatalogGraphCard
variant="gridItem"
direction={Direction.TOP_BOTTOM}
title="System Diagram"
height={700}
@@ -421,7 +414,7 @@ const domainPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
@@ -440,7 +433,7 @@ const resourcePage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
@@ -52,7 +52,7 @@ describe('extractTranslations', () => {
expect(refs[0].messages).toHaveProperty(['membersListCard.title']);
// Verify interpolation placeholders are preserved
expect(refs[0].messages['membersListCard.subtitle']).toContain(
expect(refs[0].messages['membersListCard.title']).toContain(
'{{groupName}}',
);
});
@@ -131,7 +131,7 @@ const overviewContent = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
@@ -266,7 +266,7 @@ const apiPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
<EntityLinksCard />
@@ -301,7 +301,7 @@ const userPage = (
<EntityUserProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard variant="gridItem" />
<EntityOwnershipCard />
</Grid>
</Grid>
</EntityLayout.Route>
@@ -317,7 +317,7 @@ const groupPage = (
<EntityGroupProfileCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityOwnershipCard variant="gridItem" />
<EntityOwnershipCard />
</Grid>
<Grid item xs={12} md={6}>
<EntityMembersListCard />
@@ -339,7 +339,7 @@ const systemPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={4} xs={12}>
<EntityLinksCard />
@@ -357,7 +357,6 @@ const systemPage = (
</EntityLayout.Route>
<EntityLayout.Route path="/diagram" title="Diagram">
<EntityCatalogGraphCard
variant="gridItem"
direction={Direction.TOP_BOTTOM}
title="System Diagram"
height={700}
@@ -386,7 +385,7 @@ const domainPage = (
<EntityAboutCard />
</Grid>
<Grid item md={6} xs={12}>
<EntityCatalogGraphCard variant="gridItem" height={400} />
<EntityCatalogGraphCard height={400} />
</Grid>
<Grid item md={6}>
<EntityHasSystemsCard variant="gridItem" />
+2
View File
@@ -56,9 +56,11 @@
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/types": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"@remixicon/react": "^4.6.0",
"classnames": "^2.3.1",
"lodash": "^4.17.15",
"p-limit": "^3.1.0",
-2
View File
@@ -9,7 +9,6 @@ import { CompoundEntityRef } from '@backstage/catalog-model';
import { DependencyGraphTypes } from '@backstage/core-components';
import { Entity } from '@backstage/catalog-model';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { InfoCardVariants } from '@backstage/core-components';
import { JsonObject } from '@backstage/types';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { MouseEvent as MouseEvent_2 } from 'react';
@@ -138,7 +137,6 @@ export namespace Direction {
// @public
export const EntityCatalogGraphCard: (
props: Partial<EntityRelationsGraphProps> & {
variant?: InfoCardVariants;
height?: number;
title?: string;
action?: ReactNode;
@@ -19,13 +19,15 @@ import {
parseEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
import { useAnalytics, useRouteRef } from '@backstage/core-plugin-api';
import {
EntityInfoCard,
humanizeEntityRef,
useEntity,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { ButtonLink } from '@backstage/ui';
import { RiArrowRightLine } from '@remixicon/react';
import { makeStyles, Theme } from '@material-ui/core/styles';
import qs from 'qs';
import { MouseEvent, ReactNode, useCallback, useMemo } from 'react';
@@ -41,23 +43,13 @@ import { Direction, EntityNode } from '../../lib/types';
import classNames from 'classnames';
/** @public */
export type CatalogGraphCardClassKey = 'card' | 'graph';
export type CatalogGraphCardClassKey = 'graph';
const useStyles = makeStyles<Theme, { height?: number }>(
{
card: ({ height }) => ({
display: 'flex',
flexDirection: 'column',
...(height && {
height,
maxHeight: height,
minHeight: height,
}),
}),
graph: ({ height }) => ({
flex: height ? '0 0 auto' : 1,
height: height ?? '100%',
minHeight: 0,
...(height && { height }),
}),
},
{ name: 'PluginCatalogGraphCatalogGraphCard' },
@@ -65,7 +57,6 @@ const useStyles = makeStyles<Theme, { height?: number }>(
export const CatalogGraphCard = (
props: Partial<EntityRelationsGraphProps> & {
variant?: InfoCardVariants;
height?: number;
title?: string;
action?: ReactNode;
@@ -73,7 +64,6 @@ export const CatalogGraphCard = (
) => {
const { t } = useTranslationRef(catalogGraphTranslationRef);
const {
variant = 'gridItem',
relationPairs,
maxDepth = 1,
unidirectional = true,
@@ -132,16 +122,18 @@ export const CatalogGraphCard = (
const catalogGraphUrl = `${catalogGraphRoute()}${catalogGraphParams}`;
return (
<InfoCard
<EntityInfoCard
title={title}
action={action}
cardClassName={classes.card}
variant={variant}
noPadding
deepLink={{
title: t('catalogGraphCard.deepLinkTitle'),
link: catalogGraphUrl,
}}
headerActions={action}
footerActions={
<ButtonLink
iconEnd={<RiArrowRightLine />}
variant="tertiary"
href={catalogGraphUrl}
>
{t('catalogGraphCard.deepLinkTitle')}
</ButtonLink>
}
>
<EntityRelationsGraph
{...props}
@@ -156,6 +148,6 @@ export const CatalogGraphCard = (
entityFilter={entityFilter}
zoom={zoom}
/>
</InfoCard>
</EntityInfoCard>
);
};
+5
View File
@@ -837,6 +837,11 @@ export function useEntityPresentation(
},
): EntityRefPresentationSnapshot;
// @public
export function useEntityRefLink(): (
entityRef: Entity | CompoundEntityRef | string,
) => string;
// @public
export function useEntityTypeFilter(): {
loading: boolean;
@@ -30,6 +30,10 @@ const useStyles = makeStyles({
root: {
height: '100%',
},
footer: {
display: 'flex',
justifyContent: 'flex-end',
},
});
/** @public */
@@ -63,7 +67,9 @@ export function EntityInfoCard(props: EntityInfoCardProps) {
</CardHeader>
)}
<CardBody>{children}</CardBody>
{footerActions && <CardFooter>{footerActions}</CardFooter>}
{footerActions && (
<CardFooter className={classes.footer}>{footerActions}</CardFooter>
)}
</Card>
);
}
@@ -17,7 +17,7 @@
import { CompoundEntityRef, Entity } from '@backstage/catalog-model';
import { Link, LinkProps } from '@backstage/core-components';
import { useRouteRef } from '@backstage/core-plugin-api';
import { ReactNode, forwardRef } from 'react';
import { ReactNode, forwardRef, useCallback } from 'react';
import { entityRouteParams, entityRouteRef } from '../../routes';
import { EntityDisplayName } from '../EntityDisplayName';
@@ -54,7 +54,7 @@ export const EntityRefLink = forwardRef<any, EntityRefLinkProps>(
disableTooltip,
...linkProps
} = props;
const entityRoute = useEntityRoute(props.entityRef);
const entityLink = useEntityRefLink();
const content = children ?? title ?? (
<EntityDisplayName
@@ -67,23 +67,28 @@ export const EntityRefLink = forwardRef<any, EntityRefLinkProps>(
);
return (
<Link {...linkProps} ref={ref} to={entityRoute}>
<Link {...linkProps} ref={ref} to={entityLink(props.entityRef)}>
{content}
</Link>
);
},
) as (props: EntityRefLinkProps) => JSX.Element;
// Hook that computes the route to a given entity / ref. This is a bit
// contrived, because it tries to retain the casing of the entity name if
// present, but not of other parts. This is in an attempt to make slightly more
// nice-looking URLs.
function useEntityRoute(
/**
* Returns a function that generates a route path to the given entity.
*
* @public
*/
export function useEntityRefLink(): (
entityRef: Entity | CompoundEntityRef | string,
): string {
) => string {
const entityRoute = useRouteRef(entityRouteRef);
const routeParams = entityRouteParams(entityRef, { encodeParams: true });
return entityRoute(routeParams);
return useCallback(
(entityRef: Entity | CompoundEntityRef | string) => {
const routeParams = entityRouteParams(entityRef, { encodeParams: true });
return entityRoute(routeParams);
},
[entityRoute],
);
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
export { EntityRefLink } from './EntityRefLink';
export { EntityRefLink, useEntityRefLink } from './EntityRefLink';
export type { EntityRefLinkProps } from './EntityRefLink';
export { EntityRefLinks } from './EntityRefLinks';
export type { EntityRefLinksProps } from './EntityRefLinks';
+5 -9
View File
@@ -214,17 +214,13 @@ export const orgTranslationRef: TranslationRef<
readonly 'groupProfileCard.listItemTitle.entityRef': 'Entity Ref';
readonly 'groupProfileCard.listItemTitle.parentGroup': 'Parent Group';
readonly 'groupProfileCard.listItemTitle.childGroups': 'Child Groups';
readonly 'membersListCard.title': 'Members';
readonly 'membersListCard.subtitle': 'of {{groupName}}';
readonly 'membersListCard.paginationLabel': ', page {{page}} of {{nbPages}}';
readonly 'membersListCard.title': '{{groupName}} members';
readonly 'membersListCard.cardLabel': 'User page for {{memberName}}';
readonly 'membersListCard.noMembersDescription': 'This group has no members.';
readonly 'membersListCard.aggregateMembersToggle.ariaLabel': 'Users Type Switch';
readonly 'membersListCard.aggregateMembersToggle.directMembers': 'Direct Members';
readonly 'membersListCard.aggregateMembersToggle.aggregatedMembers': 'Aggregated Members';
readonly 'membersListCard.noSearchResult': 'Found no members matching "{{searchTerm}}".';
readonly 'membersListCard.aggregateMembersToggle.label': 'Include subgroups';
readonly 'ownershipCard.title': 'Ownership';
readonly 'ownershipCard.aggregateRelationsToggle.ariaLabel': 'Ownership Type Switch';
readonly 'ownershipCard.aggregateRelationsToggle.directRelations': 'Direct Relations';
readonly 'ownershipCard.aggregateRelationsToggle.aggregatedRelations': 'Aggregated Relations';
readonly 'ownershipCard.aggregateRelationsToggle.label': 'Include indirect ownership';
readonly 'userProfileCard.editIconButtonTitle': 'Edit Metadata';
readonly 'userProfileCard.listItemTitle.email': 'Email';
readonly 'userProfileCard.listItemTitle.memberOf': 'Member of';
+2 -16
View File
@@ -6,7 +6,6 @@
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { ExternalRouteRef } from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { InfoCardVariants } from '@backstage/core-components';
import { JSX as JSX_2 } from 'react/jsx-runtime';
// @public (undocumented)
@@ -33,13 +32,11 @@ export const EntityMembersListCard: (props: {
// @public (undocumented)
export const EntityOwnershipCard: (props: {
variant?: InfoCardVariants;
entityFilterKind?: string[];
hideRelationsToggle?: boolean;
relationsType?: EntityRelationAggregation;
relationAggregation?: EntityRelationAggregation;
entityLimit?: number;
maxScrollHeight?: string;
}) => JSX_2.Element;
// @public (undocumented)
@@ -57,9 +54,6 @@ export const GroupProfileCard: (props: {
showLinks?: boolean;
}) => JSX_2.Element;
// @public (undocumented)
export type MemberComponentClassKey = 'card' | 'avatar';
// @public (undocumented)
export const MembersListCard: (props: {
memberDisplayTitle?: string;
@@ -71,7 +65,7 @@ export const MembersListCard: (props: {
}) => JSX_2.Element;
// @public (undocumented)
export type MembersListCardClassKey = 'root' | 'cardContent' | 'memberList';
export type MembersListCardClassKey = 'memberList';
// @public
export const MyGroupsSidebarItem: (props: {
@@ -93,23 +87,15 @@ export { orgPlugin as plugin };
// @public (undocumented)
export const OwnershipCard: (props: {
variant?: InfoCardVariants;
entityFilterKind?: string[];
hideRelationsToggle?: boolean;
relationsType?: EntityRelationAggregation;
relationAggregation?: EntityRelationAggregation;
entityLimit?: number;
maxScrollHeight?: string;
}) => JSX_2.Element;
// @public (undocumented)
export type OwnershipCardClassKey =
| 'card'
| 'cardContent'
| 'list'
| 'listItemText'
| 'listItemSecondaryAction'
| 'grid';
export type OwnershipCardClassKey = 'grid';
// @public (undocumented)
export const UserProfileCard: (props: {
@@ -124,19 +124,11 @@ describe('MemberTab Test', () => {
},
});
expect(screen.getByAltText('Tara MacGovern')).toHaveAttribute(
'src',
'https://example.com/staff/tara.jpeg',
);
expect(screen.getByText('Tara MacGovern')).toBeInTheDocument();
expect(screen.getByText('tara-macgovern@example.com')).toBeInTheDocument();
expect(screen.getByText('Tara MacGovern').closest('a')).toHaveAttribute(
'href',
'/catalog/foo-bar/user/tara.macgovern',
);
expect(screen.getByText('Super Awesome Developer')).toBeInTheDocument();
expect(screen.getByText('Members (1 of 1)')).toBeInTheDocument();
expect(screen.getByText('team-d members (1 of 1)')).toBeInTheDocument();
});
it('Can render different member display title', async () => {
@@ -205,8 +197,7 @@ describe('MemberTab Test', () => {
},
},
);
const toggleSwitch = screen.queryByRole('checkbox');
expect(toggleSwitch).toBeNull();
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
});
it('Shows the aggregate members toggle if the showAggregateMembersToggle prop is true', async () => {
@@ -233,8 +224,9 @@ describe('MemberTab Test', () => {
},
},
);
expect(screen.queryByRole('checkbox')).toBeInTheDocument();
expect(screen.getByRole('switch')).toBeInTheDocument();
});
it('Shows only direct members if the showAggregateMembersToggle prop is undefined', async () => {
await renderInTestApp(
<TestApiProvider
@@ -259,10 +251,9 @@ describe('MemberTab Test', () => {
},
},
);
const displayedMemberNames = screen.queryAllByTestId('user-link');
const duplicatedUserText = screen.getByText('Duplicated User');
const groupAUserOneText = screen.getByText('Group A User One');
expect(displayedMemberNames).toHaveLength(2);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(2);
expect(duplicatedUserText).toBeInTheDocument();
expect(groupAUserOneText).toBeInTheDocument();
expect(
@@ -294,10 +285,9 @@ describe('MemberTab Test', () => {
},
},
);
const displayedMemberNames = screen.queryAllByTestId('user-link');
const duplicatedUserText = screen.getByText('Duplicated User');
const groupAUserOneText = screen.getByText('Group A User One');
expect(displayedMemberNames).toHaveLength(2);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(2);
expect(duplicatedUserText).toBeInTheDocument();
expect(groupAUserOneText).toBeInTheDocument();
expect(
@@ -331,18 +321,16 @@ describe('MemberTab Test', () => {
);
// Should show only direct users on initial load
const displayedMemberNamesBefore = screen.queryAllByTestId('user-link');
expect(displayedMemberNamesBefore).toHaveLength(2);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(2);
// Click the toggle switch
await userEvent.click(screen.getByRole('checkbox'));
const displayedMemberNamesAfter = screen.queryAllByTestId('user-link');
await userEvent.click(screen.getByRole('switch'));
const duplicatedUserText = screen.getByText('Duplicated User');
const groupAUserOneText = screen.getByText('Group A User One');
const groupBUserOneText = screen.getByText('Group B User One');
const groupDUserOneText = screen.getByText('Group D User One');
const groupEUserOneText = screen.getByText('Group E User One');
expect(displayedMemberNamesAfter).toHaveLength(5);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(5);
expect(duplicatedUserText).toBeInTheDocument();
expect(groupAUserOneText).toBeInTheDocument();
expect(groupBUserOneText).toBeInTheDocument();
@@ -392,15 +380,13 @@ describe('MemberTab Test', () => {
);
// Should show aggregated users on initial load
const displayedMemberNamesBefore = screen.queryAllByTestId('user-link');
expect(displayedMemberNamesBefore).toHaveLength(5);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(5);
// Click the toggle switch
await userEvent.click(screen.getByRole('checkbox'));
await userEvent.click(screen.getByRole('switch'));
// Should now show only direct users
const displayedMemberNamesAfter = screen.queryAllByTestId('user-link');
expect(displayedMemberNamesAfter).toHaveLength(2);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(2);
});
it('Can show aggregated members without the aggregate members toggle', async () => {
@@ -428,12 +414,11 @@ describe('MemberTab Test', () => {
},
);
// aggregated relations checkbox should not be rendered
expect(screen.queryByRole('checkbox')).toBeNull();
// aggregated relations switch should not be rendered
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
// Should show all descendant users on load
const displayedMemberNames = screen.queryAllByTestId('user-link');
expect(displayedMemberNames).toHaveLength(5);
expect(screen.getAllByRole('heading', { level: 4 })).toHaveLength(5);
});
describe('Search', () => {
@@ -483,7 +468,7 @@ describe('MemberTab Test', () => {
);
expect(
screen.getByText(/This group has no members./i),
screen.getByText(/Found no members matching/i),
).toBeInTheDocument();
});
});
@@ -23,25 +23,14 @@ import {
import {
catalogApiRef,
useEntity,
EntityRefLink,
EntityInfoCard,
useEntityRefLink,
} from '@backstage/plugin-catalog-react';
import Box from '@material-ui/core/Box';
import Grid from '@material-ui/core/Grid';
import Switch from '@material-ui/core/Switch';
import Typography from '@material-ui/core/Typography';
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles';
import Pagination from '@material-ui/lab/Pagination';
import { useState, useEffect, ChangeEvent } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { useState, useEffect } from 'react';
import useAsync from 'react-use/esm/useAsync';
import {
Avatar,
InfoCard,
Progress,
ResponseErrorPanel,
Link,
OverflowTooltip,
} from '@backstage/core-components';
import { Progress, ResponseErrorPanel } from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import {
getAllDesendantMembersForGroupEntity,
@@ -50,101 +39,107 @@ import {
import { EntityRelationAggregation } from '../../types';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { orgTranslationRef } from '../../../../translation';
import TextField from '@material-ui/core/TextField';
import {
Avatar,
Box,
Card,
Flex,
Link,
SearchField,
Switch,
TablePagination,
Text,
} from '@backstage/ui';
/** @public */
export type MemberComponentClassKey = 'card' | 'avatar';
const useStyles = makeStyles(
(theme: Theme) =>
createStyles({
card: {
border: `1px solid ${theme.palette.divider}`,
boxShadow: theme.shadows[2],
borderRadius: '4px',
overflow: 'visible',
position: 'relative',
margin: theme.spacing(4, 1, 1),
flex: '1',
minWidth: '0px',
},
avatar: {
position: 'absolute',
top: '-2rem',
},
}),
{ name: 'PluginOrgMemberComponent' },
);
const useMemberStyles = makeStyles({
card: {
display: 'flex',
gap: 'var(--bui-space-3)',
padding: 'var(--bui-space-3)',
alignItems: 'flex-start',
flexDirection: 'row',
height: 140,
overflow: 'hidden',
},
avatar: {
flexShrink: 0,
},
cardTextContainer: {
overflow: 'hidden',
},
singlelineEllipsis: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
multilineEllipsis: {
display: '-webkit-box',
'-webkit-line-clamp': '3',
'-webkit-box-orient': 'vertical',
overflow: 'hidden',
},
});
const MemberComponent = (props: { member: UserEntity }) => {
const classes = useStyles();
const { t } = useTranslationRef(orgTranslationRef);
const classes = useMemberStyles();
const {
metadata: { name: metaName, description },
spec: { profile },
} = props.member;
const displayName = profile?.displayName ?? metaName;
const entityLink = useEntityRefLink();
return (
<Box className={classes.card}>
<Box
display="flex"
flexDirection="column"
m={3}
alignItems="center"
justifyContent="center"
>
<Avatar
displayName={displayName}
picture={profile?.picture}
classes={classes}
/>
<Box
pt={2}
sx={{
width: '100%',
}}
textAlign="center"
>
<Typography variant="h6">
<EntityRefLink
data-testid="user-link"
entityRef={props.member}
title={displayName}
/>
</Typography>
{profile?.email && (
<Link to={`mailto:${profile.email}`}>
<OverflowTooltip text={profile.email} />
</Link>
)}
{description && (
<Typography variant="subtitle2">
<OverflowTooltip text={description} line={5} />
</Typography>
)}
</Box>
</Box>
</Box>
<Card
className={classes.card}
href={entityLink(props.member)}
label={t('membersListCard.cardLabel', { memberName: displayName })}
>
<Avatar
className={classes.avatar}
name={displayName}
src={profile?.picture ?? ''}
purpose="decoration"
size="x-large"
/>
<Flex className={classes.cardTextContainer} direction="column" gap="1">
<Text variant="body-large" as="h4">
{displayName}
</Text>
{profile?.email && (
<Link
className={classes.singlelineEllipsis}
href={`mailto:${profile.email}`}
>
{profile.email}
</Link>
)}
{description && (
<Text className={classes.multilineEllipsis}>{description}</Text>
)}
</Flex>
</Card>
);
};
/** @public */
export type MembersListCardClassKey = 'root' | 'cardContent' | 'memberList';
export type MembersListCardClassKey = 'memberList';
const useListStyles = makeStyles(
theme => ({
root: {
height: '100%',
},
cardContent: {
overflow: 'auto',
},
() => ({
memberList: {
display: 'grid',
gap: theme.spacing(1.5),
gridTemplateColumns: `repeat(auto-fit, minmax(auto, ${theme.spacing(
34,
)}px))`,
gap: 'var(--bui-space-3)',
gridTemplateColumns: `repeat(auto-fit, minmax(275px, 1fr))`,
gridAutoRows: '1fr',
margin: 0,
padding: 0,
paddingTop: 'var(--bui-space-3)',
listStyle: 'none',
},
memberListItem: {
display: 'contents',
},
}),
{ name: 'PluginOrgMembersListCardComponent' },
@@ -162,7 +157,7 @@ export const MembersListCard = (props: {
}) => {
const { t } = useTranslationRef(orgTranslationRef);
const {
memberDisplayTitle = t('membersListCard.title'),
memberDisplayTitle,
pageSize = 50,
showAggregateMembersToggle,
relationType = 'memberof',
@@ -179,13 +174,13 @@ export const MembersListCard = (props: {
const catalogApi = useApi(catalogApiRef);
const displayName = profile?.displayName ?? groupName;
const cardTitle =
memberDisplayTitle ??
t('membersListCard.title', { groupName: displayName });
const groupNamespace = grpNamespace || DEFAULT_NAMESPACE;
const [page, setPage] = useState(1);
const pageChange = (_: ChangeEvent<unknown>, pageIndex: number) => {
setPage(pageIndex);
};
const [offset, setOffset] = useState(0);
const [showAggregateMembers, setShowAggregateMembers] = useState(
relationAggregation === 'aggregated',
@@ -194,8 +189,8 @@ export const MembersListCard = (props: {
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
setPage(1);
}, [searchTerm]);
setOffset(0);
}, [searchTerm, showAggregateMembers]);
const { loading: loadingDescendantMembers, value: descendantMembers } =
useAsync(async () => {
@@ -245,25 +240,6 @@ export const MembersListCard = (props: {
return <ResponseErrorPanel error={error} />;
}
const nbPages = Math.ceil((members?.length || 0) / pageSize);
const paginationLabel =
nbPages < 2
? ''
: t('membersListCard.paginationLabel', {
page: String(page),
nbPages: String(nbPages),
});
const pagination = (
<Pagination
count={nbPages}
page={page}
onChange={pageChange}
showFirstButton
showLastButton
/>
);
const filteredMembers = members.filter(member => {
const fields = [
member.metadata.name,
@@ -279,72 +255,79 @@ export const MembersListCard = (props: {
});
const membersToRender = searchTerm ? filteredMembers : members;
const totalCount = membersToRender.length;
const hasNextPage = offset + pageSize < totalCount;
const hasPreviousPage = offset > 0;
const pagination =
totalCount > pageSize ? (
<TablePagination
showPageSizeOptions={false}
pageSizeOptions={[pageSize]}
pageSize={pageSize}
offset={offset}
totalCount={totalCount}
hasNextPage={hasNextPage}
hasPreviousPage={hasPreviousPage}
onNextPage={() => setOffset(prev => prev + pageSize)}
onPreviousPage={() => setOffset(prev => Math.max(0, prev - pageSize))}
/>
) : undefined;
let memberList: JSX.Element;
if (membersToRender && membersToRender.length > 0) {
if (membersToRender.length > 0) {
memberList = (
<Box className={classes.memberList}>
{membersToRender
.slice(pageSize * (page - 1), pageSize * page)
.map(member => (
<MemberComponent member={member} key={stringifyEntityRef(member)} />
))}
</Box>
<ul className={classes.memberList}>
{membersToRender.slice(offset, offset + pageSize).map(member => (
<li
className={classes.memberListItem}
key={stringifyEntityRef(member)}
>
<MemberComponent member={member} />
</li>
))}
</ul>
);
} else {
memberList = (
<Box p={2}>
<Typography>{t('membersListCard.noMembersDescription')}</Typography>
<Box p="2">
<Text as="p">
{searchTerm
? t('membersListCard.noSearchResult', { searchTerm })
: t('membersListCard.noMembersDescription')}
</Text>
</Box>
);
}
return (
<Grid item className={classes.root}>
<InfoCard
title={`${memberDisplayTitle} (${filteredMembers.length} of ${members.length}${paginationLabel})`}
subheader={t('membersListCard.subtitle', {
groupName: displayName,
})}
{...(nbPages <= 1 ? {} : { actions: pagination })}
className={classes.root}
cardClassName={classes.cardContent}
>
{showAggregateMembersToggle && (
<>
{t('membersListCard.aggregateMembersToggle.directMembers')}
<Switch
color="primary"
checked={showAggregateMembers}
onChange={() => {
setShowAggregateMembers(!showAggregateMembers);
}}
inputProps={{
'aria-label': t(
'membersListCard.aggregateMembersToggle.ariaLabel',
),
}}
/>
{t('membersListCard.aggregateMembersToggle.aggregatedMembers')}
</>
)}
{showAggregateMembers && loadingDescendantMembers ? (
<Progress />
) : (
<>
<TextField
fullWidth
margin="dense"
placeholder="Search members..."
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
/>
<Box sx={{ mt: 2 }}>{memberList}</Box>
</>
)}
</InfoCard>
</Grid>
<EntityInfoCard
title={`${cardTitle} (${filteredMembers.length} of ${members.length})`}
headerActions={
showAggregateMembersToggle && (
<Switch
isSelected={showAggregateMembers}
onChange={setShowAggregateMembers}
label={t('membersListCard.aggregateMembersToggle.label')}
/>
)
}
footerActions={pagination}
>
{showAggregateMembers && loadingDescendantMembers ? (
<Progress />
) : (
<>
<SearchField
aria-label="Search members"
placeholder="Search members..."
value={searchTerm}
onChange={setSearchTerm}
onClear={() => setSearchTerm('')}
/>
{memberList}
</>
)}
</EntityInfoCard>
);
};
@@ -17,7 +17,7 @@
import { Entity, GroupEntity, UserEntity } from '@backstage/catalog-model';
import { catalogApiRef, EntityProvider } from '@backstage/plugin-catalog-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { queryByText } from '@testing-library/react';
import { queryByText, screen } from '@testing-library/react';
import { catalogIndexRouteRef } from '../../../routes';
import { OwnershipCard } from './OwnershipCard';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
@@ -275,7 +275,7 @@ describe('OwnershipCard', () => {
it('shows relations toggle', async () => {
const catalogApi = catalogApiMock({ entities: items });
const { getByTitle } = await renderInTestApp(
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<EntityProvider entity={groupEntity}>
<OwnershipCard />
@@ -288,13 +288,15 @@ describe('OwnershipCard', () => {
},
);
expect(getByTitle('Direct Relations')).toBeInTheDocument();
expect(
screen.getByRole('switch', { name: 'Include indirect ownership' }),
).toBeInTheDocument();
});
it('hides relations toggle', async () => {
const catalogApi = catalogApiMock({ entities: items });
const rendered = await renderInTestApp(
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<EntityProvider entity={groupEntity}>
<OwnershipCard hideRelationsToggle />
@@ -307,13 +309,13 @@ describe('OwnershipCard', () => {
},
);
expect(rendered.queryByText('Direct Relations')).toBeNull();
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
});
it('overrides relation type', async () => {
const catalogApi = catalogApiMock({ entities: items });
const { getByTitle } = await renderInTestApp(
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<EntityProvider entity={groupEntity}>
<OwnershipCard relationsType="aggregated" />
@@ -326,13 +328,13 @@ describe('OwnershipCard', () => {
},
);
expect(getByTitle('Aggregated Relations')).toBeInTheDocument();
await expect(screen.findByRole('switch')).resolves.toBeChecked();
});
it('defaults to aggregated for User entity kind', async () => {
const catalogApi = catalogApiMock({ entities: items });
const { getByLabelText } = await renderInTestApp(
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<EntityProvider entity={userEntity}>
<OwnershipCard />
@@ -345,13 +347,13 @@ describe('OwnershipCard', () => {
},
);
expect(getByLabelText('Ownership Type Switch')).toBeChecked();
await expect(screen.findByRole('switch')).resolves.toBeChecked();
});
it('defaults to direct for all entity kinds except User', async () => {
const catalogApi = catalogApiMock({ entities: items });
const { getByLabelText } = await renderInTestApp(
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<EntityProvider entity={groupEntity}>
<OwnershipCard />
@@ -364,13 +366,13 @@ describe('OwnershipCard', () => {
},
);
expect(getByLabelText('Ownership Type Switch')).not.toBeChecked();
expect(screen.getByRole('switch')).not.toBeChecked();
});
it('defaults to provided relationsType', async () => {
const catalogApi = catalogApiMock({ entities: items });
const { getByLabelText } = await renderInTestApp(
await renderInTestApp(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<EntityProvider entity={userEntity}>
<OwnershipCard relationsType="direct" />
@@ -383,7 +385,7 @@ describe('OwnershipCard', () => {
},
);
expect(getByLabelText('Ownership Type Switch')).not.toBeChecked();
expect(screen.getByRole('switch')).not.toBeChecked();
});
});
});
@@ -14,71 +14,25 @@
* limitations under the License.
*/
import { InfoCard, InfoCardVariants } from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import List from '@material-ui/core/List';
import ListItem from '@material-ui/core/ListItem';
import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction';
import ListItemText from '@material-ui/core/ListItemText';
import Switch from '@material-ui/core/Switch';
import Tooltip from '@material-ui/core/Tooltip';
import { EntityInfoCard, useEntity } from '@backstage/plugin-catalog-react';
import { Switch } from '@backstage/ui';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import { useEffect, useState } from 'react';
import { ComponentsGrid } from './ComponentsGrid';
import { EntityRelationAggregation } from '../types';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { orgTranslationRef } from '../../../translation';
import Box from '@material-ui/core/Box';
/** @public */
export type OwnershipCardClassKey =
| 'card'
| 'cardContent'
| 'list'
| 'listItemText'
| 'listItemSecondaryAction'
| 'grid';
export type OwnershipCardClassKey = 'grid';
const useStyles = makeStyles(
theme =>
() =>
createStyles({
card: {
maxHeight: '100%',
},
cardContent: {
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
},
list: {
[theme.breakpoints.down('xs')]: {
padding: `0 0 12px`,
},
},
listItemText: {
[theme.breakpoints.down('xs')]: {
paddingRight: 0,
paddingLeft: 0,
},
},
listItemSecondaryAction: {
[theme.breakpoints.down('xs')]: {
width: '100%',
top: 'auto',
right: 'auto',
position: 'relative',
transform: 'unset',
},
},
grid: {
overflowY: 'auto',
marginTop: 0,
},
box: {
overflowY: 'auto',
padding: theme.spacing(0, 1, 1),
margin: theme.spacing(0, -1),
},
}),
{
name: 'PluginOrgOwnershipCard',
@@ -87,27 +41,17 @@ const useStyles = makeStyles(
/** @public */
export const OwnershipCard = (props: {
variant?: InfoCardVariants;
entityFilterKind?: string[];
hideRelationsToggle?: boolean;
/** @deprecated Please use relationAggregation instead */
relationsType?: EntityRelationAggregation;
relationAggregation?: EntityRelationAggregation;
entityLimit?: number;
maxScrollHeight?: string;
}) => {
const {
variant,
entityFilterKind,
hideRelationsToggle,
entityLimit = 6,
maxScrollHeight: propMaxScrollHeight,
} = props;
const { entityFilterKind, hideRelationsToggle, entityLimit = 6 } = props;
const relationAggregation = props.relationAggregation ?? props.relationsType;
const relationsToggle =
hideRelationsToggle === undefined ? false : hideRelationsToggle;
const maxScrollHeight =
variant !== 'fullHeight' ? propMaxScrollHeight : undefined;
const classes = useStyles();
const { entity } = useEntity();
const { t } = useTranslationRef(orgTranslationRef);
@@ -125,65 +69,27 @@ export const OwnershipCard = (props: {
}, [setRelationAggregation, defaultRelationAggregation, relationAggregation]);
return (
<InfoCard
<EntityInfoCard
title={t('ownershipCard.title')}
variant={variant}
className={classes.card}
cardClassName={classes.cardContent}
headerActions={
!relationsToggle && (
<Switch
isSelected={getRelationAggregation !== 'direct'}
onChange={isSelected =>
setRelationAggregation(isSelected ? 'aggregated' : 'direct')
}
label={t('ownershipCard.aggregateRelationsToggle.label')}
/>
)
}
>
{!relationsToggle && (
<List dense>
<ListItem className={classes.list}>
<ListItemText className={classes.listItemText} />
<ListItemSecondaryAction
className={classes.listItemSecondaryAction}
>
{t('ownershipCard.aggregateRelationsToggle.directRelations')}
<Tooltip
placement="top"
arrow
title={
getRelationAggregation === 'direct'
? t(
'ownershipCard.aggregateRelationsToggle.directRelations',
)
: t(
'ownershipCard.aggregateRelationsToggle.aggregatedRelations',
)
}
>
<Switch
color="primary"
checked={getRelationAggregation !== 'direct'}
onChange={() => {
const updatedRelationAggregation =
getRelationAggregation === 'direct'
? 'aggregated'
: 'direct';
setRelationAggregation(updatedRelationAggregation);
}}
name="pin"
inputProps={{
'aria-label': t(
'ownershipCard.aggregateRelationsToggle.ariaLabel',
),
}}
/>
</Tooltip>
{t('ownershipCard.aggregateRelationsToggle.aggregatedRelations')}
</ListItemSecondaryAction>
</ListItem>
</List>
)}
<Box maxHeight={maxScrollHeight} className={classes.box}>
<ComponentsGrid
className={classes.grid}
entity={entity}
entityLimit={entityLimit}
relationAggregation={getRelationAggregation}
entityFilterKind={entityFilterKind}
/>
</Box>
</InfoCard>
<ComponentsGrid
className={classes.grid}
entity={entity}
entityLimit={entityLimit}
relationAggregation={getRelationAggregation}
entityFilterKind={entityFilterKind}
/>
</EntityInfoCard>
);
};
-2
View File
@@ -18,7 +18,6 @@ import { Overrides } from '@material-ui/core/styles/overrides';
import { StyleRules } from '@material-ui/core/styles/withStyles';
import {
ComponentsGridClassKey,
MemberComponentClassKey,
MembersListCardClassKey,
OwnershipCardClassKey,
UserProfileCardClassKey,
@@ -26,7 +25,6 @@ import {
/** @public */
export type CatalogReactComponentsNameToClassKey = {
PluginOrgMemberComponent: MemberComponentClassKey;
PluginOrgMembersListCardComponent: MembersListCardClassKey;
PluginOrgOwnershipCard: OwnershipCardClassKey;
PluginOrgComponentsGrid: ComponentsGridClassKey;
+5 -9
View File
@@ -34,22 +34,18 @@ export const orgTranslationRef = createTranslationRef({
},
},
membersListCard: {
title: 'Members',
subtitle: 'of {{groupName}}',
paginationLabel: ', page {{page}} of {{nbPages}}',
cardLabel: 'User page for {{memberName}}',
title: '{{groupName}} members',
noMembersDescription: 'This group has no members.',
noSearchResult: 'Found no members matching "{{searchTerm}}".',
aggregateMembersToggle: {
directMembers: 'Direct Members',
aggregatedMembers: 'Aggregated Members',
ariaLabel: 'Users Type Switch',
label: 'Include subgroups',
},
},
ownershipCard: {
title: 'Ownership',
aggregateRelationsToggle: {
directRelations: 'Direct Relations',
aggregatedRelations: 'Aggregated Relations',
ariaLabel: 'Ownership Type Switch',
label: 'Include indirect ownership',
},
},
userProfileCard: {
+2
View File
@@ -5373,9 +5373,11 @@ __metadata:
"@backstage/plugin-catalog-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"
"@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"