diff --git a/.changeset/itchy-candles-type.md b/.changeset/itchy-candles-type.md new file mode 100644 index 0000000000..219f95de5b --- /dev/null +++ b/.changeset/itchy-candles-type.md @@ -0,0 +1,32 @@ +--- +'@backstage/plugin-org': patch +--- + +Introduced a new MySquads SidebarItem that links to one or more groups based on the logged in user's membership. + +To use MySquads you'll need to add it to your `Root.tsx` like this: + +```diff +// app/src/components/Root/Root.tsx ++ import { MySquads } from '@backstage/plugin-org'; ++ import GroupIcon from '@material-ui/icons/People'; + + + + //... + }> + {/* Global nav, not org-specific */} + //... + ++ + //... + + + +``` + +// app/src/components/home/HomePage.tsx diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 2441ace27b..0ff4507b52 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -47,6 +47,8 @@ import { SidebarScrollWrapper, SidebarSpace, } from '@backstage/core-components'; +import { MySquads } from '@backstage/plugin-org'; +import GroupIcon from '@material-ui/icons/People'; const useSidebarLogoStyles = makeStyles({ root: { @@ -92,6 +94,11 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( }> {/* Global nav, not org-specific */} + diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md index 723cbd43b7..d725958176 100644 --- a/plugins/org/api-report.md +++ b/plugins/org/api-report.md @@ -7,6 +7,7 @@ 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'; // Warning: (ae-missing-release-tag) "EntityGroupProfileCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -63,6 +64,19 @@ export const MembersListCard: (_props: { pageSize?: number; }) => JSX.Element; +// Warning: (ae-missing-release-tag) "MySquads" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const MySquads: ({ + singularTitle, + pluralTitle, + icon, +}: { + singularTitle: string; + pluralTitle: string; + icon: IconComponent; +}) => JSX.Element; + // Warning: (ae-missing-release-tag) "orgPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/plugins/org/src/components/MySquads/MySquads.test.tsx b/plugins/org/src/components/MySquads/MySquads.test.tsx new file mode 100644 index 0000000000..8424072d89 --- /dev/null +++ b/plugins/org/src/components/MySquads/MySquads.test.tsx @@ -0,0 +1,185 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { MySquads } from '.'; +import GroupIcon from '@material-ui/icons/People'; +import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api'; +import { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; + +describe('MySquads Test', () => { + describe('For guests or users with no groups', () => { + it('MySquads should be empty', async () => { + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/guest', + ownershipEntityRefs: ['user:default/guest'], + }), + }; + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [] as Entity[], + }), + }; + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.container).toBeEmptyDOMElement(); + }); + }); + + describe('For users that are members of a single group', () => { + it('MySquads should display a single item that links to their group', async () => { + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/nigel.manning', + ownershipEntityRefs: ['user:default/nigel.manning'], + }), + }; + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + title: 'Team A', + namespace: 'default', + }, + spec: { + type: 'team', + children: [], + }, + }, + ] as Entity[], + }), + }; + const rendered = await renderInTestApp( + + + , + ); + expect(rendered.getByLabelText('My Squad')).toBeInTheDocument(); + expect(rendered.getByLabelText('My Squad')).toHaveAttribute( + 'href', + '/catalog/default/Group/team-a', + ); + }); + }); + + describe('For users that are members of multiple groups', () => { + it('MySquads should display a sub-menu with all their groups and a link to each group', async () => { + const identityApi: Partial = { + getBackstageIdentity: async () => ({ + type: 'user', + userEntityRef: 'user:default/nigel.manning', + ownershipEntityRefs: ['user:default/nigel.manning'], + }), + }; + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + title: 'Team A', + namespace: 'default', + }, + spec: { + type: 'team', + children: [], + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-b', + title: 'Team B', + namespace: 'default', + }, + spec: { + type: 'team', + children: [], + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-c', + title: 'Team C', + namespace: 'default', + }, + spec: { + type: 'team', + children: [], + }, + }, + ] as Entity[], + }), + }; + const rendered = await renderInTestApp( + + + , + ); + + expect(rendered.getByLabelText('My Squads')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/org/src/components/MySquads/MySquads.tsx b/plugins/org/src/components/MySquads/MySquads.tsx new file mode 100644 index 0000000000..e1882708cb --- /dev/null +++ b/plugins/org/src/components/MySquads/MySquads.tsx @@ -0,0 +1,85 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { + SidebarItem, + SidebarSubmenu, + SidebarSubmenuItem, +} from '@backstage/core-components'; +import { + IconComponent, + identityApiRef, + useApi, +} from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog-react'; + +export const MySquads = ({ + singularTitle, + pluralTitle, + icon, +}: { + singularTitle: string; + pluralTitle: string; + icon: IconComponent; +}) => { + const identityApi = useApi(identityApiRef); + const catalogApi: CatalogApi = useApi(catalogApiRef); + + const { value: groups } = useAsync(async () => { + const profile = await identityApi.getBackstageIdentity(); + + const response = await catalogApi.getEntities({ + filter: [{ kind: 'group', 'relations.hasMember': profile.userEntityRef }], + fields: ['metadata', 'kind'], + }); + return response.items; + }, []); + + if (groups && groups.length === 1) { + // Only member of one group + const group = groups[0]; + return ( + + ); + } + + // Member of more than one group + // or not a member of any groups + return groups && groups.length > 1 ? ( + + + {groups?.map(function groupsMap(group) { + return ( + + ); + })} + + + ) : ( + <> + ); +}; diff --git a/plugins/org/src/components/MySquads/index.ts b/plugins/org/src/components/MySquads/index.ts new file mode 100644 index 0000000000..d529a78592 --- /dev/null +++ b/plugins/org/src/components/MySquads/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MySquads } from './MySquads'; diff --git a/plugins/org/src/components/index.ts b/plugins/org/src/components/index.ts index f0c92616fa..6ec70343c5 100644 --- a/plugins/org/src/components/index.ts +++ b/plugins/org/src/components/index.ts @@ -15,3 +15,4 @@ */ export * from './Cards'; +export { MySquads } from './MySquads';