diff --git a/.changeset/giant-maps-compete.md b/.changeset/giant-maps-compete.md new file mode 100644 index 0000000000..776332b308 --- /dev/null +++ b/.changeset/giant-maps-compete.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-pagerduty': patch +--- + +Add a homepage widget for the `Pagerduty` component. diff --git a/packages/app/src/components/home/HomePage.tsx b/packages/app/src/components/home/HomePage.tsx index da1d0d425c..aa3739728e 100644 --- a/packages/app/src/components/home/HomePage.tsx +++ b/packages/app/src/components/home/HomePage.tsx @@ -30,6 +30,7 @@ import { HomePageCalendar } from '@backstage/plugin-gcalendar'; import { MicrosoftCalendarCard } from '@backstage/plugin-microsoft-calendar'; import React from 'react'; import HomeIcon from '@material-ui/icons/Home'; +import { HomePagePagerDutyCard } from '@backstage/plugin-pagerduty'; const clockConfigs: ClockConfig[] = [ { @@ -93,6 +94,7 @@ export const homePage = ( + diff --git a/plugins/pagerduty/README.md b/plugins/pagerduty/README.md index 2affe1c423..c0ba5c8fe0 100644 --- a/plugins/pagerduty/README.md +++ b/plugins/pagerduty/README.md @@ -95,6 +95,30 @@ annotations: pagerduty.com/integration-key: [INTEGRATION_KEY] ``` +### The homepage component + +You may also add the component to the homepage of Backstage. Edit the `HomePage.tsx` file, import `PagerDutyHomepageCard` and add it to the home page. e.g. + +```typescript jsx +... +export const homePage = ( + + ... + + + ... + + + + +); +``` + Next, provide the [API token](https://support.pagerduty.com/docs/generating-api-keys#generating-a-general-access-rest-api-key) that the client will use to make requests to the [PagerDuty API](https://developer.pagerduty.com/docs/rest-api-v2/rest-api/). Add the proxy configuration in `app-config.yaml`: diff --git a/plugins/pagerduty/api-report.md b/plugins/pagerduty/api-report.md index b460086976..dd24aa9bec 100644 --- a/plugins/pagerduty/api-report.md +++ b/plugins/pagerduty/api-report.md @@ -7,6 +7,7 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { CardExtensionProps } from '@backstage/plugin-home-react'; import { ConfigApi } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; @@ -14,7 +15,24 @@ import { FetchApi } from '@backstage/core-plugin-api'; import { ReactNode } from 'react'; // @public (undocumented) -export const EntityPagerDutyCard: (props: PagerDutyCardProps) => JSX.Element; +export const EntityPagerDutyCard: ( + props: EntityPagerDutyCardProps, +) => JSX.Element; + +// @public (undocumented) +export type EntityPagerDutyCardProps = { + readOnly?: boolean; +}; + +// @public (undocumented) +export const HomePagePagerDutyCard: ( + props: CardExtensionProps, +) => JSX.Element; + +// @public (undocumented) +export type HomePagePagerDutyCardProps = PagerDutyEntity & { + readOnly?: boolean; +}; // @public (undocumented) const isPluginApplicableToEntity: (entity: Entity) => boolean; @@ -31,6 +49,9 @@ export interface PagerDutyApi { ): Promise; getOnCallByPolicyId(policyId: string): Promise; getServiceByEntity(entity: Entity): Promise; + getServiceByPagerDutyEntity( + pagerDutyEntity: PagerDutyEntity, + ): Promise; triggerAlarm(request: PagerDutyTriggerAlarmRequest): Promise; } @@ -44,13 +65,11 @@ export type PagerDutyAssignee = { html_url: string; }; -// @public (undocumented) -export const PagerDutyCard: (props: PagerDutyCardProps) => JSX.Element; +// @public @deprecated (undocumented) +export const PagerDutyCard: (props: EntityPagerDutyCardProps) => JSX.Element; -// @public (undocumented) -export type PagerDutyCardProps = { - readOnly?: boolean; -}; +// @public @deprecated (undocumented) +export type PagerDutyCardProps = EntityPagerDutyCardProps; // @public (undocumented) export type PagerDutyChangeEvent = { @@ -98,6 +117,10 @@ export class PagerDutyClient implements PagerDutyApi { // (undocumented) getServiceByEntity(entity: Entity): Promise; // (undocumented) + getServiceByPagerDutyEntity( + pagerDutyEntity: PagerDutyEntity, + ): Promise; + // (undocumented) triggerAlarm(request: PagerDutyTriggerAlarmRequest): Promise; } diff --git a/plugins/pagerduty/dev/index.tsx b/plugins/pagerduty/dev/index.tsx index e45e0ffd8f..4ed8e07263 100644 --- a/plugins/pagerduty/dev/index.tsx +++ b/plugins/pagerduty/dev/index.tsx @@ -15,136 +15,12 @@ */ import React from 'react'; -import { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-react'; import { createDevApp } from '@backstage/dev-utils'; import { pagerDutyPlugin, EntityPagerDutyCard } from '../src/plugin'; import { pagerDutyApiRef } from '../src/api'; -import { PagerDutyApi, PagerDutyTriggerAlarmRequest } from '../src/api/types'; -import { - PagerDutyIncident, - PagerDutyChangeEvent, -} from '../src/components/types'; - -const mockEntity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'backstage', - description: 'backstage.io', - annotations: { - 'github.com/project-slug': 'backstage/backstage', - 'pagerduty.com/service-id': 'foo', - 'pagerduty.com/integration-key': 'foo', - }, - }, - spec: { - lifecycle: 'production', - type: 'website', - owner: 'user:guest', - }, -}; - -const mockPagerDutyApi: PagerDutyApi = { - async getServiceByEntity(entity: Entity) { - return { - service: { - name: entity.metadata.name, - integrationKey: 'key', - id: '123', - html_url: 'http://service', - escalation_policy: { - id: '123', - html_url: 'http://escalationpolicy', - user: { - id: '123', - summary: 'summary', - email: 'email@email.com', - html_url: 'http://user', - name: 'some-user', - }, - }, - }, - }; - }, - - async getIncidentsByServiceId(serviceId: string) { - const incident = (title: string) => { - return { - id: '123', - title: title, - status: 'acknowledged', - html_url: 'http://incident', - assignments: [ - { - assignee: { - id: '123', - summary: 'Jane Doe', - html_url: 'http://assignee', - }, - }, - ], - serviceId: serviceId, - created_at: '2015-10-06T21:30:42Z', - } as PagerDutyIncident; - }; - - return { - incidents: [ - incident('Some Alerting Incident'), - incident('Another Alerting Incident'), - ], - }; - }, - - async getChangeEventsByServiceId(serviceId: string) { - const changeEvent = (description: string) => { - return { - id: serviceId, - source: 'some-source', - html_url: 'http://changeevent', - links: [ - { - href: 'http://link', - text: 'link text', - }, - ], - summary: description, - timestamp: '2018-10-06T21:30:42Z', - } as PagerDutyChangeEvent; - }; - - return { - change_events: [ - changeEvent('us-east-1 deployment'), - changeEvent('us-west-2 deployment'), - ], - }; - }, - - async getOnCallByPolicyId() { - const oncall = (name: string, escalation: number) => { - return { - user: { - id: '123', - name: name, - html_url: 'http://assignee', - summary: 'summary', - email: 'email@email.com', - }, - escalation_level: escalation, - }; - }; - - return { - oncalls: [oncall('Jane Doe', 1), oncall('John Doe', 2)], - }; - }, - - async triggerAlarm(request: PagerDutyTriggerAlarmRequest) { - return new Response(request.description); - }, -}; +import { mockPagerDutyApi } from './mockPagerDutyApi'; +import { mockEntity } from './mockEntity'; createDevApp() .registerApi({ diff --git a/plugins/pagerduty/dev/mockEntity.ts b/plugins/pagerduty/dev/mockEntity.ts new file mode 100644 index 0000000000..251ba80706 --- /dev/null +++ b/plugins/pagerduty/dev/mockEntity.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2023 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 { Entity } from '@backstage/catalog-model'; + +export const mockEntity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'backstage', + description: 'backstage.io', + annotations: { + 'github.com/project-slug': 'backstage/backstage', + 'pagerduty.com/service-id': 'foo', + 'pagerduty.com/integration-key': 'foo', + }, + }, + spec: { + lifecycle: 'production', + type: 'website', + owner: 'user:guest', + }, +}; diff --git a/plugins/pagerduty/dev/mockPagerDutyApi.ts b/plugins/pagerduty/dev/mockPagerDutyApi.ts new file mode 100644 index 0000000000..f8836d7043 --- /dev/null +++ b/plugins/pagerduty/dev/mockPagerDutyApi.ts @@ -0,0 +1,146 @@ +/* + * Copyright 2023 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 { + PagerDutyApi, + PagerDutyChangeEvent, + PagerDutyEntity, + PagerDutyIncident, + PagerDutyTriggerAlarmRequest, +} from '../src'; +import { Entity } from '@backstage/catalog-model'; + +export const mockPagerDutyApi: PagerDutyApi = { + async getServiceByPagerDutyEntity(pagerDutyEntity: PagerDutyEntity) { + return { + service: { + name: pagerDutyEntity.name, + integrationKey: 'key', + id: '123', + html_url: 'http://service', + escalation_policy: { + id: '123', + html_url: 'http://escalationpolicy', + user: { + id: '123', + summary: 'summary', + email: 'email@email.com', + html_url: 'http://user', + name: 'some-user', + }, + }, + }, + }; + }, + + async getServiceByEntity(entity: Entity) { + return { + service: { + name: entity.metadata.name, + integrationKey: 'key', + id: '123', + html_url: 'http://service', + escalation_policy: { + id: '123', + html_url: 'http://escalationpolicy', + user: { + id: '123', + summary: 'summary', + email: 'email@email.com', + html_url: 'http://user', + name: 'some-user', + }, + }, + }, + }; + }, + + async getIncidentsByServiceId(serviceId: string) { + const incident = (title: string) => { + return { + id: '123', + title: title, + status: 'acknowledged', + html_url: 'http://incident', + assignments: [ + { + assignee: { + id: '123', + summary: 'Jane Doe', + html_url: 'http://assignee', + }, + }, + ], + serviceId: serviceId, + created_at: '2015-10-06T21:30:42Z', + } as PagerDutyIncident; + }; + + return { + incidents: [ + incident('Some Alerting Incident'), + incident('Another Alerting Incident'), + ], + }; + }, + + async getChangeEventsByServiceId(serviceId: string) { + const changeEvent = (description: string) => { + return { + id: serviceId, + source: 'some-source', + html_url: 'http://changeevent', + links: [ + { + href: 'http://link', + text: 'link text', + }, + ], + summary: description, + timestamp: '2018-10-06T21:30:42Z', + } as PagerDutyChangeEvent; + }; + + return { + change_events: [ + changeEvent('us-east-1 deployment'), + changeEvent('us-west-2 deployment'), + ], + }; + }, + + async getOnCallByPolicyId() { + const oncall = (name: string, escalation: number) => { + return { + user: { + id: '123', + name: name, + html_url: 'http://assignee', + summary: 'summary', + email: 'email@email.com', + }, + escalation_level: escalation, + }; + }; + + return { + oncalls: [oncall('Jane Doe', 1), oncall('John Doe', 2)], + }; + }, + + async triggerAlarm(request: PagerDutyTriggerAlarmRequest) { + return new Response(request.description); + }, +}; diff --git a/plugins/pagerduty/package.json b/plugins/pagerduty/package.json index 5437764465..dc40d566ce 100644 --- a/plugins/pagerduty/package.json +++ b/plugins/pagerduty/package.json @@ -38,6 +38,7 @@ "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-catalog-react": "workspace:^", + "@backstage/plugin-home-react": "workspace:^", "@backstage/theme": "workspace:^", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", diff --git a/plugins/pagerduty/src/api/client.ts b/plugins/pagerduty/src/api/client.ts index 7d99be219d..350b838951 100644 --- a/plugins/pagerduty/src/api/client.ts +++ b/plugins/pagerduty/src/api/client.ts @@ -30,6 +30,7 @@ import { createApiRef, ConfigApi } from '@backstage/core-plugin-api'; import { NotFoundError } from '@backstage/errors'; import { Entity } from '@backstage/catalog-model'; import { getPagerDutyEntity } from '../components/pagerDutyEntity'; +import { PagerDutyEntity } from '../types'; /** @public */ export class UnauthorizedError extends Error {} @@ -63,8 +64,10 @@ export class PagerDutyClient implements PagerDutyApi { constructor(private readonly config: PagerDutyClientApiConfig) {} - async getServiceByEntity(entity: Entity): Promise { - const { integrationKey, serviceId } = getPagerDutyEntity(entity); + async getServiceByPagerDutyEntity( + pagerDutyEntity: PagerDutyEntity, + ): Promise { + const { integrationKey, serviceId } = pagerDutyEntity; let response: PagerDutyServiceResponse; let url: string; @@ -92,6 +95,9 @@ export class PagerDutyClient implements PagerDutyApi { return response; } + async getServiceByEntity(entity: Entity): Promise { + return await this.getServiceByPagerDutyEntity(getPagerDutyEntity(entity)); + } async getIncidentsByServiceId( serviceId: string, ): Promise { diff --git a/plugins/pagerduty/src/api/types.ts b/plugins/pagerduty/src/api/types.ts index f1971d53d5..a878afc447 100644 --- a/plugins/pagerduty/src/api/types.ts +++ b/plugins/pagerduty/src/api/types.ts @@ -22,6 +22,7 @@ import { } from '../components/types'; import { DiscoveryApi, FetchApi } from '@backstage/core-plugin-api'; import { Entity } from '@backstage/catalog-model'; +import { PagerDutyEntity } from '../types'; export type PagerDutyServicesResponse = { services: PagerDutyService[]; @@ -57,6 +58,14 @@ export type PagerDutyTriggerAlarmRequest = { /** @public */ export interface PagerDutyApi { + /** + * Fetches the service for the provided pager duty Entity. + * + */ + getServiceByPagerDutyEntity( + pagerDutyEntity: PagerDutyEntity, + ): Promise; + /** * Fetches the service for the provided Entity. * diff --git a/plugins/pagerduty/src/components/EntityPagerDutyCard/index.test.tsx b/plugins/pagerduty/src/components/EntityPagerDutyCard/index.test.tsx new file mode 100644 index 0000000000..3f3a4ca1a8 --- /dev/null +++ b/plugins/pagerduty/src/components/EntityPagerDutyCard/index.test.tsx @@ -0,0 +1,386 @@ +/* + * Copyright 2020 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 { render, waitFor, fireEvent, act } from '@testing-library/react'; +import { + EntityPagerDutyCard, + isPluginApplicableToEntity, +} from '../EntityPagerDutyCard'; +import { Entity } from '@backstage/catalog-model'; +import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { NotFoundError } from '@backstage/errors'; +import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; +import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../../api'; +import { PagerDutyService, PagerDutyUser } from '../types'; + +import { alertApiRef } from '@backstage/core-plugin-api'; +import { ApiProvider } from '@backstage/core-app-api'; + +const entity: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/integration-key': 'abc123', + }, + }, +}; + +const entityWithoutAnnotations: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: {}, + }, +}; + +const entityWithServiceId: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/service-id': 'def456', + }, + }, +}; + +const entityWithAllAnnotations: Entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'pagerduty-test', + annotations: { + 'pagerduty.com/integration-key': 'abc123', + 'pagerduty.com/service-id': 'def456', + }, + }, +}; + +const user: PagerDutyUser = { + name: 'person1', + id: 'p1', + summary: 'person1', + email: 'person1@example.com', + html_url: 'http://a.com/id1', +}; + +const service: PagerDutyService = { + id: 'def456', + name: 'pagerduty-name', + html_url: 'www.example.com', + escalation_policy: { + id: 'def', + user: user, + html_url: 'http://a.com/id1', + }, + integrationKey: 'abc123', +}; + +const mockPagerDutyApi: Partial = { + getServiceByEntity: async () => ({ service }), + getServiceByPagerDutyEntity: async () => ({ service }), + getOnCallByPolicyId: async () => ({ oncalls: [] }), + getIncidentsByServiceId: async () => ({ incidents: [] }), +}; + +const apis = TestApiRegistry.from( + [pagerDutyApiRef, mockPagerDutyApi], + [alertApiRef, {}], +); + +describe('isPluginApplicableToEntity', () => { + describe('when entity has no annotations', () => { + it('returns false', () => { + expect(isPluginApplicableToEntity(entityWithoutAnnotations)).toBe(false); + }); + }); + + describe('when entity has the pagerduty.com/integration-key annotation', () => { + it('returns true', () => { + expect(isPluginApplicableToEntity(entity)).toBe(true); + }); + }); + + describe('when entity has the pagerduty.com/service-id annotation', () => { + it('returns true', () => { + expect(isPluginApplicableToEntity(entityWithServiceId)).toBe(true); + }); + }); + + describe('when entity has all annotations', () => { + it('returns true', () => { + expect(isPluginApplicableToEntity(entityWithAllAnnotations)).toBe(true); + }); + }); +}); + +describe('EntityPagerDutyCard', () => { + it('Render pagerduty', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockImplementationOnce(async () => ({ service })); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + + it('Handles custom error for missing token', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockRejectedValueOnce(new UnauthorizedError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Missing or invalid PagerDuty Token')).toBeInTheDocument(); + }); + + it('Handles custom NotFoundError', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockRejectedValueOnce(new NotFoundError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('PagerDuty Service Not Found')).toBeInTheDocument(); + }); + + it('handles general error', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockRejectedValueOnce(new Error('An error occurred')); + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText( + 'Error encountered while fetching information. An error occurred', + ), + ).toBeInTheDocument(); + }); + + it('opens the dialog when trigger button is clicked', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockImplementationOnce(async () => ({ service })); + + const { getByText, queryByTestId, getByRole } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + + const triggerLink = getByText('Create Incident'); + await act(async () => { + fireEvent.click(triggerLink); + }); + expect(getByRole('dialog')).toBeInTheDocument(); + }); + + describe('when entity has the pagerduty.com/service-id annotation', () => { + it('Renders PagerDuty service information', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockImplementationOnce(async () => ({ service })); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + + it('Handles custom error for missing token', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockRejectedValueOnce(new UnauthorizedError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByText('Missing or invalid PagerDuty Token'), + ).toBeInTheDocument(); + }); + + it('Handles custom NotFoundError', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockRejectedValueOnce(new NotFoundError()); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('PagerDuty Service Not Found')).toBeInTheDocument(); + }); + + it('handles general error', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockRejectedValueOnce(new Error('An error occurred')); + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + + expect( + getByText( + 'Error encountered while fetching information. An error occurred', + ), + ).toBeInTheDocument(); + }); + + it('disables the Create Incident button', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockImplementationOnce(async () => ({ service })); + + const { queryByTestId, getByTitle } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect( + getByTitle('Must provide an integration-key to create incidents') + .className, + ).toMatch('disabled'); + }); + }); + + describe('when entity has all annotations', () => { + it('queries by integration key', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockImplementationOnce(async () => ({ service })); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Create Incident')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + }); + }); + + describe('when entity has all annotations but the plugin has been configured to be "read only"', () => { + it('queries by integration key but does not render the "Create Incident" button', async () => { + mockPagerDutyApi.getServiceByPagerDutyEntity = jest + .fn() + .mockImplementationOnce(async () => ({ service })); + + const { getByText, queryByTestId } = render( + wrapInTestApp( + + + + + , + ), + ); + await waitFor(() => !queryByTestId('progress')); + expect(getByText('Service Directory')).toBeInTheDocument(); + expect(getByText('Nice! No incidents found!')).toBeInTheDocument(); + expect(getByText('Empty escalation policy')).toBeInTheDocument(); + expect(() => getByText('Create Incident')).toThrow(); + }); + }); +}); diff --git a/plugins/pagerduty/src/components/EntityPagerDutyCard/index.tsx b/plugins/pagerduty/src/components/EntityPagerDutyCard/index.tsx new file mode 100644 index 0000000000..50e6f3a096 --- /dev/null +++ b/plugins/pagerduty/src/components/EntityPagerDutyCard/index.tsx @@ -0,0 +1,41 @@ +/* + * Copyright 2020 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 { Entity } from '@backstage/catalog-model'; +import { PAGERDUTY_INTEGRATION_KEY, PAGERDUTY_SERVICE_ID } from '../constants'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { getPagerDutyEntity } from '../pagerDutyEntity'; +import { PagerDutyCard } from '../PagerDutyCard'; + +/** @public */ +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean( + entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY] || + entity.metadata.annotations?.[PAGERDUTY_SERVICE_ID], + ); + +/** @public */ +export type EntityPagerDutyCardProps = { + readOnly?: boolean; +}; + +/** @public */ +export const EntityPagerDutyCard = (props: EntityPagerDutyCardProps) => { + const { readOnly } = props; + const { entity } = useEntity(); + const pagerDutyEntity = getPagerDutyEntity(entity); + return ; +}; diff --git a/plugins/pagerduty/src/components/Errors/ServiceNotFoundError.tsx b/plugins/pagerduty/src/components/Errors/ServiceNotFoundError.tsx index d8b61ef7cd..236585735e 100644 --- a/plugins/pagerduty/src/components/Errors/ServiceNotFoundError.tsx +++ b/plugins/pagerduty/src/components/Errors/ServiceNotFoundError.tsx @@ -21,7 +21,7 @@ export const ServiceNotFoundError = () => ( { + return ; +}; diff --git a/plugins/pagerduty/src/components/HomePagePagerDutyCard/index.ts b/plugins/pagerduty/src/components/HomePagePagerDutyCard/index.ts new file mode 100644 index 0000000000..e9038852a6 --- /dev/null +++ b/plugins/pagerduty/src/components/HomePagePagerDutyCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 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 { Content } from './Content'; +export type { HomePagePagerDutyCardProps } from './Content'; diff --git a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx index 34c8914fde..6f4501450c 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard/index.test.tsx @@ -15,9 +15,7 @@ */ import React from 'react'; import { render, waitFor, fireEvent, act } from '@testing-library/react'; -import { PagerDutyCard, isPluginApplicableToEntity } from '../PagerDutyCard'; -import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { PagerDutyCard } from '../PagerDutyCard'; import { NotFoundError } from '@backstage/errors'; import { TestApiRegistry, wrapInTestApp } from '@backstage/test-utils'; import { pagerDutyApiRef, UnauthorizedError, PagerDutyClient } from '../../api'; @@ -26,49 +24,6 @@ import { PagerDutyService, PagerDutyUser } from '../types'; import { alertApiRef } from '@backstage/core-plugin-api'; import { ApiProvider } from '@backstage/core-app-api'; -const entity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'pagerduty-test', - annotations: { - 'pagerduty.com/integration-key': 'abc123', - }, - }, -}; - -const entityWithoutAnnotations: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'pagerduty-test', - annotations: {}, - }, -}; - -const entityWithServiceId: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'pagerduty-test', - annotations: { - 'pagerduty.com/service-id': 'def456', - }, - }, -}; - -const entityWithAllAnnotations: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'pagerduty-test', - annotations: { - 'pagerduty.com/integration-key': 'abc123', - 'pagerduty.com/service-id': 'def456', - }, - }, -}; - const user: PagerDutyUser = { name: 'person1', id: 'p1', @@ -100,44 +55,16 @@ const apis = TestApiRegistry.from( [alertApiRef, {}], ); -describe('isPluginApplicableToEntity', () => { - describe('when entity has no annotations', () => { - it('returns false', () => { - expect(isPluginApplicableToEntity(entityWithoutAnnotations)).toBe(false); - }); - }); - - describe('when entity has the pagerduty.com/integration-key annotation', () => { - it('returns true', () => { - expect(isPluginApplicableToEntity(entity)).toBe(true); - }); - }); - - describe('when entity has the pagerduty.com/service-id annotation', () => { - it('returns true', () => { - expect(isPluginApplicableToEntity(entityWithServiceId)).toBe(true); - }); - }); - - describe('when entity has all annotations', () => { - it('returns true', () => { - expect(isPluginApplicableToEntity(entityWithAllAnnotations)).toBe(true); - }); - }); -}); - -describe('PageDutyCard', () => { +describe('PagerDutyCard', () => { it('Render pagerduty', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockImplementationOnce(async () => ({ service })); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -149,16 +76,14 @@ describe('PageDutyCard', () => { }); it('Handles custom error for missing token', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockRejectedValueOnce(new UnauthorizedError()); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -167,16 +92,14 @@ describe('PageDutyCard', () => { }); it('Handles custom NotFoundError', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockRejectedValueOnce(new NotFoundError()); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -185,15 +108,13 @@ describe('PageDutyCard', () => { }); it('handles general error', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockRejectedValueOnce(new Error('An error occurred')); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -207,16 +128,14 @@ describe('PageDutyCard', () => { }); it('opens the dialog when trigger button is clicked', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockImplementationOnce(async () => ({ service })); const { getByText, queryByTestId, getByRole } = render( wrapInTestApp( - - - + , ), ); @@ -232,16 +151,14 @@ describe('PageDutyCard', () => { describe('when entity has the pagerduty.com/service-id annotation', () => { it('Renders PagerDuty service information', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockImplementationOnce(async () => ({ service })); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -253,16 +170,18 @@ describe('PageDutyCard', () => { }); it('Handles custom error for missing token', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockRejectedValueOnce(new UnauthorizedError()); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -273,16 +192,18 @@ describe('PageDutyCard', () => { }); it('Handles custom NotFoundError', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockRejectedValueOnce(new NotFoundError()); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -291,15 +212,17 @@ describe('PageDutyCard', () => { }); it('handles general error', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockRejectedValueOnce(new Error('An error occurred')); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -313,16 +236,14 @@ describe('PageDutyCard', () => { }); it('disables the Create Incident button', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockImplementationOnce(async () => ({ service })); const { queryByTestId, getByTitle } = render( wrapInTestApp( - - - + , ), ); @@ -336,16 +257,18 @@ describe('PageDutyCard', () => { describe('when entity has all annotations', () => { it('queries by integration key', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockImplementationOnce(async () => ({ service })); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); @@ -359,16 +282,19 @@ describe('PageDutyCard', () => { describe('when entity has all annotations but the plugin has been configured to be "read only"', () => { it('queries by integration key but does not render the "Create Incident" button', async () => { - mockPagerDutyApi.getServiceByEntity = jest + mockPagerDutyApi.getServiceByPagerDutyEntity = jest .fn() .mockImplementationOnce(async () => ({ service })); const { getByText, queryByTestId } = render( wrapInTestApp( - - - + , ), ); diff --git a/plugins/pagerduty/src/components/PagerDutyCard/index.tsx b/plugins/pagerduty/src/components/PagerDutyCard/index.tsx index fd23166d88..bb89865c5f 100644 --- a/plugins/pagerduty/src/components/PagerDutyCard/index.tsx +++ b/plugins/pagerduty/src/components/PagerDutyCard/index.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ import React, { ReactNode, useState, useCallback } from 'react'; -import { Entity } from '@backstage/catalog-model'; import { Card, CardHeader, Divider, CardContent } from '@material-ui/core'; import { Incidents } from '../Incident'; import { EscalationPolicy } from '../Escalation'; @@ -25,7 +24,6 @@ import AlarmAddIcon from '@material-ui/icons/AlarmAdd'; import { MissingTokenError, ServiceNotFoundError } from '../Errors'; import WebIcon from '@material-ui/icons/Web'; import DateRangeIcon from '@material-ui/icons/DateRange'; -import { PAGERDUTY_INTEGRATION_KEY, PAGERDUTY_SERVICE_ID } from '../constants'; import { TriggerDialog } from '../TriggerDialog'; import { ChangeEvents } from '../ChangeEvents'; @@ -39,30 +37,20 @@ import { CardTab, InfoCard, } from '@backstage/core-components'; -import { useEntity } from '@backstage/plugin-catalog-react'; -import { getPagerDutyEntity } from '../pagerDutyEntity'; +import { PagerDutyEntity } from '../../types'; const BasicCard = ({ children }: { children: ReactNode }) => ( {children} ); /** @public */ -export const isPluginApplicableToEntity = (entity: Entity) => - Boolean( - entity.metadata.annotations?.[PAGERDUTY_INTEGRATION_KEY] || - entity.metadata.annotations?.[PAGERDUTY_SERVICE_ID], - ); - -/** @public */ -export type PagerDutyCardProps = { +export type PagerDutyCardProps = PagerDutyEntity & { readOnly?: boolean; }; /** @public */ export const PagerDutyCard = (props: PagerDutyCardProps) => { - const { readOnly } = props; - const { entity } = useEntity(); - const pagerDutyEntity = getPagerDutyEntity(entity); + const { readOnly, integrationKey, name } = props; const api = useApi(pagerDutyApiRef); const [refreshIncidents, setRefreshIncidents] = useState(false); const [refreshChangeEvents, setRefreshChangeEvents] = @@ -86,7 +74,9 @@ export const PagerDutyCard = (props: PagerDutyCardProps) => { loading, error, } = useAsync(async () => { - const { service: foundService } = await api.getServiceByEntity(entity); + const { service: foundService } = await api.getServiceByPagerDutyEntity( + props, + ); return { id: foundService.id, @@ -137,7 +127,7 @@ export const PagerDutyCard = (props: PagerDutyCardProps) => { * There is no guarantee the current user entity has a valid email association, so instead just * only allow triggering incidents when an integration key is present. */ - const createIncidentDisabled = !pagerDutyEntity.integrationKey; + const createIncidentDisabled = !integrationKey; const triggerLink: IconLinkVerticalProps = { label: 'Create Incident', onClick: showDialog, @@ -195,6 +185,8 @@ export const PagerDutyCard = (props: PagerDutyCardProps) => { showDialog={dialogShown} handleDialog={hideDialog} onIncidentCreated={handleRefresh} + name={name} + integrationKey={integrationKey} /> )} diff --git a/plugins/pagerduty/src/components/TriggerButton/index.tsx b/plugins/pagerduty/src/components/TriggerButton/index.tsx index 732298c6d8..1c12103d73 100644 --- a/plugins/pagerduty/src/components/TriggerButton/index.tsx +++ b/plugins/pagerduty/src/components/TriggerButton/index.tsx @@ -33,7 +33,7 @@ const useStyles = makeStyles(theme => ({ /** @public */ export function TriggerButton(props: { children?: ReactNode }) { const { buttonStyle } = useStyles(); - const { integrationKey } = usePagerdutyEntity(); + const { integrationKey, name } = usePagerdutyEntity(); const [dialogShown, setDialogShown] = useState(false); const showDialog = useCallback(() => { @@ -57,7 +57,12 @@ export function TriggerButton(props: { children?: ReactNode }) { : 'Missing integration key'} {integrationKey && ( - + )} ); diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx index c6874cc7f3..74cd5100fa 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.test.tsx @@ -17,8 +17,6 @@ import React from 'react'; import { fireEvent, act } from '@testing-library/react'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; import { pagerDutyApiRef } from '../../api'; -import { Entity } from '@backstage/catalog-model'; -import { EntityProvider } from '@backstage/plugin-catalog-react'; import { TriggerDialog } from './TriggerDialog'; import { ApiProvider } from '@backstage/core-app-api'; @@ -49,26 +47,15 @@ describe('TriggerDialog', () => { ); it('open the dialog and trigger an alarm', async () => { - const entity: Entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'pagerduty-test', - annotations: { - 'pagerduty.com/integration-key': 'abc123', - }, - }, - }; - const { getByText, getByRole, getByTestId } = await renderInTestApp( - - {}} - onIncidentCreated={() => {}} - /> - + {}} + onIncidentCreated={() => {}} + /> , ); @@ -89,8 +76,7 @@ describe('TriggerDialog', () => { }); expect(mockTriggerAlarmFn).toHaveBeenCalled(); expect(mockTriggerAlarmFn).toHaveBeenCalledWith({ - integrationKey: - entity!.metadata!.annotations!['pagerduty.com/integration-key'], + integrationKey: 'abc123', source: window.location.toString(), description, userName: 'guest', diff --git a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx index 4dc4f04699..a1e88b8fa6 100644 --- a/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx +++ b/plugins/pagerduty/src/components/TriggerDialog/TriggerDialog.tsx @@ -28,7 +28,6 @@ import { import useAsyncFn from 'react-use/lib/useAsyncFn'; import { pagerDutyApiRef } from '../../api'; import { Alert } from '@material-ui/lab'; -import { usePagerdutyEntity } from '../../hooks'; import { useApi, alertApiRef, @@ -40,14 +39,17 @@ type Props = { showDialog: boolean; handleDialog: () => void; onIncidentCreated?: () => void; + name: string; + integrationKey: string; }; export const TriggerDialog = ({ showDialog, handleDialog, onIncidentCreated: onIncidentCreated, + name, + integrationKey, }: Props) => { - const { name, integrationKey } = usePagerdutyEntity(); const alertApi = useApi(alertApiRef); const identityApi = useApi(identityApiRef); const api = useApi(pagerDutyApiRef); diff --git a/plugins/pagerduty/src/components/index.ts b/plugins/pagerduty/src/components/index.ts index e7c1ff4e72..264d7195e9 100644 --- a/plugins/pagerduty/src/components/index.ts +++ b/plugins/pagerduty/src/components/index.ts @@ -23,12 +23,13 @@ export type { PagerDutyUser, } from './types'; -export type { PagerDutyCardProps } from './PagerDutyCard'; +export type { EntityPagerDutyCardProps } from './EntityPagerDutyCard'; +export type { HomePagePagerDutyCardProps } from './HomePagePagerDutyCard'; export { isPluginApplicableToEntity, isPluginApplicableToEntity as isPagerDutyAvailable, - PagerDutyCard, -} from './PagerDutyCard'; + EntityPagerDutyCard, +} from './EntityPagerDutyCard'; export { TriggerButton } from './TriggerButton'; diff --git a/plugins/pagerduty/src/deprecated.ts b/plugins/pagerduty/src/deprecated.ts new file mode 100644 index 0000000000..e9f60f6894 --- /dev/null +++ b/plugins/pagerduty/src/deprecated.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2023 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 { EntityPagerDutyCardProps, EntityPagerDutyCard } from './components'; + +/** + * @public + * @deprecated Please use EntityPagerDutyCard + */ +export const PagerDutyCard = EntityPagerDutyCard; + +/** + * @public + * @deprecated Please use EntityPagerDutyCardProps + */ +export type PagerDutyCardProps = EntityPagerDutyCardProps; diff --git a/plugins/pagerduty/src/index.ts b/plugins/pagerduty/src/index.ts index 2e0eddc9a8..c6f7aef89a 100644 --- a/plugins/pagerduty/src/index.ts +++ b/plugins/pagerduty/src/index.ts @@ -24,9 +24,12 @@ export { pagerDutyPlugin, pagerDutyPlugin as plugin, EntityPagerDutyCard, + HomePagePagerDutyCard, } from './plugin'; export * from './components'; + export * from './api'; +export * from './deprecated'; export type { PagerDutyEntity } from './types'; diff --git a/plugins/pagerduty/src/plugin.ts b/plugins/pagerduty/src/plugin.ts index 73e7e0b397..59cc5cd8cc 100644 --- a/plugins/pagerduty/src/plugin.ts +++ b/plugins/pagerduty/src/plugin.ts @@ -23,6 +23,8 @@ import { configApiRef, createComponentExtension, } from '@backstage/core-plugin-api'; +import { createCardExtension } from '@backstage/plugin-home-react'; +import { HomePagePagerDutyCardProps } from './components/HomePagePagerDutyCard/Content'; export const rootRouteRef = createRouteRef({ id: 'pagerduty', @@ -51,7 +53,38 @@ export const EntityPagerDutyCard = pagerDutyPlugin.provide( name: 'EntityPagerDutyCard', component: { lazy: () => - import('./components/PagerDutyCard').then(m => m.PagerDutyCard), + import('./components/EntityPagerDutyCard').then( + m => m.EntityPagerDutyCard, + ), + }, + }), +); + +/** @public */ +export const HomePagePagerDutyCard = pagerDutyPlugin.provide( + createCardExtension({ + name: 'HomePagePagerDutyCard', + title: 'PagerDuty Homepage Card', + components: () => import('./components/HomePagePagerDutyCard'), + settings: { + schema: { + title: 'PagerDuty', + type: 'object', + properties: { + integrationKey: { + title: 'PagerDuty integration key', + type: 'string', + }, + serviceId: { + title: 'PagerDuty service id', + type: 'string', + }, + name: { + title: 'PagerDuty service name', + type: 'string', + }, + }, + }, }, }), ); diff --git a/yarn.lock b/yarn.lock index ee0025fdda..712fd10e6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7890,6 +7890,7 @@ __metadata: "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" + "@backstage/plugin-home-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@material-ui/core": ^4.12.2