diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index c2483b40ee..393ffe2631 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -49,6 +49,11 @@ import { import { Entity } from '@backstage/catalog-model'; import { Button, Grid } from '@material-ui/core'; import { EmptyState } from '@backstage/core'; +import { + EmbeddedRouter as LighthouseRouter, + LastLighthouseAuditCard, + isPluginApplicableToEntity as isLighthouseAvailable, +} from '@backstage/plugin-lighthouse/'; const CICDSwitcher = ({ entity }: { entity: Entity }) => { // This component is just an example of how you can implement your company's logic in entity page. @@ -115,6 +120,11 @@ const OverviewContent = ({ entity }: { entity: Entity }) => ( + {isLighthouseAvailable(entity) && ( + + + + )} ); @@ -165,6 +175,11 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( title="CI/CD" element={} /> + } + /> NOTE: The lighthouse plugin only supports one website url per component at this time. + +Add a lighthouse tab to the EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { EmbeddedRouter as LighthouseRouter } from '@backstage/plugin-lighthouse'; + +// ... +const WebsiteEntityPage = ({ entity }: { entity: Entity }) => ( + + // ... + } + /> + +); +``` + +> NOTE: The embedded router renders page content without a header section allowing it to be rendered within a +> catalog plugin page. + +Add a Lighthouse card to the overview tab on the EntityPage: + +```tsx +// packages/app/src/components/catalog/EntityPage.tsx +import { + LastLighthouseAuditCard, + isPluginApplicableToEntity as isLighthouseAvailable, +} from '@backstage/plugin-lighthouse'; + +// ... + +const OverviewContent = ({ entity }: { entity: Entity }) => ( + + // ... + {isLighthouseAvailable(entity) && ( + + + + )} + +); +``` diff --git a/plugins/lighthouse/constants.ts b/plugins/lighthouse/constants.ts new file mode 100644 index 0000000000..7a60e2be67 --- /dev/null +++ b/plugins/lighthouse/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 const LIGHTHOUSE_WEBSITE_URL_ANNOTATION = 'lighthouse.com/website-url'; diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 0b30e48689..7446f09ed4 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -21,17 +21,22 @@ "start": "backstage-cli plugin:serve" }, "dependencies": { + "@backstage/catalog-model": "^0.1.1-alpha.24", "@backstage/config": "^0.1.1-alpha.24", "@backstage/core": "^0.1.1-alpha.24", + "@backstage/core-api": "^0.1.1-alpha.24", + "@backstage/plugin-catalog": "^0.1.1-alpha.24", "@backstage/theme": "^0.1.1-alpha.24", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", + "@testing-library/react-hooks": "^3.4.2", "react": "^16.13.1", "react-dom": "^16.13.1", "react-markdown": "^4.3.1", "react-router-dom": "6.0.0-beta.0", - "react-use": "^15.3.3" + "react-use": "^15.3.3", + "@types/react": "^16.9" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.24", diff --git a/plugins/lighthouse/src/Router.tsx b/plugins/lighthouse/src/Router.tsx index 46843504fd..df481483c3 100644 --- a/plugins/lighthouse/src/Router.tsx +++ b/plugins/lighthouse/src/Router.tsx @@ -15,11 +15,18 @@ */ import React from 'react'; -import { Routes, Route } from 'react-router-dom'; -import { rootRouteRef, viewAuditRouteRef, createAuditRouteRef } from './plugin'; +import { Route, Routes } from 'react-router-dom'; +import { createAuditRouteRef, rootRouteRef, viewAuditRouteRef } from './plugin'; import AuditList from './components/AuditList'; -import AuditView from './components/AuditView'; -import CreateAudit from './components/CreateAudit'; +import AuditView, { AuditViewContent } from './components/AuditView'; +import CreateAudit, { CreateAuditContent } from './components/CreateAudit'; +import { Entity } from '@backstage/catalog-model'; +import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../constants'; +import { AuditListForEntity } from './components/AuditList/AuditListForEntity'; +import { EmptyState } from '@backstage/core'; + +export const isPluginApplicableToEntity = (entity: Entity) => + Boolean(entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION]); export const Router = () => ( @@ -28,3 +35,24 @@ export const Router = () => ( } /> ); + +export const EmbeddedRouter = ({ entity }: { entity: Entity }) => + !isPluginApplicableToEntity(entity) ? ( + + ) : ( + + } /> + } + /> + } + /> + + ); diff --git a/plugins/lighthouse/src/api.ts b/plugins/lighthouse/src/api.ts index 5b6cd9aae7..03b42a38b9 100644 --- a/plugins/lighthouse/src/api.ts +++ b/plugins/lighthouse/src/api.ts @@ -104,6 +104,7 @@ export type LighthouseApi = { getWebsiteList: (listOptions: LASListRequest) => Promise; getWebsiteForAuditId: (auditId: string) => Promise; triggerAudit: (payload: TriggerAuditPayload) => Promise; + getWebsiteByUrl: (websiteUrl: string) => Promise; }; export const lighthouseApiRef = createApiRef({ @@ -150,4 +151,10 @@ export class LighthouseRestApi implements LighthouseApi { }, }); } + + async getWebsiteByUrl(websiteUrl: string): Promise { + return this.fetch( + `/v1/websites/${encodeURIComponent(websiteUrl)}`, + ); + } } diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx new file mode 100644 index 0000000000..7f10914d9c --- /dev/null +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.test.tsx @@ -0,0 +1,144 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 } from '@testing-library/react'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { + lighthouseApiRef, + LighthouseRestApi, + WebsiteListResponse, +} from '../../api'; +import mockFetch from 'jest-fetch-mock'; + +import * as data from '../../__fixtures__/website-list-response.json'; +import { EntityContext } from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { AuditListForEntity } from './AuditListForEntity'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { MemoryRouter } from 'react-router-dom'; +import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; + +jest.mock('../../hooks/useWebsiteForEntity', () => ({ + useWebsiteForEntity: jest.fn(), +})); + +const websiteListResponse = data as WebsiteListResponse; +const entityWebsite = websiteListResponse.items[0]; + +describe('', () => { + let apis: ApiRegistry; + + const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), + }; + + beforeEach(() => { + apis = ApiRegistry.from([ + [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], + [errorApiRef, mockErrorApi], + ]); + mockFetch.mockResponse(JSON.stringify(entityWebsite)); + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: entityWebsite, + loading: false, + error: null, + }); + }); + + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'lighthouse.com/website-url': entityWebsite.url, + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + + const subject = (value = {}) => + render( + + + + + + + + + , + ); + + it('renders the audit list for the entity', async () => { + const { findByText } = subject(); + expect(await findByText(entityWebsite.url)).toBeInTheDocument(); + }); + + describe('where the data is loading', () => { + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: null, + loading: true, + error: null, + }); + }); + + it('renders a Progress element', async () => { + const { findByTestId } = subject(); + expect(await findByTestId('progress')).toBeInTheDocument(); + }); + }); + + describe('where there is an error loading data', () => { + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: null, + loading: false, + error: 'error', + }); + }); + + it('renders nothing', async () => { + const { queryByTestId } = subject(); + expect(await queryByTestId('AuditListTable')).toBeNull(); + }); + }); + + describe('where there is not data', () => { + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: null, + loading: false, + error: null, + }); + }); + + it('renders nothing', async () => { + const { queryByTestId } = subject(); + expect(await queryByTestId('AuditListTable')).toBeNull(); + }); + }); +}); diff --git a/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx new file mode 100644 index 0000000000..616fd0fa8a --- /dev/null +++ b/plugins/lighthouse/src/components/AuditList/AuditListForEntity.tsx @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { AuditListTable } from './AuditListTable'; +import { Progress } from '@backstage/core'; +import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; + +export const AuditListForEntity = () => { + const { value, loading, error } = useWebsiteForEntity(); + if (loading) { + return ; + } + if (error || !value) { + return null; + } + + return ; +}; diff --git a/plugins/lighthouse/src/components/AuditView/index.tsx b/plugins/lighthouse/src/components/AuditView/index.tsx index d0e743a77f..54bd179418 100644 --- a/plugins/lighthouse/src/components/AuditView/index.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.tsx @@ -124,7 +124,7 @@ const AuditView: FC<{ audit?: Audit }> = ({ audit }: { audit?: Audit }) => { ); }; -const ConnectedAuditView: FC<{}> = () => { +export const AuditViewContent: FC<{}> = () => { const lighthouseApi = useApi(lighthouseApiRef); const params = useParams() as { id: string }; const classes = useStyles(); @@ -173,32 +173,35 @@ const ConnectedAuditView: FC<{}> = () => { } return ( - -
+ - - -
- - navigate(`../../${createAuditButtonUrl}`)} > - - - - {content} - -
+ Create New Audit + + + + {content} + ); }; +const ConnectedAuditView = () => ( + +
+ + +
+ + + +
+); + export default ConnectedAuditView; diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx new file mode 100644 index 0000000000..20b3b9c072 --- /dev/null +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.test.tsx @@ -0,0 +1,172 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 } from '@testing-library/react'; +import { + AuditCompleted, + LighthouseCategoryId, + WebsiteListResponse, +} from '../../api'; +import { EntityContext } from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; +import { lightTheme } from '@backstage/theme'; +import { ThemeProvider } from '@material-ui/core'; +import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; +import { MemoryRouter } from 'react-router-dom'; +import * as data from '../../__fixtures__/website-list-response.json'; + +jest.mock('../../hooks/useWebsiteForEntity', () => ({ + useWebsiteForEntity: jest.fn(), +})); + +const websiteListResponse = data as WebsiteListResponse; +let entityWebsite = websiteListResponse.items[2]; + +describe('', () => { + const asPercentage = (fraction: number) => `${fraction * 100}%`; + + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: entityWebsite, + loading: false, + error: null, + }); + }); + + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'lighthouse.com/website-url': entityWebsite.url, + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + + const subject = (value = {}) => + render( + + + + + + + , + ); + + describe('where the last audit completed successfully', () => { + const audit = entityWebsite.lastAudit as AuditCompleted; + + it('renders the performance data for the audit', async () => { + const { findByText } = subject(); + expect(await findByText(audit.url)).toBeInTheDocument(); + expect(await findByText(audit.status)).toBeInTheDocument(); + for (const category of Object.keys(audit.categories)) { + const { score } = audit.categories[category as LighthouseCategoryId]; + expect(await findByText(asPercentage(score))).toBeInTheDocument(); + } + }); + + describe('where a category score is not a number', () => { + beforeEach(() => { + entityWebsite = { ...entityWebsite }; + (entityWebsite.lastAudit as AuditCompleted).categories.accessibility.score = NaN; + }); + + afterEach(() => { + entityWebsite = websiteListResponse.items[2]; + }); + + it('renders the performance data for the audit', async () => { + const { findByText } = subject(); + expect(await findByText('N/A')).toBeInTheDocument(); + }); + }); + }); + + describe('where the last audit is in running', () => { + const audit = websiteListResponse.items[0].lastAudit as AuditCompleted; + + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: websiteListResponse.items[0], + loading: false, + error: null, + }); + }); + + it('renders the url and status of the audit', async () => { + const { findByText } = subject(); + expect(await findByText(audit.url)).toBeInTheDocument(); + expect(await findByText(audit.status)).toBeInTheDocument(); + }); + }); + + describe('where the data is loading', () => { + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: null, + loading: true, + error: null, + }); + }); + + it('renders a Progress element', async () => { + const { findByTestId } = subject(); + expect(await findByTestId('progress')).toBeInTheDocument(); + }); + }); + + describe('where there is an error loading data', () => { + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: null, + loading: false, + error: 'error', + }); + }); + + it('renders nothing', async () => { + const { queryByTestId } = subject(); + expect(await queryByTestId('AuditListTable')).toBeNull(); + }); + }); + // + describe('where there is no data', () => { + beforeEach(() => { + (useWebsiteForEntity as jest.Mock).mockReturnValue({ + value: null, + loading: false, + error: null, + }); + }); + + it('renders nothing', async () => { + const { queryByTestId } = subject(); + expect(await queryByTestId('AuditListTable')).toBeNull(); + }); + }); +}); diff --git a/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx new file mode 100644 index 0000000000..d3bc362877 --- /dev/null +++ b/plugins/lighthouse/src/components/Cards/LastLighthouseAuditCard.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * 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, { FC } from 'react'; +import { Audit, AuditCompleted, LighthouseCategoryId } from '../../api'; +import { + InfoCard, + Progress, + StatusError, + StatusOK, + StatusWarning, + StructuredMetadataTable, +} from '@backstage/core'; +import { useWebsiteForEntity } from '../../hooks/useWebsiteForEntity'; +import AuditStatusIcon from '../AuditStatusIcon'; + +const LighthouseCategoryScoreStatus: FC<{ score: number }> = ({ score }) => { + const scoreAsPercentage = score * 100; + switch (true) { + case scoreAsPercentage >= 90: + return ( + <> + + {scoreAsPercentage}% + + ); + case scoreAsPercentage >= 50 && scoreAsPercentage < 90: + return ( + <> + + {scoreAsPercentage}% + + ); + case scoreAsPercentage < 50: + return ( + <> + + {scoreAsPercentage}% + + ); + default: + return N/A; + } +}; + +const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => ( + <> + + {audit.status.toUpperCase()} + +); + +const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({ + audit, + dense = false, +}) => { + const { url } = audit; + const flattenedCategoryData: Record = {}; + if (audit.status === 'COMPLETED') { + const categories = (audit as AuditCompleted).categories; + const categoryIds = Object.keys(categories) as LighthouseCategoryId[]; + categoryIds.forEach((id: LighthouseCategoryId) => { + const { title, score } = categories[id]; + + flattenedCategoryData[title] = ( + + ); + }); + } + const tableData = { + url, + status: , + ...flattenedCategoryData, + }; + + return ; +}; + +export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({ + dense = false, +}) => { + const { value: website, loading, error } = useWebsiteForEntity(); + + let content; + if (loading) { + content = ; + } + if (error) { + content = null; + } + if (website) { + content = ( + + ); + } + return {content}; +}; diff --git a/plugins/lighthouse/src/components/Cards/index.ts b/plugins/lighthouse/src/components/Cards/index.ts new file mode 100644 index 0000000000..7595619808 --- /dev/null +++ b/plugins/lighthouse/src/components/Cards/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { LastLighthouseAuditCard } from './LastLighthouseAuditCard'; diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx index 36bd97485a..ad46b06020 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx @@ -53,7 +53,7 @@ const useStyles = makeStyles(theme => ({ }, })); -const CreateAudit: FC<{}> = () => { +export const CreateAuditContent: FC<{}> = () => { const errorApi = useApi(errorApiRef); const lighthouseApi = useApi(lighthouseApiRef); const classes = useStyles(); @@ -94,88 +94,91 @@ const CreateAudit: FC<{}> = () => { ]); return ( - -
+ - - -
- - - - - - - -
{ - ev.preventDefault(); - triggerAudit(); - }} - > - - - setUrl(ev.target.value)} - value={url} - inputProps={{ 'aria-label': 'URL' }} - /> - - - setEmulatedFormFactor(ev.target.value)} - value={emulatedFormFactor} - inputProps={{ 'aria-label': 'Emulated form factor' }} - > - Mobile - Desktop - - - - - - - -
-
-
+ + + + + +
{ + ev.preventDefault(); + triggerAudit(); + }} + > + + + setUrl(ev.target.value)} + value={url} + inputProps={{ 'aria-label': 'URL' }} + /> + + + setEmulatedFormFactor(ev.target.value)} + value={emulatedFormFactor} + inputProps={{ 'aria-label': 'Emulated form factor' }} + > + Mobile + Desktop + + + + + + + +
+
-
-
+ + ); }; +const CreateAudit = () => ( + +
+ + +
+ + + +
+); + export default CreateAudit; diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx new file mode 100644 index 0000000000..868e26f04c --- /dev/null +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { renderHook } from '@testing-library/react-hooks'; +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core-api'; +import { lighthouseApiRef, WebsiteListResponse } from '../api'; +import { useWebsiteForEntity } from './useWebsiteForEntity'; +import { EntityContext } from '@backstage/plugin-catalog'; +import { Entity } from '@backstage/catalog-model'; +import * as data from '../__fixtures__/website-list-response.json'; + +const websiteListResponse = data as WebsiteListResponse; +const website = websiteListResponse.items[0]; + +const mockErrorApi: jest.Mocked = { + post: jest.fn(), + error$: jest.fn(), +}; + +const mockLighthouseApi: jest.Mocked> = { + getWebsiteByUrl: jest.fn(), +}; + +describe('useWebsiteForEntity', () => { + const entity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'lighthouse.com/website-url': website.url, + }, + }, + spec: { + owner: 'guest', + type: 'Website', + lifecycle: 'development', + }, + }; + + const wrapper: React.FC<{}> = ({ children }) => { + return ( + + + {children} + + + ); + }; + + const subject = () => + renderHook(useWebsiteForEntity, { + wrapper, + }); + + beforeEach(() => { + (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockResolvedValue(website); + }); + + it('returns the lighthouse information for the website url in annotations ', async () => { + const { result, waitForNextUpdate } = subject(); + await waitForNextUpdate(); + expect(result.current?.value).toBe(website); + }); + + describe('where there is an error', () => { + const error = new Error('useWebsiteForEntity unit test'); + + beforeEach(() => { + (mockLighthouseApi.getWebsiteByUrl as jest.Mock).mockRejectedValueOnce( + error, + ); + }); + + it('posts the error to the error api and returns the error to the caller', async () => { + const { result, waitForNextUpdate } = subject(); + await waitForNextUpdate(); + expect(result.current?.error).toBe(error); + expect(mockErrorApi.post).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts new file mode 100644 index 0000000000..c52e38f473 --- /dev/null +++ b/plugins/lighthouse/src/hooks/useWebsiteForEntity.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { useEntity } from '@backstage/plugin-catalog'; +import { LIGHTHOUSE_WEBSITE_URL_ANNOTATION } from '../../constants'; +import { errorApiRef, useApi } from '@backstage/core-api'; +import { lighthouseApiRef } from '../api'; +import { useAsync } from 'react-use'; + +// For the sake of simplicity we assume that an entity has only one website url. This is to avoid encoding a list +// type in an annotation which is a plain string. +export const useWebsiteForEntity = () => { + const { entity } = useEntity(); + const websiteUrl = + entity.metadata.annotations?.[LIGHTHOUSE_WEBSITE_URL_ANNOTATION] ?? ''; + const lighthouseApi = useApi(lighthouseApiRef); + const errorApi = useApi(errorApiRef); + const response = useAsync(() => lighthouseApi.getWebsiteByUrl(websiteUrl), [ + websiteUrl, + ]); + if (response.error) { + errorApi.post(response.error); + } + return response; +}; diff --git a/plugins/lighthouse/src/index.ts b/plugins/lighthouse/src/index.ts index bebdaaf713..64fe2f8cc0 100644 --- a/plugins/lighthouse/src/index.ts +++ b/plugins/lighthouse/src/index.ts @@ -15,5 +15,6 @@ */ export { plugin } from './plugin'; -export { Router } from './Router'; +export { Router, isPluginApplicableToEntity, EmbeddedRouter } from './Router'; export * from './api'; +export * from './components/Cards'; diff --git a/yarn.lock b/yarn.lock index b9f5733acd..62b7a5e9f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4520,6 +4520,14 @@ "@babel/runtime" "^7.5.4" "@types/testing-library__react-hooks" "^3.3.0" +"@testing-library/react-hooks@^3.4.2": + version "3.4.2" + resolved "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-3.4.2.tgz#8deb94f7684e0d896edd84a4c90e5b79a0810bc2" + integrity sha512-RfPG0ckOzUIVeIqlOc1YztKgFW+ON8Y5xaSPbiBkfj9nMkkiLhLeBXT5icfPX65oJV/zCZu4z8EVnUc6GY9C5A== + dependencies: + "@babel/runtime" "^7.5.4" + "@types/testing-library__react-hooks" "^3.4.0" + "@testing-library/react@^10.4.1": version "10.4.3" resolved "https://registry.npmjs.org/@testing-library/react/-/react-10.4.3.tgz#c6f356688cffc51f6b35385583d664bb11a161f4" @@ -5632,6 +5640,13 @@ dependencies: "@types/react-test-renderer" "*" +"@types/testing-library__react-hooks@^3.4.0": + version "3.4.1" + resolved "https://registry.npmjs.org/@types/testing-library__react-hooks/-/testing-library__react-hooks-3.4.1.tgz#b8d7311c6c1f7db3103e94095fe901f8fef6e433" + integrity sha512-G4JdzEcq61fUyV6wVW9ebHWEiLK2iQvaBuCHHn9eMSbZzVh4Z4wHnUGIvQOYCCYeu5DnUtFyNYuAAgbSaO/43Q== + dependencies: + "@types/react-test-renderer" "*" + "@types/through@*": version "0.0.30" resolved "https://registry.npmjs.org/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"