diff --git a/.changeset/itchy-candles-type.md b/.changeset/itchy-candles-type.md new file mode 100644 index 0000000000..3507ecd867 --- /dev/null +++ b/.changeset/itchy-candles-type.md @@ -0,0 +1,30 @@ +--- +'@backstage/plugin-org': patch +--- + +Introduced a new MyGroupsSidebarItem SidebarItem that links to one or more groups based on the logged in user's membership. + +To use MyGroupsSidebarItem you'll need to add it to your `Root.tsx` like this: + +```diff +// app/src/components/Root/Root.tsx ++ import { MyGroupsSidebarItem } from '@backstage/plugin-org'; ++ import GroupIcon from '@material-ui/icons/People'; + + + + //... + }> + {/* Global nav, not org-specific */} + //... + ++ + //... + + + +``` diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 2441ace27b..e3c36c9329 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 { MyGroupsSidebarItem } 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/README.md b/plugins/org/README.md index a117f36722..0c5452aa62 100644 --- a/plugins/org/README.md +++ b/plugins/org/README.md @@ -4,3 +4,55 @@ - Show Group Page - Show User Profile +- Quick access to Groups + +### Group Page + +Here's an example of what the Group Page looks like: + +![Group Page example](./docs/group-page-example.png) + +### User Profile + +Here's an example of what the User Profile looks like: + +![Group Page example](./docs/user-profile-example.png) + +### MyGroupsSidebarItem + +The MyGroupsSidebarItem provides quick access to the group(s) the logged in user is a member of directly in the sidebar. + +To use the MyGroupsSidebarItem you'll need to add it to your `Root.tsx` - found at `packages\app\src\components\Root` - like this: + +```diff ++ import { MyGroupsSidebarItem } from '@backstage/plugin-org'; ++ import GroupIcon from '@material-ui/icons/People'; + + + + //... + }> + {/* Global nav, not org-specific */} + //... + ++ + //... + + + +``` + +Once added MyGroupsSidebarItem will work in three ways: + +1. The user is not logged in or the logged in user is not a member of any group: the MyGroupsSidebarItem will not display anything in the sidebar +2. The user is logged in and a member of only one group: the MyGroupsSidebarItem will display a single item in the sidebar like this: + + ![MyGroupsSidebarItem single example](./docs/mygroupssidebaritem-single.png) + +3. The user is logged in and a member of more than one group: the MyGroupsSidebarItem will display a single items with a sub-menu with all the related groups like this: + + ![MyGroupsSidebarItem multiple example](./docs/mygroupssidebaritem-multiple.png) diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md index 723cbd43b7..1050b2aaff 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,17 @@ export const MembersListCard: (_props: { pageSize?: number; }) => JSX.Element; +// @public +export const MyGroupsSidebarItem: ({ + singularTitle, + pluralTitle, + icon, +}: { + singularTitle: string; + pluralTitle: string; + icon: IconComponent; +}) => JSX.Element | null; + // 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/docs/group-page-example.png b/plugins/org/docs/group-page-example.png new file mode 100644 index 0000000000..f9770bd494 Binary files /dev/null and b/plugins/org/docs/group-page-example.png differ diff --git a/plugins/org/docs/mygroupssidebaritem-multiple.png b/plugins/org/docs/mygroupssidebaritem-multiple.png new file mode 100644 index 0000000000..90cf709e23 Binary files /dev/null and b/plugins/org/docs/mygroupssidebaritem-multiple.png differ diff --git a/plugins/org/docs/mygroupssidebaritem-single.png b/plugins/org/docs/mygroupssidebaritem-single.png new file mode 100644 index 0000000000..6c2ed4a768 Binary files /dev/null and b/plugins/org/docs/mygroupssidebaritem-single.png differ diff --git a/plugins/org/docs/user-profile-example.png b/plugins/org/docs/user-profile-example.png new file mode 100644 index 0000000000..7679a03cf4 Binary files /dev/null and b/plugins/org/docs/user-profile-example.png differ diff --git a/plugins/org/package.json b/plugins/org/package.json index eeb8d9656a..034e32177f 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -13,6 +13,11 @@ "backstage": { "role": "frontend-plugin" }, + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "plugins/org" + }, "scripts": { "build": "backstage-cli package build", "start": "backstage-cli package start", diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx new file mode 100644 index 0000000000..794dd23747 --- /dev/null +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.test.tsx @@ -0,0 +1,201 @@ +/* + * 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 { MyGroupsSidebarItem } from './MyGroupsSidebarItem'; +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, entityRouteRef } from '@backstage/plugin-catalog-react'; + +describe('MyGroupsSidebarItem Test', () => { + describe('For guests or users with no groups', () => { + it('MyGroupsSidebarItem 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( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + expect( + rendered.getByText('Mounted at /catalog/:namespace/:kind/:name'), + ).toBeInTheDocument(); + }); + }); + + describe('For users that are members of a single group', () => { + it('MyGroupsSidebarItem 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( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + 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('MyGroupsSidebarItem 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( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(rendered.getByLabelText('My Squads')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx new file mode 100644 index 0000000000..22f3e76848 --- /dev/null +++ b/plugins/org/src/components/MyGroupsSidebarItem/MyGroupsSidebarItem.tsx @@ -0,0 +1,100 @@ +/* + * 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, + useRouteRef, +} from '@backstage/core-plugin-api'; +import useAsync from 'react-use/lib/useAsync'; +import { + catalogApiRef, + CatalogApi, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { getCompoundEntityRef } from '@backstage/catalog-model'; + +/** + * MyGroupsSidebarItem can be added to your sidebar providing quick access to groups the logged in user is a member of + * + * @public + */ +export const MyGroupsSidebarItem = ({ + singularTitle, + pluralTitle, + icon, +}: { + singularTitle: string; + pluralTitle: string; + icon: IconComponent; +}) => { + const identityApi = useApi(identityApiRef); + const catalogApi: CatalogApi = useApi(catalogApiRef); + const catalogEntityRoute = useRouteRef(entityRouteRef); + + 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; + }, []); + + // Not a member of any groups + if (!groups?.length) { + return null; + } + + // Only member of one group + if (groups.length === 1) { + const group = groups[0]; + return ( + + ); + } + + // Member of more than one group + return ( + + + {groups?.map(function groupsMap(group) { + return ( + + ); + })} + + + ); +}; diff --git a/plugins/org/src/components/MyGroupsSidebarItem/index.ts b/plugins/org/src/components/MyGroupsSidebarItem/index.ts new file mode 100644 index 0000000000..088e768980 --- /dev/null +++ b/plugins/org/src/components/MyGroupsSidebarItem/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 { MyGroupsSidebarItem } from './MyGroupsSidebarItem'; diff --git a/plugins/org/src/components/index.ts b/plugins/org/src/components/index.ts index f0c92616fa..e8bb006171 100644 --- a/plugins/org/src/components/index.ts +++ b/plugins/org/src/components/index.ts @@ -15,3 +15,4 @@ */ export * from './Cards'; +export { MyGroupsSidebarItem } from './MyGroupsSidebarItem';