diff --git a/packages/app/package.json b/packages/app/package.json index 90ff7fa0b9396..4f581599a55d5 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index cb4c4470ba3c3..91b588041c0ff 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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 ( + diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts new file mode 100644 index 0000000000000..6e2de637951a0 --- /dev/null +++ b/packages/app/src/apis.ts @@ -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; diff --git a/packages/app/src/components/ErrorDisplay/ErrorDisplay.tsx b/packages/app/src/components/ErrorDisplay/ErrorDisplay.tsx new file mode 100644 index 0000000000000..307158d88cd99 --- /dev/null +++ b/packages/app/src/components/ErrorDisplay/ErrorDisplay.tsx @@ -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(); + + 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 = ({ forwarder }) => { + const [errors, setErrors] = useState>([]); + + 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 ( + + + {firstError.toString()} + + + ); +}; + +ErrorDisplay.propTypes = { + forwarder: PropTypes.instanceOf(ErrorDisplayForwarder).isRequired, +}; + +export default ErrorDisplay; diff --git a/packages/app/src/components/ErrorDisplay/index.ts b/packages/app/src/components/ErrorDisplay/index.ts new file mode 100644 index 0000000000000..05e414061bc7a --- /dev/null +++ b/packages/app/src/components/ErrorDisplay/index.ts @@ -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'; diff --git a/packages/core/src/api/apis/ApiRef.test.ts b/packages/core/src/api/apis/ApiRef.test.ts index 902ab1bfbee1b..b592e327b045b 100644 --- a/packages/core/src/api/apis/ApiRef.test.ts +++ b/packages/core/src/api/apis/ApiRef.test.ts @@ -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}'`, ); } }); diff --git a/packages/core/src/api/apis/ApiRef.ts b/packages/core/src/api/apis/ApiRef.ts index c28bb100b9179..b3f4d48428640 100644 --- a/packages/core/src/api/apis/ApiRef.ts +++ b/packages/core/src/api/apis/ApiRef.ts @@ -21,9 +21,9 @@ export type ApiRefConfig = { export default class ApiRef { 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}'`, ); } } diff --git a/packages/core/src/api/apis/ApiRegistry.ts b/packages/core/src/api/apis/ApiRegistry.ts index b7af906b6c355..4149365806594 100644 --- a/packages/core/src/api/apis/ApiRegistry.ts +++ b/packages/core/src/api/apis/ApiRegistry.ts @@ -22,7 +22,7 @@ type ApiImpl = readonly [ApiRef, T]; class ApiRegistryBuilder { private apis: ApiImpl[] = []; - add(api: ApiRef, impl: T): T { + add(api: ApiRef, impl: I): I { this.apis.push([api, impl]); return impl; } diff --git a/packages/core/src/api/apis/definitions/error.ts b/packages/core/src/api/apis/definitions/error.ts new file mode 100644 index 0000000000000..01a6509715c83 --- /dev/null +++ b/packages/core/src/api/apis/definitions/error.ts @@ -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({ + id: 'core.error', + description: 'Used to report errors and forward them to the app', +}); diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts new file mode 100644 index 0000000000000..bdf2a0b6a2cb0 --- /dev/null +++ b/packages/core/src/api/apis/definitions/index.ts @@ -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'; diff --git a/packages/core/src/api/apis/index.ts b/packages/core/src/api/apis/index.ts index a49a622858377..650948edc05bc 100644 --- a/packages/core/src/api/apis/index.ts +++ b/packages/core/src/api/apis/index.ts @@ -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'; diff --git a/packages/core/src/api/app/AppBuilder.tsx b/packages/core/src/api/app/AppBuilder.tsx index 393388cc384f3..ab6f60a293de9 100644 --- a/packages/core/src/api/app/AppBuilder.tsx +++ b/packages/core/src/api/app/AppBuilder.tsx @@ -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(); + registerApis(apis: ApiHolder) { + this.apis = apis; + } + registerIcons(icons: Partial) { this.systemIcons = { ...this.systemIcons, ...icons }; } @@ -91,13 +97,17 @@ export default class AppBuilder { , ); - return () => ( - - - {routes} - 404 Not Found} /> - - + let rendered = ( + + {routes} + 404 Not Found} /> + ); + + if (this.apis) { + rendered = ; + } + + return () => ; } } diff --git a/plugins/welcome/src/components/WelcomePage/ErrorButton.test.tsx b/plugins/welcome/src/components/WelcomePage/ErrorButton.test.tsx new file mode 100644 index 0000000000000..791937a3b5052 --- /dev/null +++ b/plugins/welcome/src/components/WelcomePage/ErrorButton.test.tsx @@ -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( + + + , + ); + + 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!' }), + ); + }); +}); diff --git a/plugins/welcome/src/components/WelcomePage/ErrorButton.tsx b/plugins/welcome/src/components/WelcomePage/ErrorButton.tsx new file mode 100644 index 0000000000000..fa73115d18f2a --- /dev/null +++ b/plugins/welcome/src/components/WelcomePage/ErrorButton.tsx @@ -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 ( + + ); +}; + +export default ErrorButton; diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index a5be434b59c95..9787fa2a9a18e 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -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( - - - , + + + + + , ); expect(rendered.baseElement).toBeInTheDocument(); }); diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index c77e9ae6aa570..755e19e0ffb3c 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -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<{}> = () => { + + + + The button below is an example of how to consume APIs. + +
+ +
+