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"