diff --git a/.changeset/export-use-entity-route.md b/.changeset/export-use-entity-route.md
new file mode 100644
index 0000000000..c5ab9a9b33
--- /dev/null
+++ b/.changeset/export-use-entity-route.md
@@ -0,0 +1,5 @@
+---
+'@backstage/plugin-catalog-react': minor
+---
+
+Exported `useEntityRefLink` hook that returns a function for generating entity page URLs from entity references.
diff --git a/.changeset/happy-dogs-fly.md b/.changeset/happy-dogs-fly.md
new file mode 100644
index 0000000000..30b21a66e7
--- /dev/null
+++ b/.changeset/happy-dogs-fly.md
@@ -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
+-
++
+```
+
+```diff
+-
++
+```
diff --git a/packages/app-legacy/src/components/catalog/EntityPage.tsx b/packages/app-legacy/src/components/catalog/EntityPage.tsx
index 32249cb84a..7c42b62542 100644
--- a/packages/app-legacy/src/components/catalog/EntityPage.tsx
+++ b/packages/app-legacy/src/components/catalog/EntityPage.tsx
@@ -170,7 +170,7 @@ const overviewContent = (
-
+
@@ -299,7 +299,7 @@ const apiPage = (
-
+
@@ -333,10 +333,7 @@ const userPage = (
-
+
@@ -352,10 +349,7 @@ const groupPage = (
-
+
@@ -377,7 +371,7 @@ const systemPage = (
-
+
@@ -392,7 +386,6 @@ const systemPage = (
-
+
@@ -440,7 +433,7 @@ const resourcePage = (
-
+
diff --git a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts
index 27b67db052..20ff05b69e 100644
--- a/packages/cli/src/modules/translations/lib/extractTranslations.test.ts
+++ b/packages/cli/src/modules/translations/lib/extractTranslations.test.ts
@@ -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}}',
);
});
diff --git a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx
index 750dfe6cea..367652b70d 100644
--- a/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx
+++ b/packages/create-app/templates/default-app/packages/app/src/components/catalog/EntityPage.tsx
@@ -131,7 +131,7 @@ const overviewContent = (
-
+
@@ -266,7 +266,7 @@ const apiPage = (
-
+
@@ -301,7 +301,7 @@ const userPage = (
-
+
@@ -317,7 +317,7 @@ const groupPage = (
-
+
@@ -339,7 +339,7 @@ const systemPage = (
-
+
@@ -357,7 +357,6 @@ const systemPage = (
-
+
diff --git a/plugins/catalog-graph/package.json b/plugins/catalog-graph/package.json
index dbb4c69899..c2c1b36885 100644
--- a/plugins/catalog-graph/package.json
+++ b/plugins/catalog-graph/package.json
@@ -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",
diff --git a/plugins/catalog-graph/report.api.md b/plugins/catalog-graph/report.api.md
index 8d3b8f2cc8..74b9713cf2 100644
--- a/plugins/catalog-graph/report.api.md
+++ b/plugins/catalog-graph/report.api.md
@@ -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 & {
- variant?: InfoCardVariants;
height?: number;
title?: string;
action?: ReactNode;
diff --git a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx
index b08404f63b..20b4074f79 100644
--- a/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx
+++ b/plugins/catalog-graph/src/components/CatalogGraphCard/CatalogGraphCard.tsx
@@ -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(
{
- 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(
export const CatalogGraphCard = (
props: Partial & {
- 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 (
- }
+ variant="tertiary"
+ href={catalogGraphUrl}
+ >
+ {t('catalogGraphCard.deepLinkTitle')}
+
+ }
>
-
+
);
};
diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md
index 6a930671c2..3ae36ce941 100644
--- a/plugins/catalog-react/report.api.md
+++ b/plugins/catalog-react/report.api.md
@@ -837,6 +837,11 @@ export function useEntityPresentation(
},
): EntityRefPresentationSnapshot;
+// @public
+export function useEntityRefLink(): (
+ entityRef: Entity | CompoundEntityRef | string,
+) => string;
+
// @public
export function useEntityTypeFilter(): {
loading: boolean;
diff --git a/plugins/catalog-react/src/components/EntityInfoCard/EntityInfoCard.tsx b/plugins/catalog-react/src/components/EntityInfoCard/EntityInfoCard.tsx
index a0c7c20ffe..54db900fa4 100644
--- a/plugins/catalog-react/src/components/EntityInfoCard/EntityInfoCard.tsx
+++ b/plugins/catalog-react/src/components/EntityInfoCard/EntityInfoCard.tsx
@@ -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) {
)}
{children}
- {footerActions && {footerActions}}
+ {footerActions && (
+ {footerActions}
+ )}
);
}
diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx
index 40c738a66e..8af051db8c 100644
--- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx
+++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx
@@ -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(
disableTooltip,
...linkProps
} = props;
- const entityRoute = useEntityRoute(props.entityRef);
+ const entityLink = useEntityRefLink();
const content = children ?? title ?? (
(
);
return (
-
+
{content}
);
},
) 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],
+ );
}
diff --git a/plugins/catalog-react/src/components/EntityRefLink/index.ts b/plugins/catalog-react/src/components/EntityRefLink/index.ts
index 50ac3d4534..82773da141 100644
--- a/plugins/catalog-react/src/components/EntityRefLink/index.ts
+++ b/plugins/catalog-react/src/components/EntityRefLink/index.ts
@@ -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';
diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md
index 781cfe605a..44fd429bfa 100644
--- a/plugins/org/report-alpha.api.md
+++ b/plugins/org/report-alpha.api.md
@@ -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';
diff --git a/plugins/org/report.api.md b/plugins/org/report.api.md
index 0d6b15b308..d51a45e3f7 100644
--- a/plugins/org/report.api.md
+++ b/plugins/org/report.api.md
@@ -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: {
diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx
index 891c1ba3c7..5ae60fb9b2 100644
--- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx
+++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.test.tsx
@@ -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(
{
},
},
);
- 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();
});
});
diff --git a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx
index ee2f37ea9c..feb8590826 100644
--- a/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx
+++ b/plugins/org/src/components/Cards/Group/MembersList/MembersListCard.tsx
@@ -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 (
-
-
-
-
-
-
-
- {profile?.email && (
-
-
-
- )}
- {description && (
-
-
-
- )}
-
-
-
+
+
+
+
+ {displayName}
+
+ {profile?.email && (
+
+ {profile.email}
+
+ )}
+ {description && (
+ {description}
+ )}
+
+
);
};
/** @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, 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 ;
}
- const nbPages = Math.ceil((members?.length || 0) / pageSize);
- const paginationLabel =
- nbPages < 2
- ? ''
- : t('membersListCard.paginationLabel', {
- page: String(page),
- nbPages: String(nbPages),
- });
-
- const pagination = (
-
- );
-
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 ? (
+ 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 = (
-
- {membersToRender
- .slice(pageSize * (page - 1), pageSize * page)
- .map(member => (
-
- ))}
-
+
+ {membersToRender.slice(offset, offset + pageSize).map(member => (
+ -
+
+
+ ))}
+
);
} else {
memberList = (
-
- {t('membersListCard.noMembersDescription')}
+
+
+ {searchTerm
+ ? t('membersListCard.noSearchResult', { searchTerm })
+ : t('membersListCard.noMembersDescription')}
+
);
}
return (
-
-
- {showAggregateMembersToggle && (
- <>
- {t('membersListCard.aggregateMembersToggle.directMembers')}
- {
- setShowAggregateMembers(!showAggregateMembers);
- }}
- inputProps={{
- 'aria-label': t(
- 'membersListCard.aggregateMembersToggle.ariaLabel',
- ),
- }}
- />
- {t('membersListCard.aggregateMembersToggle.aggregatedMembers')}
- >
- )}
- {showAggregateMembers && loadingDescendantMembers ? (
-
- ) : (
- <>
- ) =>
- setSearchTerm(e.target.value)
- }
- />
- {memberList}
- >
- )}
-
-
+
+ )
+ }
+ footerActions={pagination}
+ >
+ {showAggregateMembers && loadingDescendantMembers ? (
+
+ ) : (
+ <>
+ setSearchTerm('')}
+ />
+ {memberList}
+ >
+ )}
+
);
};
diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
index 5bb058e1b2..780b9d4837 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -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(
@@ -383,7 +385,7 @@ describe('OwnershipCard', () => {
},
);
- expect(getByLabelText('Ownership Type Switch')).not.toBeChecked();
+ expect(screen.getByRole('switch')).not.toBeChecked();
});
});
});
diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
index 1df55c62c2..d756fdaca3 100644
--- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
+++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx
@@ -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 (
-
+ setRelationAggregation(isSelected ? 'aggregated' : 'direct')
+ }
+ label={t('ownershipCard.aggregateRelationsToggle.label')}
+ />
+ )
+ }
>
- {!relationsToggle && (
-
-
-
-
- {t('ownershipCard.aggregateRelationsToggle.directRelations')}
-
- {
- const updatedRelationAggregation =
- getRelationAggregation === 'direct'
- ? 'aggregated'
- : 'direct';
- setRelationAggregation(updatedRelationAggregation);
- }}
- name="pin"
- inputProps={{
- 'aria-label': t(
- 'ownershipCard.aggregateRelationsToggle.ariaLabel',
- ),
- }}
- />
-
- {t('ownershipCard.aggregateRelationsToggle.aggregatedRelations')}
-
-
-
- )}
-
-
-
-
+
+
);
};
diff --git a/plugins/org/src/overridableComponents.ts b/plugins/org/src/overridableComponents.ts
index b4c5f31212..a5057e57ec 100644
--- a/plugins/org/src/overridableComponents.ts
+++ b/plugins/org/src/overridableComponents.ts
@@ -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;
diff --git a/plugins/org/src/translation.ts b/plugins/org/src/translation.ts
index b7dfda6937..b5309d14e7 100644
--- a/plugins/org/src/translation.ts
+++ b/plugins/org/src/translation.ts
@@ -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: {
diff --git a/yarn.lock b/yarn.lock
index 22035c19d0..68e68bb140 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"