diff --git a/.changeset/popular-ants-mix.md b/.changeset/popular-ants-mix.md new file mode 100644 index 0000000000..f0e6b17f8d --- /dev/null +++ b/.changeset/popular-ants-mix.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': minor +--- + +Updates the User and Group Profile cards to add the links from the UserEntity or the GroupEntity diff --git a/docs/features/software-catalog/external-integrations.md b/docs/features/software-catalog/external-integrations.md index 40f917e2f3..65f97fb8da 100644 --- a/docs/features/software-catalog/external-integrations.md +++ b/docs/features/software-catalog/external-integrations.md @@ -261,6 +261,129 @@ the `connect` call has been made to the provider. Start up the backend - it should now start reading from the previously registered location and you'll see your entities start to appear in Backstage. +### Example User Entity Provider + +If you have a 3rd party entity provider such as an internal HR system that you wish to use you are not limited to using our entity providers, (or simply wish to add to existing entity providers with your own data). + +We can create an entity provider to read entities that are based off that provider. + +We create a basic entity provider as shown above. In the example below we might want to extract our users from an HR system, I am assuming the HR system already has the slackUserId to get that information please see the [Slack Api](https://api.slack.com/methods). + +```typescript +import { + ANNOTATION_LOCATION, + ANNOTATION_ORIGIN_LOCATION, +} from '@backstage/catalog-model' +import { + EntityProvider, + EntityProviderConnection, +} from '@backstage/plugin-catalog-backend' +import { WebClient } from '@slack/web-api' +import {kebabCase} from 'lodash' + +interface Staff { + displayName: string + slackUserId: string + jobTitle: string + photoUrl: string + address: string + email:string +} + +export class UserEntityProvider implements EntityProvider { + private readonly getStaffUrl: string + protected readonly slackTeam: string + protected readonly slackToken: string + protected connection?: EntityProviderConnection + + static fromConfig(config: Config, options: { logger: Logger }) { + const getStaffUrl = config.getString('staff.url') + const slackToken = config.getString('slack.token') + const slackTeam = config.getString('slack.team') + return new UserEntityProvider({ + ...options, + getStaffUrl, + slackToken, + slackTeam, + }) + } + + private constructor(options: { + getStaffUrl: string + slackToken: string + slackTeam: string + }) { + this.getStaffUrl = options.getStaffUrl + this.slackToken = options.slackToken + this.slackTeam = options.slackTeam + } + + async getAllStaff(): Promise{ + await return axios.get(this.getStaffUrl) + } + + public async connect(connection: EntityProviderConnection): Promise { + this.connection = connection + } + + async run(): Promise { + if (!this.connection) { + throw new Error('User Connection Not initialized') + } + + const userResources: UserEntity[] = [] + const staff = await this.getAllStaff() + + for (const user of staff) { + // we can add any links here in this case it would be adding a slack link to the users so you can directly slack them. + const links = + user.slackUserId != null && user.slackUserId.length > 0 + ? [ + { + url: `slack://user?team=${this.slackTeam}&id=${user.slackUserId}`, + title: 'Slack', + icon: 'message', + }, + ] + : undefined + const userEntity: UserEntity = { + kind: 'User', + apiVersion: 'backstage.io/v1alpha1', + metadata: { + annotations: { + [ANNOTATION_LOCATION]: 'hr-user-https://www.hrurl.com/', + [ANNOTATION_ORIGIN_LOCATION]: 'hr-user-https://www.hrurl.com/', + }, + links, + // name of the entity + name: kebabCase(user.displayName), + // name for display purposes could be anything including email + title: user.displayName, + }, + spec: { + profile: { + displayName: user.displayName, + email: user.email, + picture: user.photoUrl, + }, + memberOf: [], + }, + } + + userResources.push(userEntity) + } + + await this.connection.applyMutation({ + type: 'full', + entities: userResources.map((entity) => ({ + entity, + locationKey: 'hr-user-https://www.hrurl.com/', + })), + }) +} + +``` + ## Custom Processors The other possible way of ingesting data into the catalog is through the use of diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx index fc45d9a8fe..cfb3d847a4 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.stories.tsx @@ -87,3 +87,44 @@ export default { ), ], }; + +const extraDetailsEntity: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: 'team-a', + description: 'Team A', + links: [ + { + url: 'slack://user?team=T00000000&id=U00000000', + title: 'Slack', + icon: 'chat', + }, + { + url: 'https://www.google.com', + title: 'Google', + }, + ], + }, + spec: { + profile: { + displayName: 'Team A', + email: 'team-a@example.com', + picture: + 'https://avatars.dicebear.com/api/identicon/team-a@example.com.svg?background=%23fff&margin=25', + }, + type: 'group', + children: [], + }, + relations: [dummyDepartment], +}; + +export const ExtraDetails = () => ( + + + + + + + +); diff --git a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx index dfdb566b20..3a6b7c238d 100644 --- a/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx +++ b/plugins/org/src/components/Cards/Group/GroupProfile/GroupProfileCard.tsx @@ -52,6 +52,7 @@ import { Link, } from '@backstage/core-components'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { LinksGroup } from '../../Meta'; const CardTitle = (props: { title: string }) => ( @@ -76,7 +77,7 @@ export const GroupProfileCard = (props: { variant?: InfoCardVariants }) => { } const { - metadata: { name, description, annotations }, + metadata: { name, description, annotations, links }, spec: { profile }, } = group; @@ -190,6 +191,7 @@ export const GroupProfileCard = (props: { variant?: InfoCardVariants }) => { secondary="Child Groups" /> + diff --git a/plugins/org/src/components/Cards/Meta/LinksGroup.tsx b/plugins/org/src/components/Cards/Meta/LinksGroup.tsx new file mode 100644 index 0000000000..eeade18411 --- /dev/null +++ b/plugins/org/src/components/Cards/Meta/LinksGroup.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EntityLink } from '@backstage/catalog-model'; +import { IconComponent, useApp } from '@backstage/core-plugin-api'; +import LanguageIcon from '@material-ui/icons/Language'; +import { + ListItem, + ListItemIcon, + ListItemText, + Divider, +} from '@material-ui/core'; +import React from 'react'; + +const WebLink = ({ + href, + Icon, + text, +}: { + href: string; + text?: string; + Icon?: IconComponent; +}) => ( + + {Icon ? : } + {text} + +); + +export const LinksGroup = ({ links }: { links?: EntityLink[] }) => { + const app = useApp(); + const iconResolver = (key?: string): IconComponent => + key ? app.getSystemIcon(key) ?? LanguageIcon : LanguageIcon; + + if (links === undefined) { + return null; + } + + return ( + <> + + {links.map(link => { + return ( + + ); + })} + + ); +}; diff --git a/plugins/org/src/components/Cards/Meta/index.ts b/plugins/org/src/components/Cards/Meta/index.ts new file mode 100644 index 0000000000..076af49bf9 --- /dev/null +++ b/plugins/org/src/components/Cards/Meta/index.ts @@ -0,0 +1,16 @@ +/* + * 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 * from './LinksGroup'; diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx index eb7b8fadda..a27be7857b 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.stories.tsx @@ -102,3 +102,43 @@ export default { }), ], }; + +const extraDetailsEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'guest', + description: 'Description for guest', + links: [ + { + url: 'slack://user?team=T00000000&id=U00000000', + title: 'Slack', + icon: 'chat', + }, + { + url: 'https://www.google.com', + title: 'Google', + }, + ], + }, + spec: { + profile: { + displayName: 'Guest User', + email: 'guest@example.com', + picture: + 'https://avatars.dicebear.com/api/avataaars/guest@example.com.svg?background=%23fff', + }, + memberOf: ['team-a'], + }, + relations: [dummyGroup], +}; + +export const ExtraDetails = () => ( + + + + + + + +); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx index f339a5828c..fb0df3d25f 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.test.tsx @@ -155,4 +155,58 @@ describe('Edit Button', () => { ); expect(rendered.getByRole('button')).toBeInTheDocument(); }); + + it('Should show the extra fields if either links or extra profile are filled', async () => { + const annotations: Record = { + 'backstage.io/edit-url': 'https://example.com/user.yaml', + }; + const userEntity: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { + name: 'calum.leavy', + description: 'Super awesome human', + annotations, + links: [ + { + url: 'slack://user?team=T00000000&id=U00000000', + title: 'Slack', + icon: 'message', + }, + { + url: 'https://www.google.com', + title: 'Google', + }, + ], + }, + spec: { + profile: { + displayName: 'Calum Leavy', + email: 'calum-leavy@example.com', + }, + memberOf: ['ExampleGroup'], + }, + relations: [ + { + type: 'memberOf', + targetRef: 'group:default/examplegroup', + }, + ], + }; + + const rendered = await renderWithEffects( + wrapInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ), + ); + expect(rendered.getByText('Slack')).toBeInTheDocument(); + expect(rendered.getByText('Google')).toBeInTheDocument(); + }); }); diff --git a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx index dd75578687..a35c1c057b 100644 --- a/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx +++ b/plugins/org/src/components/Cards/User/UserProfileCard/UserProfileCard.tsx @@ -46,6 +46,7 @@ import { InfoCardVariants, Link, } from '@backstage/core-components'; +import { LinksGroup } from '../../Meta'; const CardTitle = (props: { title?: string }) => props.title ? ( @@ -66,7 +67,7 @@ export const UserProfileCard = (props: { variant?: InfoCardVariants }) => { user.metadata.annotations?.[ANNOTATION_EDIT_URL]; const { - metadata: { name: metaName, description }, + metadata: { name: metaName, description, links }, spec: { profile }, } = user; const displayName = profile?.displayName ?? metaName; @@ -128,6 +129,8 @@ export const UserProfileCard = (props: { variant?: InfoCardVariants }) => { /> + +