Merge pull request #326 from spotify/rugvip/errors

Add initial version of core error API with small showcase in welcome plugin and app
This commit is contained in:
Patrik Oldsberg
2020-03-23 10:00:12 +01:00
committed by GitHub
16 changed files with 357 additions and 18 deletions
+1
View File
@@ -5,6 +5,7 @@
"dependencies": {
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@spotify-backstage/cli": "^0.1.0",
"@spotify-backstage/core": "^0.1.0",
"@spotify-backstage/plugin-home-page": "^0.1.0",
+4
View File
@@ -19,7 +19,9 @@ import { BackstageTheme, createApp } from '@spotify-backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import Root from './components/Root';
import ErrorDisplay from './components/ErrorDisplay';
import * as plugins from './plugins';
import apis, { errorDialogForwarder } from './apis';
const useStyles = makeStyles(theme => ({
'@global': {
@@ -40,6 +42,7 @@ const useStyles = makeStyles(theme => ({
}));
const app = createApp();
app.registerApis(apis);
app.registerPlugin(...Object.values(plugins));
const AppComponent = app.build();
@@ -48,6 +51,7 @@ const App: FC<{}> = () => {
return (
<CssBaseline>
<ThemeProvider theme={BackstageTheme}>
<ErrorDisplay forwarder={errorDialogForwarder} />
<Router>
<Root>
<AppComponent />
+26
View File
@@ -0,0 +1,26 @@
/*
* 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 { ApiHolder, ApiRegistry, errorApiRef } from '@spotify-backstage/core';
import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay';
const builder = ApiRegistry.builder();
export const errorDialogForwarder = new ErrorDisplayForwarder();
builder.add(errorApiRef, errorDialogForwarder);
export default builder.build() as ApiHolder;
@@ -0,0 +1,87 @@
/*
* 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, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { Snackbar } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import { ErrorApi, ErrorContext } from '@spotify-backstage/core';
type SubscriberFunc = (error: Error) => void;
type Unsubscribe = () => void;
// TODO: figure out where to put implementations of APIs, both inside apps
// but also in core/separate package.
export class ErrorDisplayForwarder implements ErrorApi {
private readonly subscribers = new Set<SubscriberFunc>();
post(error: Error, context?: ErrorContext) {
if (context?.hidden) {
return;
}
this.subscribers.forEach(subscriber => subscriber(error));
}
subscribe(func: SubscriberFunc): Unsubscribe {
this.subscribers.add(func);
return () => {
this.subscribers.delete(func);
};
}
}
type Props = {
forwarder: ErrorDisplayForwarder;
};
// TODO: improve on this and promote to a shared component for use by all apps.
const ErrorDisplay: FC<Props> = ({ forwarder }) => {
const [errors, setErrors] = useState<Array<Error>>([]);
useEffect(() => {
return forwarder.subscribe(error => setErrors(errs => errs.concat(error)));
}, [forwarder]);
if (errors.length === 0) {
return null;
}
const [firstError] = errors;
const handleClose = () => {
setErrors(errs => errs.filter(err => err !== firstError));
};
return (
<Snackbar
open
message={firstError.toString()}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert onClose={handleClose} severity="error">
{firstError.toString()}
</Alert>
</Snackbar>
);
};
ErrorDisplay.propTypes = {
forwarder: PropTypes.instanceOf(ErrorDisplayForwarder).isRequired,
};
export default ErrorDisplay;
@@ -0,0 +1,17 @@
/*
* 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 { default } from './ErrorDisplay';
+16 -4
View File
@@ -25,14 +25,26 @@ describe('ApiRef', () => {
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
});
it('should require a ascii letters only in id', () => {
for (const id of ['a', 'abc', 'ABC', 'aBC', 'aBc']) {
it('should reject invalid ids', () => {
for (const id of ['a', 'abc', 'a.b.c', 'ab.c', 'abc.abc.abc3']) {
expect(new ApiRef({ id, description: '123' }).id).toBe(id);
}
for (const id of ['123', 'ab-c', 'ab_c', 'a2c', '', '_']) {
for (const id of [
'123',
'ab-c',
'ab_c',
'.',
'2ac',
'ab.3a',
'.abc',
'abc.',
'ab..s',
'',
'_',
]) {
expect(() => new ApiRef({ id, description: '123' }).id).toThrow(
`API id must only contain ascii letters, got '${id}'`,
`API id must only contain lowercase alphanums separated by dots, got '${id}'`,
);
}
});
+2 -2
View File
@@ -21,9 +21,9 @@ export type ApiRefConfig = {
export default class ApiRef<T> {
constructor(private readonly config: ApiRefConfig) {
if (!config.id.match(/^[a-zA-Z]+$/)) {
if (!config.id.match(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/)) {
throw new Error(
`API id must only contain ascii letters, got '${config.id}'`,
`API id must only contain lowercase alphanums separated by dots, got '${config.id}'`,
);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
class ApiRegistryBuilder {
private apis: ApiImpl[] = [];
add<T>(api: ApiRef<T>, impl: T): T {
add<T, I extends T>(api: ApiRef<T>, impl: I): I {
this.apis.push([api, impl]);
return impl;
}
@@ -0,0 +1,62 @@
/*
* 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 ApiRef from '../ApiRef';
/**
* Mirrors the javascript Error class, for the purpose of
* providing documentation and optional fields.
*/
type Error = {
name: string;
message: string;
stack?: string;
};
/**
* Provides additional information about an error that was posted to the application.
*/
export type ErrorContext = {
// If set to true, this error should not be displayed to the user. Defaults to false.
hidden?: boolean;
};
/**
* The error API is used to report errors to the app, and display them to the user.
*
* Plugins can use this API as a method of displaying errors to the user, but also
* to report errors for collection by error reporting services.
*
* If an error can be displayed inline, e.g. as feedback in a form, that should be
* preferred over relying on this API to display the error. The main use of this api
* for displaying errors should be for asynchronous errors, such as a failing background process.
*
* Even if an error is displayed inline, it should still be reported through this api
* if it would be useful to collect or log it for debugging purposes, but with
* the hidden flag set. For example, an error arising from form field validation
* should probably not be reported, while a failed REST call would be useful to report.
*/
export type ErrorApi = {
/**
* Post an error for handling by the application.
*/
post(error: Error, context?: ErrorContext);
};
export const errorApiRef = new ApiRef<ErrorApi>({
id: 'core.error',
description: 'Used to report errors and forward them to the app',
});
@@ -0,0 +1,23 @@
/*
* 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.
*/
// This folder contains definitions for all core APIs.
//
// Plugins should rely on these APIs for functionality as much as possible.
//
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './error';
+1
View File
@@ -18,3 +18,4 @@ export { default as ApiProvider, useApi } from './ApiProvider';
export { default as ApiRegistry } from './ApiRegistry';
export { default as ApiTestRegistry } from './ApiTestRegistry';
export * from './types';
export * from './definitions';
+17 -7
View File
@@ -25,6 +25,7 @@ import {
SystemIconKey,
defaultSystemIcons,
} from '../../icons';
import { ApiHolder, ApiProvider } from '../apis';
import LoginPage from './LoginPage';
class AppImpl implements App {
@@ -36,9 +37,14 @@ class AppImpl implements App {
}
export default class AppBuilder {
private apis?: ApiHolder;
private systemIcons = { ...defaultSystemIcons };
private readonly plugins = new Set<BackstagePlugin>();
registerApis(apis: ApiHolder) {
this.apis = apis;
}
registerIcons(icons: Partial<SystemIcons>) {
this.systemIcons = { ...this.systemIcons, ...icons };
}
@@ -91,13 +97,17 @@ export default class AppBuilder {
<Route key="login" path="/login" component={LoginPage} exact />,
);
return () => (
<AppContextProvider app={app}>
<Switch>
{routes}
<Route component={() => <span>404 Not Found</span>} />
</Switch>
</AppContextProvider>
let rendered = (
<Switch>
{routes}
<Route component={() => <span>404 Not Found</span>} />
</Switch>
);
if (this.apis) {
rendered = <ApiProvider apis={this.apis} children={rendered} />;
}
return () => <AppContextProvider app={app} children={rendered} />;
}
}
@@ -0,0 +1,41 @@
/*
* 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, fireEvent } from '@testing-library/react';
import ErrorButton from './ErrorButton';
import { ApiRegistry, errorApiRef, ApiProvider } from '@spotify-backstage/core';
describe('ErrorButton', () => {
it('should trigger an error', () => {
const errorApi = { post: jest.fn() };
const rendered = render(
<ApiProvider apis={ApiRegistry.from([[errorApiRef, errorApi]])}>
<ErrorButton />
</ApiProvider>,
);
const button = rendered.getByText('Trigger an error!');
expect(button).toBeInTheDocument();
expect(errorApi.post).not.toHaveBeenCalled();
fireEvent.click(button);
expect(errorApi.post).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Oh no!' }),
);
});
});
@@ -0,0 +1,35 @@
/*
* 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 { Button } from '@material-ui/core';
import { errorApiRef, useApi } from '@spotify-backstage/core';
const ErrorButton: FC<{}> = () => {
const errorApi = useApi(errorApiRef);
const handleClick = () => {
errorApi.post(new Error('Oh no!'));
};
return (
<Button variant="contained" color="primary" onClick={handleClick}>
Trigger an error!
</Button>
);
};
export default ErrorButton;
@@ -18,14 +18,24 @@ import React from 'react';
import { render } from '@testing-library/react';
import WelcomePage from './WelcomePage';
import { ThemeProvider } from '@material-ui/core';
import { BackstageTheme } from '@spotify-backstage/core';
import {
BackstageTheme,
ApiProvider,
ApiRegistry,
errorApiRef,
} from '@spotify-backstage/core';
describe('WelcomePage', () => {
it('should render', () => {
// TODO: use common test app with mock implementations of all core APIs
const rendered = render(
<ThemeProvider theme={BackstageTheme}>
<WelcomePage />
</ThemeProvider>,
<ApiProvider
apis={ApiRegistry.from([[errorApiRef, { post: jest.fn() }]])}
>
<ThemeProvider theme={BackstageTheme}>
<WelcomePage />
</ThemeProvider>
</ApiProvider>,
);
expect(rendered.baseElement).toBeInTheDocument();
});
@@ -34,6 +34,7 @@ import {
ContentHeader,
SupportButton,
} from '@spotify-backstage/core';
import ErrorButton from './ErrorButton';
const WelcomePage: FC<{}> = () => {
const profile = { givenName: '' };
@@ -113,6 +114,15 @@ const WelcomePage: FC<{}> = () => {
</List>
</InfoCard>
</Grid>
<Grid item>
<InfoCard title="APIs">
<Typography>
The button below is an example of how to consume APIs.
</Typography>
<br />
<ErrorButton />
</InfoCard>
</Grid>
</Grid>
</Content>
</Page>