Merge pull request #2709 from RoadieHQ/lighthouse-widget

Add Lighthouse card & tab
This commit is contained in:
Niklas Ek
2020-10-07 16:53:39 +02:00
committed by GitHub
18 changed files with 876 additions and 111 deletions
+1 -1
View File
@@ -20,6 +20,6 @@ export * from './api/types';
export * from './routes';
export { useEntityCompoundName } from './components/useEntityCompoundName';
export { Router } from './components/Router';
export { useEntity } from './hooks/useEntity';
export { useEntity, EntityContext } from './hooks/useEntity';
export { AboutCard } from './components/AboutCard';
export { EntityPageLayout } from './components/EntityPageLayout';
+63
View File
@@ -57,3 +57,66 @@ Then configure the lighthouse service url in your [`app-config.yaml`](https://gi
lighthouse:
baseUrl: http://your-service-url
```
### Integration with the Catalog
The lighthouse plugin can be integrated into the catalog so that lighthouse audit information relating to a component
can be displayed within that component's entity page. In order to link an Entity to its lighthouse audits the entity
must be annotated as follows:
```yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
# ...
annotations:
# ...
lighthouse.com/website-url: # A single website url e.g. https://backstage.io/
```
> 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 }) => (
<EntityPageLayout>
// ...
<EntityPageLayout.Content
path="/lighthouse"
title="Lighthouse"
element={<LighthouseRouter entity={entity} />}
/>
</EntityPageLayout>
);
```
> 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 }) => (
<Grid container spacing={3}>
// ...
{isLighthouseAvailable(entity) && (
<Grid item sm={4}>
<LastLighthouseAuditCard />
</Grid>
)}
</Grid>
);
```
+16
View File
@@ -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';
+6 -1
View File
@@ -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",
+32 -4
View File
@@ -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 = () => (
<Routes>
@@ -28,3 +35,24 @@ export const Router = () => (
<Route path={`/${createAuditRouteRef.path}`} element={<CreateAudit />} />
</Routes>
);
export const EmbeddedRouter = ({ entity }: { entity: Entity }) =>
!isPluginApplicableToEntity(entity) ? (
<EmptyState
missing="field"
title="Your plugin is missing an annotation"
description={`Please add the ${LIGHTHOUSE_WEBSITE_URL_ANNOTATION} annotation`}
/>
) : (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<AuditListForEntity />} />
<Route
path={`/${viewAuditRouteRef.path}`}
element={<AuditViewContent />}
/>
<Route
path={`/${createAuditRouteRef.path}`}
element={<CreateAuditContent />}
/>
</Routes>
);
+7
View File
@@ -104,6 +104,7 @@ export type LighthouseApi = {
getWebsiteList: (listOptions: LASListRequest) => Promise<WebsiteListResponse>;
getWebsiteForAuditId: (auditId: string) => Promise<Website>;
triggerAudit: (payload: TriggerAuditPayload) => Promise<Audit>;
getWebsiteByUrl: (websiteUrl: string) => Promise<Website>;
};
export const lighthouseApiRef = createApiRef<LighthouseApi>({
@@ -150,4 +151,10 @@ export class LighthouseRestApi implements LighthouseApi {
},
});
}
async getWebsiteByUrl(websiteUrl: string): Promise<Website> {
return this.fetch<Website>(
`/v1/websites/${encodeURIComponent(websiteUrl)}`,
);
}
}
@@ -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('<AuditListTableForEntity />', () => {
let apis: ApiRegistry;
const mockErrorApi: jest.Mocked<typeof errorApiRef.T> = {
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(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<ApiProvider apis={apis}>
<EntityContext.Provider
value={{ entity: entity, loading: false, ...value }}
>
<AuditListForEntity />
</EntityContext.Provider>
</ApiProvider>
</MemoryRouter>
</ThemeProvider>,
);
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();
});
});
});
@@ -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 <Progress />;
}
if (error || !value) {
return null;
}
return <AuditListTable data-test-id="AuditListTable" items={[value]} />;
};
@@ -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 (
<Page theme={pageTheme.tool}>
<Header
title="Lighthouse"
subtitle="Website audits powered by Lighthouse"
<>
<ContentHeader
title={value?.url || 'Audit'}
description="See a history of all Lighthouse audits for your website run through Backstage."
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content stretch>
<ContentHeader
title={value?.url || 'Audit'}
description="See a history of all Lighthouse audits for your website run through Backstage."
<Button
variant="contained"
color="primary"
onClick={() => navigate(`../../${createAuditButtonUrl}`)}
>
<Button
variant="contained"
color="primary"
onClick={() => navigate(`../../${createAuditButtonUrl}`)}
>
Create New Audit
</Button>
<LighthouseSupportButton />
</ContentHeader>
{content}
</Content>
</Page>
Create New Audit
</Button>
<LighthouseSupportButton />
</ContentHeader>
{content}
</>
);
};
const ConnectedAuditView = () => (
<Page theme={pageTheme.tool}>
<Header title="Lighthouse" subtitle="Website audits powered by Lighthouse">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content stretch>
<AuditViewContent />
</Content>
</Page>
);
export default ConnectedAuditView;
@@ -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('<LastLighthouseAuditCard />', () => {
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(
<ThemeProvider theme={lightTheme}>
<MemoryRouter>
<EntityContext.Provider
value={{ entity: entity, loading: false, ...value }}
>
<LastLighthouseAuditCard />
</EntityContext.Provider>
</MemoryRouter>
</ThemeProvider>,
);
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();
});
});
});
@@ -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 (
<>
<StatusOK />
{scoreAsPercentage}%
</>
);
case scoreAsPercentage >= 50 && scoreAsPercentage < 90:
return (
<>
<StatusWarning />
{scoreAsPercentage}%
</>
);
case scoreAsPercentage < 50:
return (
<>
<StatusError />
{scoreAsPercentage}%
</>
);
default:
return <span>N/A</span>;
}
};
const LighthouseAuditStatus: FC<{ audit: Audit }> = ({ audit }) => (
<>
<AuditStatusIcon audit={audit} />
{audit.status.toUpperCase()}
</>
);
const LighthouseAuditSummary: FC<{ audit: Audit; dense?: boolean }> = ({
audit,
dense = false,
}) => {
const { url } = audit;
const flattenedCategoryData: Record<string, React.ReactNode> = {};
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] = (
<LighthouseCategoryScoreStatus score={score} />
);
});
}
const tableData = {
url,
status: <LighthouseAuditStatus audit={audit} />,
...flattenedCategoryData,
};
return <StructuredMetadataTable metadata={tableData} dense={dense} />;
};
export const LastLighthouseAuditCard: FC<{ dense?: boolean }> = ({
dense = false,
}) => {
const { value: website, loading, error } = useWebsiteForEntity();
let content;
if (loading) {
content = <Progress />;
}
if (error) {
content = null;
}
if (website) {
content = (
<LighthouseAuditSummary audit={website.lastAudit} dense={dense} />
);
}
return <InfoCard title="Lighthouse Audit">{content}</InfoCard>;
};
@@ -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';
@@ -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 (
<Page theme={pageTheme.tool}>
<Header
title="Lighthouse"
subtitle="Website audits powered by Lighthouse"
<>
<ContentHeader
title="Trigger a new audit"
description="Submitting this form will immediately trigger and store a new Lighthouse audit. Trigger audits to track your website's accessibility, performance, SEO, and best practices over time."
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader
title="Trigger a new audit"
description="Submitting this form will immediately trigger and store a new Lighthouse audit. Trigger audits to track your website's accessibility, performance, SEO, and best practices over time."
>
<LighthouseSupportButton />
</ContentHeader>
<Grid container direction="column">
<Grid item xs={12} sm={6}>
<InfoCard>
<form
onSubmit={ev => {
ev.preventDefault();
triggerAudit();
}}
>
<List>
<ListItem>
<TextField
name="lighthouse-create-audit-url-tf"
className={classes.input}
label="URL"
placeholder="https://spotify.com"
helperText="The target URL for Lighthouse to use."
required
disabled={submitting}
onChange={ev => setUrl(ev.target.value)}
value={url}
inputProps={{ 'aria-label': 'URL' }}
/>
</ListItem>
<ListItem>
<TextField
name="lighthouse-create-audit-emulated-form-factor-tf"
className={classes.input}
label="Emulated Form Factor"
helperText="Device to simulate when auditing"
select
required
disabled={submitting}
onChange={ev => setEmulatedFormFactor(ev.target.value)}
value={emulatedFormFactor}
inputProps={{ 'aria-label': 'Emulated form factor' }}
>
<MenuItem value="mobile">Mobile</MenuItem>
<MenuItem value="desktop">Desktop</MenuItem>
</TextField>
</ListItem>
<ListItem className={classes.buttonList}>
<Button
variant="outlined"
color="primary"
onClick={() => navigate('..')}
disabled={submitting}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={submitting}
>
Create Audit
</Button>
</ListItem>
</List>
</form>
</InfoCard>
</Grid>
<LighthouseSupportButton />
</ContentHeader>
<Grid container direction="column">
<Grid item xs={12} sm={6}>
<InfoCard>
<form
onSubmit={ev => {
ev.preventDefault();
triggerAudit();
}}
>
<List>
<ListItem>
<TextField
name="lighthouse-create-audit-url-tf"
className={classes.input}
label="URL"
placeholder="https://spotify.com"
helperText="The target URL for Lighthouse to use."
required
disabled={submitting}
onChange={ev => setUrl(ev.target.value)}
value={url}
inputProps={{ 'aria-label': 'URL' }}
/>
</ListItem>
<ListItem>
<TextField
name="lighthouse-create-audit-emulated-form-factor-tf"
className={classes.input}
label="Emulated Form Factor"
helperText="Device to simulate when auditing"
select
required
disabled={submitting}
onChange={ev => setEmulatedFormFactor(ev.target.value)}
value={emulatedFormFactor}
inputProps={{ 'aria-label': 'Emulated form factor' }}
>
<MenuItem value="mobile">Mobile</MenuItem>
<MenuItem value="desktop">Desktop</MenuItem>
</TextField>
</ListItem>
<ListItem className={classes.buttonList}>
<Button
variant="outlined"
color="primary"
onClick={() => navigate('..')}
disabled={submitting}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
type="submit"
disabled={submitting}
>
Create Audit
</Button>
</ListItem>
</List>
</form>
</InfoCard>
</Grid>
</Content>
</Page>
</Grid>
</>
);
};
const CreateAudit = () => (
<Page theme={pageTheme.tool}>
<Header title="Lighthouse" subtitle="Website audits powered by Lighthouse">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<CreateAuditContent />
</Content>
</Page>
);
export default CreateAudit;
@@ -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<typeof errorApiRef.T> = {
post: jest.fn(),
error$: jest.fn(),
};
const mockLighthouseApi: jest.Mocked<Partial<typeof lighthouseApiRef.T>> = {
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 (
<ApiProvider
apis={ApiRegistry.with(errorApiRef, mockErrorApi).with(
lighthouseApiRef,
mockLighthouseApi,
)}
>
<EntityContext.Provider value={{ entity: entity, loading: false }}>
{children}
</EntityContext.Provider>
</ApiProvider>
);
};
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);
});
});
});
@@ -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;
};
+2 -1
View File
@@ -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';