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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': minor
|
||||
---
|
||||
|
||||
Exported `useEntityRefLink` hook that returns a function for generating entity page URLs from entity references.
|
||||
@@ -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}}',
|
||||
);
|
||||
});
|
||||
|
||||
+6
-7
@@ -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" />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user