diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f4b31a74b2..43c1f0d7b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,6 @@ Contributions are welcome, and they are greatly appreciated! Every little bit he Backstage is released under the Apache2.0 License, and original creations contributed to this repo are accepted under the same license. - # Types of Contributions ## Report bugs diff --git a/README.md b/README.md index 086c1ace60..2e22818f4b 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Take a look at the [Getting Started](docs/getting-started/README.md) guide to le - [Architecture](docs/architecture-terminology.md) - [API references](docs/reference/README.md) - [Designing for Backstage](docs/design.md) -- [Storybook - UI components](http://storybook.backstage.io) ([WIP](https://github.com/spotify/backstage/milestone/9)) +- [Storybook - UI components](http://storybook.backstage.io) - [Contributing to Storybook](docs/getting-started/contributing-to-storybook.md) - Using Backstage components (TODO) diff --git a/docs/architecture-decisions/adr004-module-export-structure.md b/docs/architecture-decisions/adr004-module-export-structure.md new file mode 100644 index 0000000000..56c675f069 --- /dev/null +++ b/docs/architecture-decisions/adr004-module-export-structure.md @@ -0,0 +1,71 @@ +# ADR004: Module Export Structure + +| Created | Status | +| ---------- | ------ | +| 2020-05-27 | Open | + +## Context + +With a growing number of exports of packages like `@backstage/core`, it is becoming more and more difficult to answer questions such as + +> Is the export in this module also exported by the package? + +or + +> What is exported from this directory? + +We currently do not use any pattern for how to structure exports. There is a mix of package-level re-exports deep into the directory tree, shallow re-exports for each directory, exports using `*` and explicit lists of each symbol, etc. +The mix and lack of predictability makes it difficult to reason about the boundaries of a module, and for example knowing whether is is safe to export a symbol in a given file. + +## Decision + +We will make each exported symbol traceable through index files all the way down to the root of the package, `src/index.ts`. Each index file will only re-export from its own immediate directory children, and only index files will have re-exports. This gives a file tree similar to this: + +```text +index.ts +components/index.ts + /ComponentX/index.ts + /ComponentX.tsx + /SubComponentY.tsx +lib/index.ts + /UtilityX/index.ts + /UtilityX.ts + /helper.ts +``` + +To check whether for example `SubComponentY` is exported from the package, it should be possible to traverse the index files towards the root, starting at the adjacent one. If there is any index file that doesn't export the previous one, the symbol is not publicly exported. For example, if `components/ComponentX/index.ts` exports `SubComponentY`, but `components/index.ts` does not re-export `./ComponentX`, one should be certain that `SubComponentY` is not exported outside the package. This rule would be broken if for example the root `index.ts` re-exports `./components/ComponentX` + +In addition, index files that are re-exporting other index files should always use wildcard form, that is: + +```ts +// in components/index.ts +export * from './ComponentX'; +``` + +Index files that are re-exporting symbols from non-index files should always enumerate all exports, that is: + +```ts +// in components/ComponentX/index.ts +export { ComponentX } from './ComponentX'; +export type { ComponentXProps } from './ComponentX'; +``` + +Internal cross-directory imports are allowed from non-index modules to index modules, for example: + +```ts +// in components/ComponentX/ComponentX.tsx +import { UtilityX } from '../../lib/UtilityX'; +``` + +Imports that bypass an index file are discouraged, but may sometimes be necessary, for example: + +```ts +// in components/ComponentX/ComponentX.tsx +import { helperFunc } from '../../lib/UtilityX/helper'; +``` + +## Consequences + +We will actively work to rework the export structure in our codebase, prioritizing the library packages such as `@backstage/core` and `@backstage/backend-common`. + +If possible, we will add tools, such as lint rules, to help enforce the export structure. diff --git a/docs/getting-started/utility-apis.md b/docs/getting-started/utility-apis.md index 09dc27ce7a..dba48355c7 100644 --- a/docs/getting-started/utility-apis.md +++ b/docs/getting-started/utility-apis.md @@ -55,15 +55,16 @@ import { errorApiRef, AlertApiForwarder, ErrorApiForwarder, + ErrorAlerter, } from '@backstage/core'; const builder = ApiRegistry.builder(); // The alert API is a self-contained implementation that shows alerts to the user. -builder.add(alertApiRef, new AlertApiForwarder()); +const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); // The error API uses the alert API to send error notifications to the user. -builder.add(errorApiRef, new ErrorApiForwarder(alertApiForwarder)); +builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); const app = createApp({ apis: apiBuilder.build(), diff --git a/packages/app/package.json b/packages/app/package.json index 41a4dace53..ed2f547ad7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -18,7 +18,6 @@ "@backstage/plugin-sentry": "^0.1.1-alpha.6", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", - "@material-ui/lab": "4.0.0-alpha.45", "prop-types": "^15.7.2", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index e29fac8865..56faf4402b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,13 +14,17 @@ * limitations under the License. */ -import { createApp, OAuthRequestDialog } from '@backstage/core'; +import { + createApp, + AlertDisplay, + OAuthRequestDialog, + LoginPage, +} from '@backstage/core'; import React, { FC } from 'react'; -import { BrowserRouter as Router } from 'react-router-dom'; +import { BrowserRouter as Router, Route } from 'react-router-dom'; import Root from './components/Root'; -import AlertDisplay from './components/AlertDisplay'; import * as plugins from './plugins'; -import apis, { alertApiForwarder } from './apis'; +import apis from './apis'; const app = createApp({ apis, @@ -32,10 +36,11 @@ const AppComponent = app.getRootComponent(); const App: FC<{}> = () => ( - + + diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index bf9980bf5f..8efc3757eb 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -21,6 +21,7 @@ import { errorApiRef, AlertApiForwarder, ErrorApiForwarder, + ErrorAlerter, featureFlagsApiRef, FeatureFlags, GoogleAuth, @@ -41,11 +42,9 @@ import { catalogApiRef, CatalogApi } from '@backstage/plugin-catalog'; const builder = ApiRegistry.builder(); -export const alertApiForwarder = new AlertApiForwarder(); -builder.add(alertApiRef, alertApiForwarder); +const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); -export const errorApiForwarder = new ErrorApiForwarder(alertApiForwarder); -builder.add(errorApiRef, errorApiForwarder); +builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); builder.add(circleCIApiRef, new CircleCIApi()); builder.add(featureFlagsApiRef, new FeatureFlags()); diff --git a/packages/app/src/components/Root/LogoFull.tsx b/packages/app/src/components/Root/LogoFull.tsx new file mode 100644 index 0000000000..d2b1bf1080 --- /dev/null +++ b/packages/app/src/components/Root/LogoFull.tsx @@ -0,0 +1,46 @@ +/* + * 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 { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 30, + }, + path: { + fill: '#7df3e1', + }, +}); +const LogoFull: FC<{}> = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoFull; diff --git a/packages/app/src/components/Root/LogoIcon.tsx b/packages/app/src/components/Root/LogoIcon.tsx new file mode 100644 index 0000000000..d70be3dd32 --- /dev/null +++ b/packages/app/src/components/Root/LogoIcon.tsx @@ -0,0 +1,47 @@ +/* + * 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 { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + svg: { + width: 'auto', + height: 28, + }, + path: { + fill: '#7df3e1', + }, +}); + +const LogoIcon: FC<{}> = () => { + const classes = useStyles(); + + return ( + + + + ); +}; + +export default LogoIcon; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index c26336c18b..20eabbcac5 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -16,12 +16,12 @@ import React, { FC, useContext } from 'react'; import PropTypes from 'prop-types'; -import { Link, makeStyles, Typography } from '@material-ui/core'; +import { Link, makeStyles } from '@material-ui/core'; import HomeIcon from '@material-ui/icons/Home'; import ExploreIcon from '@material-ui/icons/Explore'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; -import AccountTreeIcon from '@material-ui/icons/AccountTree'; - +import LogoFull from './LogoFull'; +import LogoIcon from './LogoIcon'; import { Sidebar, SidebarPage, @@ -44,21 +44,9 @@ const useSidebarLogoStyles = makeStyles({ alignItems: 'center', marginBottom: -14, }, - logoContainer: { + link: { width: sidebarConfig.drawerWidthClosed, - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, - title: { - fontSize: sidebarConfig.logoHeight, - fontWeight: 'bold', - marginLeft: 20, - whiteSpace: 'nowrap', - color: '#fff', - }, - titleDot: { - color: '#68c5b5', + marginLeft: 24, }, }); @@ -68,11 +56,8 @@ const SidebarLogo: FC<{}> = () => { return (
- - - {isOpen ? 'Backstage' : 'B'} - . - + + {isOpen ? : }
); @@ -96,8 +81,6 @@ const Root: FC<{}> = ({ children }) => ( {/* End global nav */} - - diff --git a/packages/backend/package.json b/packages/backend/package.json index 2c7b48f65f..9aedafecbb 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -40,8 +40,7 @@ "@types/helmet": "^0.0.47", "jest": "^26.0.1", "tsc-watch": "^4.2.3", - "typescript": "^3.9.2", - "winston": "^3.2.1" + "typescript": "^3.9.2" }, "nodemonConfig": { "watch": "./dist" diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 7af6e631a8..5b1fc54330 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -58,9 +58,13 @@ function createEnv(plugin: string): PluginEnvironment { async function main() { const app = express(); + const corsOptions: cors.CorsOptions = { + origin: 'http://localhost:3000', + credentials: true, + }; app.use(helmet()); - app.use(cors()); + app.use(cors(corsOptions)); app.use(compression()); app.use(express.json()); app.use(requestLoggingHandler()); diff --git a/packages/backend/src/plugins/catalog.ts b/packages/backend/src/plugins/catalog.ts index e18d021395..687fd9157a 100644 --- a/packages/backend/src/plugins/catalog.ts +++ b/packages/backend/src/plugins/catalog.ts @@ -36,7 +36,7 @@ export default async function ({ logger, database }: PluginEnvironment) { ); const entitiesCatalog = new DatabaseEntitiesCatalog(db); - const locationsCatalog = new DatabaseLocationsCatalog(db); + const locationsCatalog = new DatabaseLocationsCatalog(db, reader); return await createRouter({ entitiesCatalog, locationsCatalog, logger }); } diff --git a/packages/backend/src/plugins/sentry.ts b/packages/backend/src/plugins/sentry.ts index ddb7ddd540..34506ee3de 100644 --- a/packages/backend/src/plugins/sentry.ts +++ b/packages/backend/src/plugins/sentry.ts @@ -17,6 +17,6 @@ import { createRouter } from '@backstage/plugin-sentry-backend'; import { Logger } from 'winston'; -export default async function(logger: Logger) { +export default async function (logger: Logger) { return await createRouter(logger); } diff --git a/packages/cli/package.json b/packages/cli/package.json index 299fc5e8d3..a15765c731 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -62,7 +62,7 @@ "react-hot-loader": "^4.12.21", "recursive-readdir": "^2.2.2", "replace-in-file": "^6.0.0", - "rollup": "^2.3.2", + "rollup": "2.10.x", "rollup-plugin-dts": "^1.4.6", "rollup-plugin-esbuild": "^1.4.1", "rollup-plugin-image-files": "^1.4.2", diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index e28cb376b9..a42271435b 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -62,7 +62,7 @@ async function buildApp(appDir: string) { await Task.forItem('executing', cmd, async () => { process.chdir(appDir); - await exec(cmd).catch((error) => { + await exec(cmd).catch(error => { process.stdout.write(error.stderr); process.stdout.write(error.stdout); throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); @@ -81,7 +81,7 @@ export async function moveApp( id: string, ) { await Task.forItem('moving', id, async () => { - await fs.move(tempDir, destination).catch((error) => { + await fs.move(tempDir, destination).catch(error => { throw new Error( `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, ); diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 1441b16d05..3a91044500 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -107,7 +107,7 @@ export async function addPluginDependencyToApp( packageFileJson.dependencies = sortObjectByKeys(dependencies); const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`; - await fs.writeFile(packageFile, newContents, 'utf-8').catch((error) => { + await fs.writeFile(packageFile, newContents, 'utf-8').catch(error => { throw new Error( `Failed to add plugin as dependency to app: ${packageFile}: ${error.message}`, ); @@ -119,14 +119,14 @@ export async function addPluginToApp(rootDir: string, pluginName: string) { const pluginPackage = `@backstage/plugin-${pluginName}`; const pluginNameCapitalized = pluginName .split('-') - .map((name) => capitalize(name)) + .map(name => capitalize(name)) .join(''); const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`; const pluginsFilePath = 'packages/app/src/plugins.ts'; const pluginsFile = resolvePath(rootDir, pluginsFilePath); await Task.forItem('processing', pluginsFilePath, async () => { - await addExportStatement(pluginsFile, pluginExport).catch((error) => { + await addExportStatement(pluginsFile, pluginExport).catch(error => { throw new Error( `Failed to import plugin in app: ${pluginsFile}: ${error.message}`, ); @@ -148,7 +148,7 @@ async function buildPlugin(pluginFolder: string) { await Task.forItem('executing', command, async () => { process.chdir(pluginFolder); - await exec(command).catch((error) => { + await exec(command).catch(error => { process.stdout.write(error.stderr); process.stdout.write(error.stdout); throw new Error(`Could not execute command ${chalk.cyan(command)}`); @@ -163,7 +163,7 @@ export async function movePlugin( id: string, ) { await Task.forItem('moving', id, async () => { - await fs.move(tempDir, destination).catch((error) => { + await fs.move(tempDir, destination).catch(error => { throw new Error( `Failed to move plugin from ${tempDir} to ${destination}: ${error.message}`, ); diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts index f24da5b0c8..c7860c0f42 100644 --- a/packages/cli/src/lib/bundler/bundle.ts +++ b/packages/cli/src/lib/bundler/bundle.ts @@ -48,7 +48,7 @@ export async function buildBundle(options: BuildOptions) { const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist); await fs.emptyDir(paths.targetDist); - const { stats } = await build(compiler, isCi).catch((error) => { + const { stats } = await build(compiler, isCi).catch(error => { console.log(chalk.red('Failed to compile.\n')); throw new Error(`Failed to compile.\n${error.message || error}`); }); diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts index 721e1a355b..2d592bc549 100644 --- a/packages/cli/src/lib/tasks.ts +++ b/packages/cli/src/lib/tasks.ts @@ -73,7 +73,7 @@ export async function templatingTask( destinationDir: string, context: any, ) { - const files = await recursive(templateDir).catch((error) => { + const files = await recursive(templateDir).catch(error => { throw new Error(`Failed to read template directory: ${error.message}`); }); @@ -89,7 +89,7 @@ export async function templatingTask( const compiled = handlebars.compile(template.toString()); const contents = compiled({ name: basename(destination), ...context }); - await fs.writeFile(destination, contents).catch((error) => { + await fs.writeFile(destination, contents).catch(error => { throw new Error( `Failed to create file: ${destination}: ${error.message}`, ); @@ -97,7 +97,7 @@ export async function templatingTask( }); } else { await Task.forItem('copying', basename(file), async () => { - await fs.copyFile(file, destinationFile).catch((error) => { + await fs.copyFile(file, destinationFile).catch(error => { const destination = destinationFile; throw new Error( `Failed to copy file to ${destination} : ${error.message}`, @@ -112,6 +112,7 @@ export async function templatingTask( const PATCH_PACKAGES = [ 'cli', 'core', + 'core-api', 'dev-utils', 'test-utils', 'test-utils-core', @@ -145,7 +146,7 @@ export async function installWithLocalDeps(dir: string) { await fs .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 }) - .catch((error) => { + .catch(error => { throw new Error( `Failed to add resolutions to package.json: ${error.message}`, ); @@ -157,7 +158,7 @@ export async function installWithLocalDeps(dir: string) { } await Task.forItem('executing', 'yarn install', async () => { - await exec('yarn install', { cwd: dir }).catch((error) => { + await exec('yarn install', { cwd: dir }).catch(error => { process.stdout.write(error.stderr); process.stdout.write(error.stdout); throw new Error( @@ -193,7 +194,7 @@ export async function installWithLocalDeps(dir: string) { await fs .writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 }) - .catch((error) => { + .catch(error => { throw new Error( `Failed to add resolutions to package.json: ${error.message}`, ); diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index c32efd2a6b..84bc1b2e7d 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -4,7 +4,7 @@ import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import * as plugins from './plugins'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ '@global': { html: { height: '100%', diff --git a/packages/core-api/.eslintrc.js b/packages/core-api/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/core-api/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + // TODO: add prop types to JS and remove + 'react/prop-types': 0, + 'jest/expect-expect': 0, + }, +}; diff --git a/packages/core-api/README.md b/packages/core-api/README.md new file mode 100644 index 0000000000..5732b5ef23 --- /dev/null +++ b/packages/core-api/README.md @@ -0,0 +1,22 @@ +# @backstage/core-api + +This package provides the core API used by Backstage plugins and apps. + +## Installation + +Do not install this package directly, it is reexported by `@backstage/core`. Install that instead: + +```sh +$ npm install --save @backstage/core +``` + +or + +```sh +$ yarn add @backstage/core +``` + +## Documentation + +- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md) diff --git a/packages/core-api/package.json b/packages/core-api/package.json new file mode 100644 index 0000000000..73e7a18d33 --- /dev/null +++ b/packages/core-api/package.json @@ -0,0 +1,54 @@ +{ + "name": "@backstage/core-api", + "description": "Internal Core API used by Backstage plugins and apps", + "version": "0.1.1-alpha.6", + "private": false, + "publishConfig": { + "access": "public" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/spotify/backstage", + "directory": "packages/core-api" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "dist/index.esm.js", + "main:src": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli plugin:build", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/theme": "^0.1.1-alpha.6", + "@material-ui/core": "^4.9.1", + "@material-ui/icons": "^4.9.1", + "@types/jest": "^25.2.2", + "@types/node": "^12.0.0", + "@types/zen-observable": "^0.8.0", + "prop-types": "^15.7.2", + "react": "^16.12.0", + "react-router-dom": "^5.2.0", + "react-use": "^14.2.0", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.6", + "@backstage/test-utils-core": "^0.1.1-alpha.6", + "@testing-library/jest-dom": "^5.7.0", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^10.2.4", + "jest-fetch-mock": "^3.0.3" + }, + "files": [ + "dist/**/*.{js,d.ts}" + ] +} diff --git a/packages/core/src/api/apis/ApiAggregator.test.ts b/packages/core-api/src/apis/ApiAggregator.test.ts similarity index 100% rename from packages/core/src/api/apis/ApiAggregator.test.ts rename to packages/core-api/src/apis/ApiAggregator.test.ts diff --git a/packages/core/src/api/apis/ApiAggregator.ts b/packages/core-api/src/apis/ApiAggregator.ts similarity index 100% rename from packages/core/src/api/apis/ApiAggregator.ts rename to packages/core-api/src/apis/ApiAggregator.ts diff --git a/packages/core/src/api/apis/ApiProvider.test.tsx b/packages/core-api/src/apis/ApiProvider.test.tsx similarity index 100% rename from packages/core/src/api/apis/ApiProvider.test.tsx rename to packages/core-api/src/apis/ApiProvider.test.tsx diff --git a/packages/core/src/api/apis/ApiProvider.tsx b/packages/core-api/src/apis/ApiProvider.tsx similarity index 97% rename from packages/core/src/api/apis/ApiProvider.tsx rename to packages/core-api/src/apis/ApiProvider.tsx index 46f89cfa0d..1c5abf1c56 100644 --- a/packages/core/src/api/apis/ApiProvider.tsx +++ b/packages/core-api/src/apis/ApiProvider.tsx @@ -53,7 +53,7 @@ export function withApis(apis: TypesToApiRefs) { return function withApisWrapper

( WrappedComponent: React.ComponentType

, ) { - const Hoc: FC> = (props) => { + const Hoc: FC> = props => { const apiHolder = useContext(Context); if (!apiHolder) { diff --git a/packages/core/src/api/apis/ApiRef.test.ts b/packages/core-api/src/apis/ApiRef.test.ts similarity index 100% rename from packages/core/src/api/apis/ApiRef.test.ts rename to packages/core-api/src/apis/ApiRef.test.ts diff --git a/packages/core/src/api/apis/ApiRef.ts b/packages/core-api/src/apis/ApiRef.ts similarity index 100% rename from packages/core/src/api/apis/ApiRef.ts rename to packages/core-api/src/apis/ApiRef.ts diff --git a/packages/core/src/api/apis/ApiRegistry.test.ts b/packages/core-api/src/apis/ApiRegistry.test.ts similarity index 100% rename from packages/core/src/api/apis/ApiRegistry.test.ts rename to packages/core-api/src/apis/ApiRegistry.test.ts diff --git a/packages/core/src/api/apis/ApiRegistry.ts b/packages/core-api/src/apis/ApiRegistry.ts similarity index 100% rename from packages/core/src/api/apis/ApiRegistry.ts rename to packages/core-api/src/apis/ApiRegistry.ts diff --git a/packages/core/src/api/apis/ApiTestRegistry.test.ts b/packages/core-api/src/apis/ApiTestRegistry.test.ts similarity index 100% rename from packages/core/src/api/apis/ApiTestRegistry.test.ts rename to packages/core-api/src/apis/ApiTestRegistry.test.ts diff --git a/packages/core/src/api/apis/ApiTestRegistry.ts b/packages/core-api/src/apis/ApiTestRegistry.ts similarity index 100% rename from packages/core/src/api/apis/ApiTestRegistry.ts rename to packages/core-api/src/apis/ApiTestRegistry.ts diff --git a/packages/core/src/api/apis/definitions/AlertApi.ts b/packages/core-api/src/apis/definitions/AlertApi.ts similarity index 88% rename from packages/core/src/api/apis/definitions/AlertApi.ts rename to packages/core-api/src/apis/definitions/AlertApi.ts index 9776a96d84..123cd47651 100644 --- a/packages/core/src/api/apis/definitions/AlertApi.ts +++ b/packages/core-api/src/apis/definitions/AlertApi.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { createApiRef } from '../ApiRef'; +import { Observable } from '../../types'; export type AlertMessage = { message: string; @@ -30,6 +31,11 @@ export type AlertApi = { * Post an alert for handling by the application. */ post(alert: AlertMessage): void; + + /** + * Observe alerts posted by other parts of the application. + */ + alert$(): Observable; }; export const alertApiRef = createApiRef({ diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core-api/src/apis/definitions/AppThemeApi.ts similarity index 100% rename from packages/core/src/api/apis/definitions/AppThemeApi.ts rename to packages/core-api/src/apis/definitions/AppThemeApi.ts diff --git a/packages/core/src/api/apis/definitions/ErrorApi.ts b/packages/core-api/src/apis/definitions/ErrorApi.ts similarity index 92% rename from packages/core/src/api/apis/definitions/ErrorApi.ts rename to packages/core-api/src/apis/definitions/ErrorApi.ts index c481547459..7f9676ea5a 100644 --- a/packages/core/src/api/apis/definitions/ErrorApi.ts +++ b/packages/core-api/src/apis/definitions/ErrorApi.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '../ApiRef'; +import { Observable } from '../../types'; /** * Mirrors the javascript Error class, for the purpose of @@ -54,6 +55,11 @@ export type ErrorApi = { * Post an error for handling by the application. */ post(error: Error, context?: ErrorContext): void; + + /** + * Observe errors posted by other parts of the application. + */ + error$(): Observable<{ error: Error; context?: ErrorContext }>; }; export const errorApiRef = createApiRef({ diff --git a/packages/core/src/api/apis/definitions/FeatureFlagsApi.ts b/packages/core-api/src/apis/definitions/FeatureFlagsApi.ts similarity index 100% rename from packages/core/src/api/apis/definitions/FeatureFlagsApi.ts rename to packages/core-api/src/apis/definitions/FeatureFlagsApi.ts diff --git a/packages/core/src/api/apis/definitions/OAuthRequestApi.ts b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts similarity index 98% rename from packages/core/src/api/apis/definitions/OAuthRequestApi.ts rename to packages/core-api/src/apis/definitions/OAuthRequestApi.ts index 5a0f399a6d..6fefbd1b9c 100644 --- a/packages/core/src/api/apis/definitions/OAuthRequestApi.ts +++ b/packages/core-api/src/apis/definitions/OAuthRequestApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../../../icons'; +import { IconComponent } from '../../icons'; import { Observable } from '../../types'; import { createApiRef } from '../ApiRef'; diff --git a/packages/core/src/api/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts similarity index 98% rename from packages/core/src/api/apis/definitions/auth.ts rename to packages/core-api/src/apis/definitions/auth.ts index 15c9e5d632..c066c83b73 100644 --- a/packages/core/src/api/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -85,7 +85,10 @@ export type OAuthApi = { * will be prompted to log in. The returned promise will not resolve until the user has * successfully logged in. The returned promise can be rejected, but only if the user rejects the login request. */ - getAccessToken(scope?: OAuthScope): Promise; + getAccessToken( + scope?: OAuthScope, + options?: AccessTokenOptions, + ): Promise; /** * Log out the user's session. This will reload the page. diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts similarity index 100% rename from packages/core/src/api/apis/definitions/index.ts rename to packages/core-api/src/apis/definitions/index.ts diff --git a/packages/core/src/api/apis/helpers.ts b/packages/core-api/src/apis/helpers.ts similarity index 100% rename from packages/core/src/api/apis/helpers.ts rename to packages/core-api/src/apis/helpers.ts diff --git a/packages/core/src/api/apis/implementations/AlertApiForwarder.ts b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts similarity index 62% rename from packages/core/src/api/apis/implementations/AlertApiForwarder.ts rename to packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts index 349512c0f9..91a606059d 100644 --- a/packages/core/src/api/apis/implementations/AlertApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -13,23 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { AlertApi, AlertMessage } from '../../../'; - -type SubscriberFunc = (message: AlertMessage) => void; -type Unsubscribe = () => void; +import { AlertApi, AlertMessage } from '../..'; +import { PublishSubject } from '../../../lib'; +import { Observable } from '../../../types'; +/** + * Base implementation for the AlertApi that simply forwards alerts to consumers. + */ export class AlertApiForwarder implements AlertApi { - private readonly subscribers = new Set(); + private readonly subject = new PublishSubject(); post(alert: AlertMessage) { - this.subscribers.forEach(subscriber => subscriber(alert)); + this.subject.next(alert); } - subscribe(func: SubscriberFunc): Unsubscribe { - this.subscribers.add(func); - - return () => { - this.subscribers.delete(func); - }; + alert$(): Observable { + return this.subject; } } diff --git a/packages/core-api/src/apis/implementations/AlertApi/index.ts b/packages/core-api/src/apis/implementations/AlertApi/index.ts new file mode 100644 index 0000000000..12ab8bc60c --- /dev/null +++ b/packages/core-api/src/apis/implementations/AlertApi/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 { AlertApiForwarder } from './AlertApiForwarder'; diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts rename to packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.test.ts diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts similarity index 92% rename from packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts rename to packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts index 7f6659e641..43b52a254b 100644 --- a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts +++ b/packages/core-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -15,7 +15,7 @@ */ import { AppThemeApi, AppTheme } from '../../definitions'; -import { BehaviorSubject } from '../lib'; +import { BehaviorSubject } from '../../../lib'; import { Observable } from '../../../types'; const STORAGE_KEY = 'theme'; @@ -33,7 +33,7 @@ export class AppThemeSelector implements AppThemeApi { selector.setActiveThemeId(initialThemeId); - selector.activeThemeId$().subscribe((themeId) => { + selector.activeThemeId$().subscribe(themeId => { if (themeId) { window.localStorage.setItem(STORAGE_KEY, themeId); } else { @@ -41,7 +41,7 @@ export class AppThemeSelector implements AppThemeApi { } }); - window.addEventListener('storage', (event) => { + window.addEventListener('storage', event => { if (event.key === STORAGE_KEY) { const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined; selector.setActiveThemeId(themeId); diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts b/packages/core-api/src/apis/implementations/AppThemeApi/index.ts similarity index 100% rename from packages/core/src/api/apis/implementations/AppThemeSelector/index.ts rename to packages/core-api/src/apis/implementations/AppThemeApi/index.ts diff --git a/packages/core-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts new file mode 100644 index 0000000000..162b28f334 --- /dev/null +++ b/packages/core-api/src/apis/implementations/ErrorApi/ErrorAlerter.ts @@ -0,0 +1,39 @@ +/* + * 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 { ErrorApi, ErrorContext, AlertApi } from '../..'; + +/** + * Decorates an ErrorApi by also forwarding error messages + * to the alertApi with an 'error' severity. + */ +export class ErrorAlerter implements ErrorApi { + constructor( + private readonly alertApi: AlertApi, + private readonly errorApi: ErrorApi, + ) {} + + post(error: Error, context?: ErrorContext) { + if (!context?.hidden) { + this.alertApi.post({ message: error.message, severity: 'error' }); + } + + return this.errorApi.post(error, context); + } + + error$() { + return this.errorApi.error$(); + } +} diff --git a/packages/core/src/api/apis/implementations/ErrorApiForwarder.ts b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts similarity index 52% rename from packages/core/src/api/apis/implementations/ErrorApiForwarder.ts rename to packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts index 8d777ac18f..2ebf9db7a8 100644 --- a/packages/core/src/api/apis/implementations/ErrorApiForwarder.ts +++ b/packages/core-api/src/apis/implementations/ErrorApi/ErrorApiForwarder.ts @@ -13,33 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ErrorApi, ErrorContext, AlertApi } from '../../../'; - -type SubscriberFunc = (error: Error) => void; -type Unsubscribe = () => void; +import { ErrorApi, ErrorContext } from '../..'; +import { PublishSubject } from '../../../lib'; +import { Observable } from '../../../types'; +/** + * Base implementation for the ErrorApi that simply forwards errors to consumers. + */ export class ErrorApiForwarder implements ErrorApi { - private readonly subscribers = new Set(); - private alertApi: AlertApi; - - constructor(alertApi: AlertApi) { - this.alertApi = alertApi; - } + private readonly subject = new PublishSubject<{ + error: Error; + context?: ErrorContext; + }>(); post(error: Error, context?: ErrorContext) { - if (context?.hidden) { - return; - } - - this.alertApi.post({ message: error.message, severity: 'error' }); - this.subscribers.forEach(subscriber => subscriber(error)); + this.subject.next({ error, context }); } - subscribe(func: SubscriberFunc): Unsubscribe { - this.subscribers.add(func); - - return () => { - this.subscribers.delete(func); - }; + error$(): Observable<{ error: Error; context?: ErrorContext }> { + return this.subject; } } diff --git a/packages/core-api/src/apis/implementations/ErrorApi/index.ts b/packages/core-api/src/apis/implementations/ErrorApi/index.ts new file mode 100644 index 0000000000..757dfd0d8f --- /dev/null +++ b/packages/core-api/src/apis/implementations/ErrorApi/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { ErrorAlerter } from './ErrorAlerter'; +export { ErrorApiForwarder } from './ErrorApiForwarder'; diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.test.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.test.ts diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts similarity index 100% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/MockOAuthApi.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/MockOAuthApi.ts diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.test.ts diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts similarity index 98% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts index aa5609b1e9..6287f35015 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthPendingRequests.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { BehaviorSubject } from '../lib'; +import { BehaviorSubject } from '../../../lib'; import { Observable } from '../../../types'; type RequestQueueEntry = { diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.test.ts diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts similarity index 98% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts index 1168d6abd4..23e9e1ddc4 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts +++ b/packages/core-api/src/apis/implementations/OAuthRequestApi/OAuthRequestManager.ts @@ -21,7 +21,7 @@ import { AuthRequesterOptions, } from '../../definitions'; import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; -import { BehaviorSubject } from '../lib'; +import { BehaviorSubject } from '../../../lib'; import { Observable } from '../../../types'; /** diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts b/packages/core-api/src/apis/implementations/OAuthRequestApi/index.ts similarity index 100% rename from packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts rename to packages/core-api/src/apis/implementations/OAuthRequestApi/index.ts diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/auth/google/GoogleAuth.test.ts rename to packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts diff --git a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts similarity index 84% rename from packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts rename to packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index ccf99772fc..582afbcfea 100644 --- a/packages/core/src/api/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -15,16 +15,17 @@ */ import GoogleIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../lib/AuthConnector'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GoogleSession } from './types'; import { OAuthApi, OpenIdConnectApi, IdTokenOptions, + AccessTokenOptions, } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; -import { SessionManager } from '../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../lib/AuthSessionManager'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth @@ -40,7 +41,7 @@ type CreateOptions = { export type GoogleAuthResponse = { accessToken: string; idToken: string; - scopes: string; + scope: string; expiresInSeconds: number; }; @@ -70,7 +71,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { return { idToken: res.idToken, accessToken: res.accessToken, - scopes: GoogleAuth.normalizeScopes(res.scopes), + scopes: GoogleAuth.normalizeScopes(res.scope), expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000), }; }, @@ -95,19 +96,23 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi { constructor(private readonly sessionManager: SessionManager) {} - async getAccessToken(scope?: string | string[]) { + async getAccessToken( + scope?: string | string[], + options?: AccessTokenOptions, + ) { const normalizedScopes = GoogleAuth.normalizeScopes(scope); const session = await this.sessionManager.getSession({ - optional: false, + ...options, scopes: normalizedScopes, }); - return session.accessToken; + if (session) { + return session.accessToken; + } + return ''; } - async getIdToken({ optional }: IdTokenOptions = {}) { - const session = await this.sessionManager.getSession({ - optional: optional || false, - }); + async getIdToken(options: IdTokenOptions = {}) { + const session = await this.sessionManager.getSession(options); if (session) { return session.idToken; } diff --git a/packages/core/src/api/apis/implementations/auth/google/index.ts b/packages/core-api/src/apis/implementations/auth/google/index.ts similarity index 100% rename from packages/core/src/api/apis/implementations/auth/google/index.ts rename to packages/core-api/src/apis/implementations/auth/google/index.ts diff --git a/packages/core/src/api/apis/implementations/auth/google/types.ts b/packages/core-api/src/apis/implementations/auth/google/types.ts similarity index 100% rename from packages/core/src/api/apis/implementations/auth/google/types.ts rename to packages/core-api/src/apis/implementations/auth/google/types.ts diff --git a/packages/core/src/api/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts similarity index 100% rename from packages/core/src/api/apis/implementations/auth/index.ts rename to packages/core-api/src/apis/implementations/auth/index.ts diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts similarity index 83% rename from packages/core/src/api/apis/implementations/index.ts rename to packages/core-api/src/apis/implementations/index.ts index 0bf5cbbb24..bb77cf5bd3 100644 --- a/packages/core/src/api/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -19,7 +19,8 @@ // Plugins should rely on these APIs for functionality as much as possible. export * from './auth'; -export * from './AppThemeSelector'; -export * from './AlertApiForwarder'; -export * from './ErrorApiForwarder'; -export * from './OAuthRequestManager'; + +export * from './AlertApi'; +export * from './AppThemeApi'; +export * from './ErrorApi'; +export * from './OAuthRequestApi'; diff --git a/packages/core/src/api/apis/index.ts b/packages/core-api/src/apis/index.ts similarity index 100% rename from packages/core/src/api/apis/index.ts rename to packages/core-api/src/apis/index.ts diff --git a/packages/core/src/api/apis/types.ts b/packages/core-api/src/apis/types.ts similarity index 100% rename from packages/core/src/api/apis/types.ts rename to packages/core-api/src/apis/types.ts diff --git a/packages/core/src/api/app/App.tsx b/packages/core-api/src/app/App.tsx similarity index 78% rename from packages/core/src/api/app/App.tsx rename to packages/core-api/src/app/App.tsx index 3d1faf0d56..844c38ecce 100644 --- a/packages/core/src/api/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -17,19 +17,13 @@ import React, { ComponentType, FC } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppOptions, AppComponents } from './types'; +import { BackstageApp, AppComponents } from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef } from '../apis/definitions'; -import ErrorPage from '../../layout/ErrorPage'; import { AppThemeProvider } from './AppThemeProvider'; -import { - IconComponent, - SystemIcons, - SystemIconKey, - defaultSystemIcons, -} from '../../icons'; +import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; import { ApiHolder, ApiProvider, @@ -38,8 +32,6 @@ import { AppThemeSelector, appThemeApiRef, } from '../apis'; -import LoginPage from './LoginPage'; -import { lightTheme, darkTheme } from '@backstage/theme'; import { ApiAggregator } from '../apis/ApiAggregator'; type FullAppOptions = { @@ -50,7 +42,7 @@ type FullAppOptions = { themes: AppTheme[]; }; -class AppImpl implements BackstageApp { +export class PrivateAppImpl implements BackstageApp { private readonly apis: ApiHolder; private readonly icons: SystemIcons; private readonly plugins: BackstagePlugin[]; @@ -138,10 +130,6 @@ class AppImpl implements BackstageApp { FeatureFlags.registeredFeatureFlags = registeredFeatureFlags; } - routes.push( - , - ); - const rendered = ( {routes} @@ -180,40 +168,3 @@ class AppImpl implements BackstageApp { } } } - -/** - * Creates a new Backstage App. - */ -export function createApp(options?: AppOptions) { - const DefaultNotFoundPage = () => ( - - ); - - const apis = options?.apis ?? ApiRegistry.from([]); - const icons = { ...defaultSystemIcons, ...options?.icons }; - const plugins = options?.plugins ?? []; - const components = { - NotFoundErrorPage: DefaultNotFoundPage, - ...options?.components, - }; - const themes = options?.themes ?? [ - { - id: 'light', - title: 'Light Theme', - variant: 'light', - theme: lightTheme, - }, - { - id: 'dark', - title: 'Dark Theme', - variant: 'dark', - theme: darkTheme, - }, - ]; - - const app = new AppImpl({ apis, icons, plugins, components, themes }); - - app.verify(); - - return app; -} diff --git a/packages/core/src/api/app/AppContext.tsx b/packages/core-api/src/app/AppContext.tsx similarity index 100% rename from packages/core/src/api/app/AppContext.tsx rename to packages/core-api/src/app/AppContext.tsx diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core-api/src/app/AppThemeProvider.tsx similarity index 91% rename from packages/core/src/api/app/AppThemeProvider.tsx rename to packages/core-api/src/app/AppThemeProvider.tsx index 6e68fe9ba0..775d8293ba 100644 --- a/packages/core/src/api/app/AppThemeProvider.tsx +++ b/packages/core-api/src/app/AppThemeProvider.tsx @@ -27,20 +27,20 @@ function resolveTheme( themes: AppTheme[], ) { if (themeId !== undefined) { - const selectedTheme = themes.find((theme) => theme.id === themeId); + const selectedTheme = themes.find(theme => theme.id === themeId); if (selectedTheme) { return selectedTheme; } } if (shouldPreferDark) { - const darkTheme = themes.find((theme) => theme.variant === 'dark'); + const darkTheme = themes.find(theme => theme.variant === 'dark'); if (darkTheme) { return darkTheme; } } - const lightTheme = themes.find((theme) => theme.variant === 'light'); + const lightTheme = themes.find(theme => theme.variant === 'light'); if (lightTheme) { return lightTheme; } diff --git a/packages/core/src/api/app/FeatureFlags.test.tsx b/packages/core-api/src/app/FeatureFlags.test.tsx similarity index 99% rename from packages/core/src/api/app/FeatureFlags.test.tsx rename to packages/core-api/src/app/FeatureFlags.test.tsx index ca4a819f13..99974f9b1d 100644 --- a/packages/core/src/api/app/FeatureFlags.test.tsx +++ b/packages/core-api/src/app/FeatureFlags.test.tsx @@ -147,7 +147,7 @@ describe('FeatureFlags', () => { it('should get the correct values', () => { const getByName = (name: string) => - featureFlags.getRegisteredFlags().find((flag) => flag.name === name); + featureFlags.getRegisteredFlags().find(flag => flag.name === name); expect(getByName('registered-flag-0')).toBeUndefined(); expect(getByName('registered-flag-1')).toEqual({ diff --git a/packages/core/src/api/app/FeatureFlags.tsx b/packages/core-api/src/app/FeatureFlags.tsx similarity index 95% rename from packages/core/src/api/app/FeatureFlags.tsx rename to packages/core-api/src/app/FeatureFlags.tsx index 6af10f228e..11c084d3ed 100644 --- a/packages/core/src/api/app/FeatureFlags.tsx +++ b/packages/core-api/src/app/FeatureFlags.tsx @@ -127,12 +127,12 @@ export interface FeatureFlagsRegistryItem { export class FeatureFlagsRegistry extends Array { static from(entries: FeatureFlagsRegistryItem[]) { - Array.from(entries).forEach((entry) => validateFlagName(entry.name)); + Array.from(entries).forEach(entry => validateFlagName(entry.name)); return new FeatureFlagsRegistry(...entries); } push(...entries: FeatureFlagsRegistryItem[]): number { - Array.from(entries).forEach((entry) => validateFlagName(entry.name)); + Array.from(entries).forEach(entry => validateFlagName(entry.name)); return super.push(...entries); } @@ -143,7 +143,7 @@ export class FeatureFlagsRegistry extends Array { )[] ): FeatureFlagsRegistryItem[] { const _concat = super.concat(...entries); - Array.from(_concat).forEach((entry) => validateFlagName(entry.name)); + Array.from(_concat).forEach(entry => validateFlagName(entry.name)); return _concat; } diff --git a/packages/core/src/api/app/index.ts b/packages/core-api/src/app/index.ts similarity index 95% rename from packages/core/src/api/app/index.ts rename to packages/core-api/src/app/index.ts index c40fa9e9f8..003b71e353 100644 --- a/packages/core/src/api/app/index.ts +++ b/packages/core-api/src/app/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { createApp } from './App'; export { FeatureFlags } from './FeatureFlags'; export { useApp } from './AppContext'; export * from './types'; diff --git a/packages/core/src/api/app/types.ts b/packages/core-api/src/app/types.ts similarity index 97% rename from packages/core/src/api/app/types.ts rename to packages/core-api/src/app/types.ts index 75a750ce6e..ea3a812557 100644 --- a/packages/core/src/api/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -15,7 +15,7 @@ */ import { ComponentType } from 'react'; -import { IconComponent, SystemIconKey, SystemIcons } from '../../icons'; +import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; import { AppTheme } from '../apis/definitions'; diff --git a/packages/core-api/src/icons/icons.tsx b/packages/core-api/src/icons/icons.tsx new file mode 100644 index 0000000000..488973b664 --- /dev/null +++ b/packages/core-api/src/icons/icons.tsx @@ -0,0 +1,39 @@ +/* + * 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 { SvgIconProps } from '@material-ui/core'; +import PeopleIcon from '@material-ui/icons/People'; +import PersonIcon from '@material-ui/icons/Person'; +import React, { FC } from 'react'; +import { useApp } from '../app/AppContext'; +import { IconComponent, SystemIconKey, SystemIcons } from './types'; + +export const defaultSystemIcons: SystemIcons = { + user: PersonIcon, + group: PeopleIcon, +}; + +const overridableSystemIcon = (key: SystemIconKey): IconComponent => { + const Component: FC = props => { + const app = useApp(); + const Icon = app.getSystemIcon(key); + return ; + }; + return Component; +}; + +export const UserIcon = overridableSystemIcon('user'); +export const GroupIcon = overridableSystemIcon('group'); diff --git a/packages/core-api/src/icons/index.ts b/packages/core-api/src/icons/index.ts new file mode 100644 index 0000000000..4c97d27176 --- /dev/null +++ b/packages/core-api/src/icons/index.ts @@ -0,0 +1,18 @@ +/* + * 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 * from './icons'; +export * from './types'; diff --git a/packages/core-api/src/icons/types.ts b/packages/core-api/src/icons/types.ts new file mode 100644 index 0000000000..30e0ba53b2 --- /dev/null +++ b/packages/core-api/src/icons/types.ts @@ -0,0 +1,22 @@ +/* + * 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 { ComponentType } from 'react'; +import { SvgIconProps } from '@material-ui/core'; + +export type IconComponent = ComponentType; +export type SystemIconKey = 'user' | 'group'; +export type SystemIcons = { [key in SystemIconKey]: IconComponent }; diff --git a/packages/core-api/src/index.ts b/packages/core-api/src/index.ts new file mode 100644 index 0000000000..f08e65809e --- /dev/null +++ b/packages/core-api/src/index.ts @@ -0,0 +1,19 @@ +/* + * 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 * from './public'; +import * as privateExports from './private'; +export default privateExports; diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts similarity index 83% rename from packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.test.ts rename to packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index f3698b4e15..3d8f7abeb9 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -16,7 +16,7 @@ import ProviderIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from './DefaultAuthConnector'; -import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi'; +import MockOAuthApi from '../../apis/implementations/OAuthRequestApi/MockOAuthApi'; import * as loginPopup from '../loginPopup'; const anyFetch = fetch as any; @@ -86,7 +86,7 @@ describe('DefaultAuthConnector', () => { ...defaultOptions, oauthRequestApi: mockOauth, }); - const promise = helper.createSession(new Set(['a', 'b'])); + const promise = helper.createSession({ scopes: new Set(['a', 'b']) }); await mockOauth.rejectAll(); await expect(promise).rejects.toMatchObject({ name: 'RejectedError' }); }); @@ -106,7 +106,9 @@ describe('DefaultAuthConnector', () => { oauthRequestApi: mockOauth, }); - const sessionPromise = helper.createSession(new Set(['a', 'b'])); + const sessionPromise = helper.createSession({ + scopes: new Set(['a', 'b']), + }); await mockOauth.triggerAll(); @@ -123,6 +125,26 @@ describe('DefaultAuthConnector', () => { }); }); + it('should instantly show popup if option is set', async () => { + const popupSpy = jest + .spyOn(loginPopup, 'showLoginPopup') + .mockResolvedValue('my-session'); + const helper = new DefaultAuthConnector({ + ...defaultOptions, + oauthRequestApi: new MockOAuthApi(), + sessionTransform: str => str, + }); + + const sessionPromise = helper.createSession({ + scopes: new Set(), + instantPopup: true, + }); + + expect(popupSpy).toBeCalledTimes(1); + + await expect(sessionPromise).resolves.toBe('my-session'); + }); + it('should use join func to join scopes', async () => { const mockOauth = new MockOAuthApi(); const popupSpy = jest @@ -134,7 +156,7 @@ describe('DefaultAuthConnector', () => { oauthRequestApi: mockOauth, }); - helper.createSession(new Set(['a', 'b'])); + helper.createSession({ scopes: new Set(['a', 'b']) }); await mockOauth.triggerAll(); diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts similarity index 87% rename from packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.ts rename to packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts index 161015b1a8..21d2530c5a 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { AuthRequester } from '../../..'; -import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { AuthRequester } from '../../apis'; +import { OAuthRequestApi, AuthProvider } from '../../apis/definitions'; import { showLoginPopup } from '../loginPopup'; -import { AuthConnector } from './types'; +import { AuthConnector, CreateSessionOptions } from './types'; const DEFAULT_BASE_PATH = '/api/auth/'; @@ -68,6 +68,7 @@ export class DefaultAuthConnector private readonly basePath: string; private readonly environment: string; private readonly provider: AuthProvider & { id: string }; + private readonly joinScopesFunc: (scopes: Set) => string; private readonly authRequester: AuthRequester; private readonly sessionTransform: (response: any) => Promise; @@ -84,22 +85,26 @@ export class DefaultAuthConnector this.authRequester = oauthRequestApi.createAuthRequester({ provider, - onAuthRequest: scopes => this.showPopup(joinScopes(scopes)), + onAuthRequest: scopes => this.showPopup(scopes), }); this.apiOrigin = apiOrigin; this.basePath = basePath; this.environment = environment; this.provider = provider; + this.joinScopesFunc = joinScopes; this.sessionTransform = sessionTransform; } - async createSession(scopes: Set): Promise { - return this.authRequester(scopes); + async createSession(options: CreateSessionOptions): Promise { + if (options.instantPopup) { + return this.showPopup(options.scopes); + } + return this.authRequester(options.scopes); } async refreshSession(): Promise { - const res = await fetch(this.buildUrl('/token', { optional: true }), { + const res = await fetch(this.buildUrl('/refresh', { optional: true }), { headers: { 'x-requested-with': 'XMLHttpRequest', }, @@ -142,7 +147,8 @@ export class DefaultAuthConnector } } - private async showPopup(scope: string): Promise { + private async showPopup(scopes: Set): Promise { + const scope = this.joinScopesFunc(scopes); const popupUrl = this.buildUrl('/start', { scope }); const payload = await showLoginPopup({ diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.test.ts b/packages/core-api/src/lib/AuthConnector/MockAuthConnector.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.test.ts rename to packages/core-api/src/lib/AuthConnector/MockAuthConnector.test.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.ts b/packages/core-api/src/lib/AuthConnector/MockAuthConnector.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/AuthConnector/MockAuthConnector.ts rename to packages/core-api/src/lib/AuthConnector/MockAuthConnector.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/index.ts b/packages/core-api/src/lib/AuthConnector/index.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/AuthConnector/index.ts rename to packages/core-api/src/lib/AuthConnector/index.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthConnector/types.ts b/packages/core-api/src/lib/AuthConnector/types.ts similarity index 84% rename from packages/core/src/api/apis/implementations/lib/AuthConnector/types.ts rename to packages/core-api/src/lib/AuthConnector/types.ts index 146c31cbe1..46175a265f 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthConnector/types.ts +++ b/packages/core-api/src/lib/AuthConnector/types.ts @@ -14,12 +14,17 @@ * limitations under the License. */ +export type CreateSessionOptions = { + scopes: Set; + instantPopup?: boolean; +}; + /** * An AuthConnector is responsible for realizing auth session actions * by for example communicating with a backend or interacting with the user. */ export type AuthConnector = { - createSession(scopes: Set): Promise; + createSession(options: CreateSessionOptions): Promise; refreshSession(): Promise; removeSession(): Promise; }; diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.test.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.test.ts rename to packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.test.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts similarity index 90% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts rename to packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index 14796c6e11..8d318f5bff 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -18,6 +18,7 @@ import { SessionManager, SessionScopesFunc, SessionShouldRefreshFunc, + GetSessionOptions, } from './types'; import { SessionScopeHelper } from './common'; @@ -59,18 +60,7 @@ export class AuthSessionStore implements SessionManager { }); } - async getSession(options: { - optional: false; - scopes?: Set; - }): Promise; - async getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise; - async getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise { + async getSession(options: GetSessionOptions): Promise { const { scopes } = options; const session = this.loadSession(); diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts similarity index 88% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts rename to packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts index 8684c932b2..ee7cbab2ae 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -113,6 +113,23 @@ describe('RefreshingAuthSessionManager', () => { expect(refreshSession).toBeCalledTimes(1); }); + it('should forward option to instantly show auth popup', async () => { + const createSession = jest.fn(); + const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE')); + const manager = new RefreshingAuthSessionManager({ + connector: { createSession, refreshSession }, + ...defaultOptions, + } as any); + + expect(await manager.getSession({ instantPopup: true })).toBe(undefined); + expect(createSession).toBeCalledTimes(1); + expect(createSession).toHaveBeenCalledWith({ + scopes: new Set(), + instantPopup: true, + }); + expect(refreshSession).toBeCalledTimes(1); + }); + it('should remove session and reload', async () => { // This is a workaround that is used by Facebook and the Jest core team // It is a limitation with the newest versions of JSDOM, and newer browser standards diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts similarity index 87% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts rename to packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 0cb0f2f1ea..64df3deca4 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -18,10 +18,10 @@ import { SessionManager, SessionScopesFunc, SessionShouldRefreshFunc, + GetSessionOptions, } from './types'; import { AuthConnector } from '../AuthConnector'; -import { SessionScopeHelper } from './common'; -import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests'; +import { SessionScopeHelper, hasScopes } from './common'; type Options = { /** The connector used for acting on the auth session */ @@ -61,18 +61,7 @@ export class RefreshingAuthSessionManager implements SessionManager { this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); } - async getSession(options: { - optional: false; - scopes?: Set; - }): Promise; - async getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise; - async getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise { + async getSession(options: GetSessionOptions): Promise { if ( this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) ) { @@ -116,9 +105,10 @@ export class RefreshingAuthSessionManager implements SessionManager { } // We can call authRequester multiple times, the returned session will contain all requested scopes. - this.currentSession = await this.connector.createSession( - this.helper.getExtendedScope(this.currentSession, options.scopes), - ); + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); return this.currentSession; } diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts similarity index 98% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts rename to packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts index dc13aec0c2..7d47fa91df 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -62,7 +62,7 @@ describe('StaticAuthSessionManager', () => { it('should only request auth once for same scopes', async () => { const createSession = jest .fn() - .mockImplementation(scopes => [...scopes].join(' ')); + .mockImplementation(({ scopes }) => [...scopes].join(' ')); const manager = new StaticAuthSessionManager({ connector: { createSession, ...baseConnector }, ...defaultOptions, diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts similarity index 81% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts rename to packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index 8d057ecc58..6e6db47a99 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { SessionManager } from './types'; +import { SessionManager, GetSessionOptions } from './types'; import { AuthConnector } from '../AuthConnector'; import { SessionScopeHelper } from './common'; @@ -43,18 +43,7 @@ export class StaticAuthSessionManager implements SessionManager { this.helper = new SessionScopeHelper({ sessionScopes, defaultScopes }); } - async getSession(options: { - optional: false; - scopes?: Set; - }): Promise; - async getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise; - async getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise { + async getSession(options: GetSessionOptions): Promise { if ( this.helper.sessionExistsAndHasScope(this.currentSession, options.scopes) ) { @@ -67,9 +56,10 @@ export class StaticAuthSessionManager implements SessionManager { } // We can call authRequester multiple times, the returned session will contain all requested scopes. - this.currentSession = await this.connector.createSession( - this.helper.getExtendedScope(this.currentSession, options.scopes), - ); + this.currentSession = await this.connector.createSession({ + ...options, + scopes: this.helper.getExtendedScope(this.currentSession, options.scopes), + }); return this.currentSession; } diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/common.ts b/packages/core-api/src/lib/AuthSessionManager/common.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/common.ts rename to packages/core-api/src/lib/AuthSessionManager/common.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/index.ts rename to packages/core-api/src/lib/AuthSessionManager/index.ts diff --git a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts b/packages/core-api/src/lib/AuthSessionManager/types.ts similarity index 87% rename from packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts rename to packages/core-api/src/lib/AuthSessionManager/types.ts index 5ebc8643e1..d28a751fbf 100644 --- a/packages/core/src/api/apis/implementations/lib/AuthSessionManager/types.ts +++ b/packages/core-api/src/lib/AuthSessionManager/types.ts @@ -14,17 +14,19 @@ * limitations under the License. */ +export type GetSessionOptions = { + optional?: boolean; + instantPopup?: boolean; + scopes?: Set; +}; + /** * A sessions manager keeps track of the current session and makes sure that * multiple simultaneous requests for sessions with different scope are handled * in a correct way. */ export type SessionManager = { - getSession(options: { optional: false; scopes?: Set }): Promise; - getSession(options: { - optional?: boolean; - scopes?: Set; - }): Promise; + getSession(options: GetSessionOptions): Promise; removeSession(): Promise; }; diff --git a/packages/core-api/src/lib/index.ts b/packages/core-api/src/lib/index.ts new file mode 100644 index 0000000000..10f213b50f --- /dev/null +++ b/packages/core-api/src/lib/index.ts @@ -0,0 +1,20 @@ +/* + * 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 * from './subjects'; +export * from './loginPopup'; +export * from './AuthConnector'; +export * from './AuthSessionManager'; diff --git a/packages/core/src/api/apis/implementations/lib/loginPopup.test.ts b/packages/core-api/src/lib/loginPopup.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/loginPopup.test.ts rename to packages/core-api/src/lib/loginPopup.test.ts diff --git a/packages/core/src/api/apis/implementations/lib/loginPopup.ts b/packages/core-api/src/lib/loginPopup.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/loginPopup.ts rename to packages/core-api/src/lib/loginPopup.ts diff --git a/packages/core/src/api/apis/implementations/lib/subjects.test.ts b/packages/core-api/src/lib/subjects.test.ts similarity index 100% rename from packages/core/src/api/apis/implementations/lib/subjects.test.ts rename to packages/core-api/src/lib/subjects.test.ts diff --git a/packages/core/src/api/apis/implementations/lib/subjects.ts b/packages/core-api/src/lib/subjects.ts similarity index 88% rename from packages/core/src/api/apis/implementations/lib/subjects.ts rename to packages/core-api/src/lib/subjects.ts index b33df70a65..9ebde6ebbc 100644 --- a/packages/core/src/api/apis/implementations/lib/subjects.ts +++ b/packages/core-api/src/lib/subjects.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Observable } from '../../../types'; +import { Observable } from '../types'; import ObservableImpl from 'zen-observable'; // TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects. @@ -33,7 +33,7 @@ export class PublishSubject private isClosed = false; private terminatingError?: Error; - private readonly observable = new ObservableImpl((subscriber) => { + private readonly observable = new ObservableImpl(subscriber => { if (this.isClosed) { if (this.terminatingError) { subscriber.error(this.terminatingError); @@ -61,7 +61,7 @@ export class PublishSubject if (this.isClosed) { throw new Error('PublishSubject is closed'); } - this.subscribers.forEach((subscriber) => subscriber.next(value)); + this.subscribers.forEach(subscriber => subscriber.next(value)); } error(error: Error) { @@ -70,7 +70,7 @@ export class PublishSubject } this.isClosed = true; this.terminatingError = error; - this.subscribers.forEach((subscriber) => subscriber.error(error)); + this.subscribers.forEach(subscriber => subscriber.error(error)); } complete() { @@ -78,7 +78,7 @@ export class PublishSubject throw new Error('PublishSubject is closed'); } this.isClosed = true; - this.subscribers.forEach((subscriber) => subscriber.complete()); + this.subscribers.forEach(subscriber => subscriber.complete()); } subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; @@ -126,7 +126,7 @@ export class BehaviorSubject this.currentValue = value; } - private readonly observable = new ObservableImpl((subscriber) => { + private readonly observable = new ObservableImpl(subscriber => { if (this.isClosed) { if (this.terminatingError) { subscriber.error(this.terminatingError); @@ -157,7 +157,7 @@ export class BehaviorSubject throw new Error('BehaviorSubject is closed'); } this.currentValue = value; - this.subscribers.forEach((subscriber) => subscriber.next(value)); + this.subscribers.forEach(subscriber => subscriber.next(value)); } error(error: Error) { @@ -166,7 +166,7 @@ export class BehaviorSubject } this.isClosed = true; this.terminatingError = error; - this.subscribers.forEach((subscriber) => subscriber.error(error)); + this.subscribers.forEach(subscriber => subscriber.error(error)); } complete() { @@ -174,7 +174,7 @@ export class BehaviorSubject throw new Error('BehaviorSubject is closed'); } this.isClosed = true; - this.subscribers.forEach((subscriber) => subscriber.complete()); + this.subscribers.forEach(subscriber => subscriber.complete()); } subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription; diff --git a/packages/core/src/api/plugin/Plugin.tsx b/packages/core-api/src/plugin/Plugin.tsx similarity index 100% rename from packages/core/src/api/plugin/Plugin.tsx rename to packages/core-api/src/plugin/Plugin.tsx diff --git a/packages/core/src/api/plugin/index.ts b/packages/core-api/src/plugin/index.ts similarity index 100% rename from packages/core/src/api/plugin/index.ts rename to packages/core-api/src/plugin/index.ts diff --git a/packages/core/src/api/plugin/types.ts b/packages/core-api/src/plugin/types.ts similarity index 100% rename from packages/core/src/api/plugin/types.ts rename to packages/core-api/src/plugin/types.ts diff --git a/packages/app/src/components/AlertDisplay/index.ts b/packages/core-api/src/private.ts similarity index 93% rename from packages/app/src/components/AlertDisplay/index.ts rename to packages/core-api/src/private.ts index b60bc1776b..65462d8d51 100644 --- a/packages/app/src/components/AlertDisplay/index.ts +++ b/packages/core-api/src/private.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './AlertDisplay'; +export { PrivateAppImpl } from './app/App'; diff --git a/packages/core-api/src/public.ts b/packages/core-api/src/public.ts new file mode 100644 index 0000000000..0a30936c52 --- /dev/null +++ b/packages/core-api/src/public.ts @@ -0,0 +1,22 @@ +/* + * 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 * from './apis'; +export * from './app'; +export * from './icons'; +export * from './plugin'; +export * from './routing'; +export * from './types'; diff --git a/packages/core/src/api/routing/RouteRef.ts b/packages/core-api/src/routing/RouteRef.ts similarity index 100% rename from packages/core/src/api/routing/RouteRef.ts rename to packages/core-api/src/routing/RouteRef.ts diff --git a/packages/core/src/api/routing/index.ts b/packages/core-api/src/routing/index.ts similarity index 100% rename from packages/core/src/api/routing/index.ts rename to packages/core-api/src/routing/index.ts diff --git a/packages/core/src/api/routing/types.ts b/packages/core-api/src/routing/types.ts similarity index 95% rename from packages/core/src/api/routing/types.ts rename to packages/core-api/src/routing/types.ts index 0b6d1c711f..491665bbcf 100644 --- a/packages/core/src/api/routing/types.ts +++ b/packages/core-api/src/routing/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { IconComponent } from '../../icons'; +import { IconComponent } from '../icons'; export type RouteRef = { path: string; diff --git a/packages/core-api/src/setupTests.ts b/packages/core-api/src/setupTests.ts new file mode 100644 index 0000000000..e34bc46f4b --- /dev/null +++ b/packages/core-api/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * 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 '@testing-library/jest-dom'; +require('jest-fetch-mock').enableMocks(); diff --git a/packages/core/src/api/types.ts b/packages/core-api/src/types.ts similarity index 100% rename from packages/core/src/api/types.ts rename to packages/core-api/src/types.ts diff --git a/packages/core/package.json b/packages/core/package.json index e4521530b1..a89ffc0b92 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -28,9 +28,11 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/core-api": "0.1.1-alpha.6", "@backstage/theme": "^0.1.1-alpha.6", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", "@types/classnames": "^2.2.9", "@types/google-protobuf": "^3.7.2", "@types/jest": "^25.2.2", @@ -45,20 +47,17 @@ "prop-types": "^15.7.2", "rc-progress": "^3.0.0", "react": "^16.12.0", - "react-addons-text-content": "0.0.4", "react-dom": "^16.12.0", - "react-helmet": "5.2.1", + "react-helmet": "6.0.0", "react-router": "^5.2.0", "react-router-dom": "^5.2.0", "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^12.2.1", - "react-use": "^14.2.0", - "zen-observable": "^0.8.15" + "react-use": "^14.2.0" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.6", "@backstage/test-utils": "^0.1.1-alpha.6", - "@backstage/test-utils-core": "^0.1.1-alpha.6", "@testing-library/jest-dom": "^5.7.0", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^10.2.4", diff --git a/packages/core/src/api/createApp.tsx b/packages/core/src/api/createApp.tsx new file mode 100644 index 0000000000..d0deed667a --- /dev/null +++ b/packages/core/src/api/createApp.tsx @@ -0,0 +1,69 @@ +/* + * 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 privateExports, { + AppOptions, + ApiRegistry, + defaultSystemIcons, +} from '@backstage/core-api'; + +import ErrorPage from '../layout/ErrorPage'; +import { lightTheme, darkTheme } from '@backstage/theme'; + +const { PrivateAppImpl } = privateExports; + +// createApp is defined in core, and not core-api, since we need access +// to the components inside core to provide defaults. +// The actual implementation of the app class still lives in core-api, +// as it needs to be used by dev- and test-utils. + +/** + * Creates a new Backstage App. + */ +export function createApp(options?: AppOptions) { + const DefaultNotFoundPage = () => ( + + ); + + const apis = options?.apis ?? ApiRegistry.from([]); + const icons = { ...defaultSystemIcons, ...options?.icons }; + const plugins = options?.plugins ?? []; + const components = { + NotFoundErrorPage: DefaultNotFoundPage, + ...options?.components, + }; + const themes = options?.themes ?? [ + { + id: 'light', + title: 'Light Theme', + variant: 'light', + theme: lightTheme, + }, + { + id: 'dark', + title: 'Dark Theme', + variant: 'dark', + theme: darkTheme, + }, + ]; + + const app = new PrivateAppImpl({ apis, icons, plugins, components, themes }); + + app.verify(); + + return app; +} diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index cde010293d..b8136305b0 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -14,8 +14,4 @@ * limitations under the License. */ -export * from './apis'; -export * from './app'; -export * from './routing'; -export * from './plugin'; -export * from './types'; +export { createApp } from './createApp'; diff --git a/packages/app/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx similarity index 78% rename from packages/app/src/components/AlertDisplay/AlertDisplay.tsx rename to packages/core/src/components/AlertDisplay/AlertDisplay.tsx index d9dd0b240b..30940f68ac 100644 --- a/packages/app/src/components/AlertDisplay/AlertDisplay.tsx +++ b/packages/core/src/components/AlertDisplay/AlertDisplay.tsx @@ -15,25 +15,27 @@ */ import React, { FC, useEffect, useState } from 'react'; -import PropTypes from 'prop-types'; import { Snackbar, IconButton } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; import { Alert } from '@material-ui/lab'; -import { AlertApiForwarder, AlertMessage } from '@backstage/core'; +import { AlertMessage, useApi, alertApiRef } from '@backstage/core-api'; -type Props = { - forwarder: AlertApiForwarder; -}; +type Props = {}; // TODO: improve on this and promote to a shared component for use by all apps. -const AlertDisplay: FC = ({ forwarder }) => { +export const AlertDisplay: FC = () => { const [messages, setMessages] = useState>([]); + const alertApi = useApi(alertApiRef); useEffect(() => { - return forwarder.subscribe((message: AlertMessage) => - setMessages(msgs => msgs.concat(message)), - ); - }, [forwarder]); + const subscription = alertApi + .alert$() + .subscribe(message => setMessages(msgs => msgs.concat(message))); + + return () => { + subscription.unsubscribe(); + }; + }, [alertApi]); if (messages.length === 0) { return null; @@ -69,9 +71,3 @@ const AlertDisplay: FC = ({ forwarder }) => { ); }; - -AlertDisplay.propTypes = { - forwarder: PropTypes.instanceOf(AlertApiForwarder).isRequired, -}; - -export default AlertDisplay; diff --git a/packages/core/src/api/apis/implementations/lib/index.ts b/packages/core/src/components/AlertDisplay/index.ts similarity index 94% rename from packages/core/src/api/apis/implementations/lib/index.ts rename to packages/core/src/components/AlertDisplay/index.ts index aa2202858c..7bfac0c981 100644 --- a/packages/core/src/api/apis/implementations/lib/index.ts +++ b/packages/core/src/components/AlertDisplay/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export * from './subjects'; +export * from './AlertDisplay'; diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.tsx index fc7c262882..c77dc5bd43 100644 --- a/packages/core/src/components/CodeSnippet/CodeSnippet.tsx +++ b/packages/core/src/components/CodeSnippet/CodeSnippet.tsx @@ -31,7 +31,7 @@ const defaultProps = { showLineNumbers: false, }; -const CodeSnippet: FC = (props) => { +const CodeSnippet: FC = props => { const { text, language, showLineNumbers } = { ...defaultProps, ...props, diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx index 80c11f6b4a..3dc5854ab3 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.stories.tsx @@ -16,7 +16,12 @@ import React from 'react'; import CopyTextButton from '.'; -import { ApiProvider, errorApiRef, ApiRegistry, ErrorApi } from '../../api'; +import { + ApiProvider, + errorApiRef, + ApiRegistry, + ErrorApi, +} from '@backstage/core-api'; export default { title: 'CopyTextButton', diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx index 695a700738..e0f7271014 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.test.tsx @@ -18,7 +18,12 @@ import React from 'react'; import { render } from '@testing-library/react'; import { wrapInThemedTestApp } from '@backstage/test-utils'; import CopyTextButton from './CopyTextButton'; -import { ApiRegistry, errorApiRef, ApiProvider, ErrorApi } from '../../api'; +import { + ApiRegistry, + errorApiRef, + ApiProvider, + ErrorApi, +} from '@backstage/core-api'; jest.mock('popper.js', () => { const PopperJS = jest.requireActual('popper.js'); @@ -44,6 +49,7 @@ const apiRegistry = ApiRegistry.from([ post(error) { throw error; }, + error$: jest.fn(), } as ErrorApi, ], ]); diff --git a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx index 5af62f4380..1c5028fa9c 100644 --- a/packages/core/src/components/CopyTextButton/CopyTextButton.tsx +++ b/packages/core/src/components/CopyTextButton/CopyTextButton.tsx @@ -19,9 +19,9 @@ import { IconButton, makeStyles, Tooltip } from '@material-ui/core'; import PropTypes from 'prop-types'; import CopyIcon from '@material-ui/icons/FileCopy'; import { BackstageTheme } from '@backstage/theme'; -import { errorApiRef, useApi } from '../../api'; +import { errorApiRef, useApi } from '@backstage/core-api'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ button: { '&:hover': { backgroundColor: theme.palette.highlight, @@ -56,7 +56,7 @@ const defaultProps = { tooltipText: 'Text copied to clipboard', }; -const CopyTextButton: FC = (props) => { +const CopyTextButton: FC = props => { const { text, tooltipDelay, tooltipText } = { ...defaultProps, ...props, @@ -66,7 +66,7 @@ const CopyTextButton: FC = (props) => { const inputRef = useRef(null); const [open, setOpen] = useState(false); - const handleCopyClick: MouseEventHandler = (e) => { + const handleCopyClick: MouseEventHandler = e => { e.stopPropagation(); setOpen(true); diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx new file mode 100644 index 0000000000..e19495a099 --- /dev/null +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.stories.tsx @@ -0,0 +1,66 @@ +/* + * 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 DismissableBanner from './DismissableBanner'; +import { Link, Typography } from '@material-ui/core'; + +export default { + title: 'DismissableBanner', + component: DismissableBanner, +}; + +const containerStyle = { width: '70%' }; + +export const Default = () => ( +

+ +
+); + +export const Error = () => ( +
+ +
+); + +export const EmojisIncluded = () => ( +
+ +
+); + +export const WithLink = () => ( +
+ + This is a dismissable banner with a link:{' '} + + example.com + + + } + variant="info" + /> +
+); diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.test.js b/packages/core/src/components/DismissableBanner/DismissableBanner.test.js new file mode 100644 index 0000000000..485b6226d2 --- /dev/null +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.test.js @@ -0,0 +1,46 @@ +/* + * 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 { fireEvent, waitForElementToBeRemoved } from '@testing-library/react'; +import { renderWithEffects, wrapInThemedTestApp } from '@backstage/test-utils'; +// import { createSetting } from 'shared/apis/settings'; +import DismissableBanner from './DismissableBanner'; + +describe('', () => { + it('renders the message and the popover', async () => { + /* + const mockSetting = createSetting({ + id: 'mockSetting', + defaultValue: true, + }); + */ + + const rendered = await renderWithEffects( + wrapInThemedTestApp( + , + ), + ); + rendered.getByText('test message'); + + // fireEvent.click(rendered.getByTitle('Permanently dismiss this message')); + // await waitForElementToBeRemoved(rendered.queryByText('test message')); + }); +}); diff --git a/packages/core/src/components/DismissableBanner/DismissableBanner.tsx b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx new file mode 100644 index 0000000000..7190ee8725 --- /dev/null +++ b/packages/core/src/components/DismissableBanner/DismissableBanner.tsx @@ -0,0 +1,96 @@ +/* + * 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, ReactNode, useState } from 'react'; +import classNames from 'classnames'; +import { makeStyles, Theme } from '@material-ui/core'; +import Snackbar from '@material-ui/core/Snackbar'; +import SnackbarContent from '@material-ui/core/SnackbarContent'; +import IconButton from '@material-ui/core/IconButton'; +import Close from '@material-ui/icons/Close'; +// import { useSetting, Setting } from 'shared/apis/settings'; + +const useStyles = makeStyles((theme: Theme) => ({ + root: { + position: 'relative', + padding: theme.spacing(0), + marginBottom: theme.spacing(6), + marginTop: -theme.spacing(3), + display: 'flex', + flexFlow: 'row nowrap', + }, + icon: { + fontSize: 20, + }, + content: { + width: '100%', + maxWidth: 'inherit', + }, + message: { + display: 'flex', + alignItems: 'center', + }, + info: { + backgroundColor: theme.palette.primary.main, + }, + error: { + backgroundColor: theme.palette.error.dark, + }, +})); + +type Props = { + variant: 'info' | 'error'; + // setting: Setting; + message: ReactNode; +}; + +const DismissableBanner: FC = ({ variant, /* setting, */ message }) => { + // const [show, setShown, loading] = useSetting(setting); + const [show, setShown] = useState(true); + const classes = useStyles(); + + const handleClick = () => { + setShown(false); + }; + + return ( + + + + , + ]} + /> + + ); +}; + +export default DismissableBanner; diff --git a/packages/core/src/components/DismissableBanner/index.ts b/packages/core/src/components/DismissableBanner/index.ts new file mode 100644 index 0000000000..28091e858b --- /dev/null +++ b/packages/core/src/components/DismissableBanner/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 './DismissableBanner'; diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx index 36079854fd..d7f1e2387a 100644 --- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx +++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx @@ -25,7 +25,7 @@ describe('', () => { jest.spyOn(window.performance, 'now').mockReturnValue(5); jest .spyOn(window, 'requestAnimationFrame') - .mockImplementation((cb) => cb(20)); + .mockImplementation(cb => cb(20)); }); afterEach(() => { diff --git a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index 8e3d98c998..cc7d99660b 100644 --- a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -23,9 +23,9 @@ import { Theme, } from '@material-ui/core'; import React, { FC, useState } from 'react'; -import { PendingAuthRequest } from '../../api'; +import { PendingAuthRequest } from '@backstage/core-api'; -const useItemStyles = makeStyles((theme) => ({ +const useItemStyles = makeStyles(theme => ({ root: { paddingLeft: theme.spacing(3), }, diff --git a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx index 617cce2aee..07078c3fa2 100644 --- a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx +++ b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -27,9 +27,9 @@ import { import React, { FC, useMemo, useState } from 'react'; import { useObservable } from 'react-use'; import LoginRequestListItem from './LoginRequestListItem'; -import { useApi, oauthRequestApiRef } from '../../api'; +import { useApi, oauthRequestApiRef } from '@backstage/core-api'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ dialog: { paddingTop: theme.spacing(1), }, @@ -53,7 +53,7 @@ export const OAuthRequestDialog: FC = () => { ); const handleRejectAll = () => { - requests.forEach((request) => request.reject()); + requests.forEach(request => request.reject()); }; return ( @@ -69,7 +69,7 @@ export const OAuthRequestDialog: FC = () => { - {requests.map((request) => ( + {requests.map(request => ( ((theme) => ({ +const useStyles = makeStyles(theme => ({ status: { fontWeight: 500, '&::before': { @@ -63,26 +63,26 @@ const useStyles = makeStyles((theme) => ({ }, })); -export const StatusOK: FC<{}> = (props) => { +export const StatusOK: FC<{}> = props => { const classes = useStyles(props); return ; }; -export const StatusWarning: FC<{}> = (props) => { +export const StatusWarning: FC<{}> = props => { const classes = useStyles(props); return ( ); }; -export const StatusError: FC<{}> = (props) => { +export const StatusError: FC<{}> = props => { const classes = useStyles(props); return ( ); }; -export const StatusPending: FC<{}> = (props) => { +export const StatusPending: FC<{}> = props => { const classes = useStyles(props); return ( = (props) => { ); }; -export const StatusRunning: FC<{}> = (props) => { +export const StatusRunning: FC<{}> = props => { const classes = useStyles(props); return ( = (props) => { ); }; -export const StatusAborted: FC<{}> = (props) => { +export const StatusAborted: FC<{}> = props => { const classes = useStyles(props); return ( ', () => { , ); const keys = Object.keys(metadata); - keys.forEach((value) => { + keys.forEach(value => { expect(getByText(startCase(value))).toBeInTheDocument(); expect(getByText(metadata[value])).toBeInTheDocument(); }); @@ -49,7 +49,7 @@ describe('', () => { ); const keys = Object.keys(metadata); - keys.forEach((value) => { + keys.forEach(value => { expect(getByText(startCase(value))).toBeInTheDocument(); expect(getByText(metadata[value].toString())).toBeInTheDocument(); }); @@ -61,10 +61,10 @@ describe('', () => { , ); const keys = Object.keys(metadata); - keys.forEach((value) => { + keys.forEach(value => { expect(getByText(startCase(value))).toBeInTheDocument(); }); - metadata.arrayField.forEach((value) => { + metadata.arrayField.forEach(value => { expect(getByText(value)).toBeInTheDocument(); }); }); @@ -85,7 +85,7 @@ describe('', () => { ); const keys = Object.keys(metadata.config); - keys.forEach((value) => { + keys.forEach(value => { expect( getByText(startCase(value), { exact: false }), ).toBeInTheDocument(); diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index bf2c5a3fc7..dbe87963ee 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -76,6 +76,42 @@ export const DefaultTable = () => { ); }; +export const SubtitleTable = () => { + const columns: TableColumn[] = [ + { + title: 'Column 1', + field: 'col1', + highlight: true, + }, + { + title: 'Column 2', + field: 'col2', + }, + { + title: 'Numeric value', + field: 'number', + type: 'numeric', + }, + { + title: 'A Date', + field: 'date', + type: 'date', + }, + ]; + + return ( +
+ + + ); +}; + export const HiddenSearchTable = () => { const columns: TableColumn[] = [ { diff --git a/packages/core/src/components/Table/Table.test.tsx b/packages/core/src/components/Table/Table.test.tsx index f6801359eb..fb87db1b4b 100644 --- a/packages/core/src/components/Table/Table.test.tsx +++ b/packages/core/src/components/Table/Table.test.tsx @@ -47,4 +47,11 @@ describe('
', () => { const rendered = render(wrapInTestApp(
)); expect(rendered.getByText('second value, second row')).toBeInTheDocument(); }); + + it('renders with subtitle', () => { + const rendered = render( + wrapInTestApp(
), + ); + expect(rendered.getByText('subtitle')).toBeInTheDocument(); + }); }); diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index a4dbc89b4f..b092f6d39b 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -24,7 +24,7 @@ import MTable, { Column, } from 'material-table'; import { BackstageTheme } from '@backstage/theme'; -import { makeStyles, useTheme } from '@material-ui/core'; +import { makeStyles, useTheme, Typography } from '@material-ui/core'; // Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51 import AddBox from '@material-ui/icons/AddBox'; @@ -158,9 +158,16 @@ export interface TableColumn extends Column<{}> { export interface TableProps extends MaterialTableProps<{}> { columns: TableColumn[]; + subtitle?: string; } -const Table: FC = ({ columns, options, ...props }) => { +const Table: FC = ({ + columns, + options, + title, + subtitle, + ...props +}) => { const cellClasses = useCellStyles(); const headerClasses = useHeaderStyles(); const toolbarClasses = useToolbarStyles(); @@ -190,6 +197,16 @@ const Table: FC = ({ columns, options, ...props }) => { options={{ ...defaultOptions, ...options }} columns={MTColumns} icons={tableIcons} + title={ + <> + {title} + {subtitle && ( + + {subtitle} + + )} + + } {...props} /> ); diff --git a/packages/core/src/icons/icons.tsx b/packages/core/src/icons/icons.tsx index ca3ef32859..e8834a6162 100644 --- a/packages/core/src/icons/icons.tsx +++ b/packages/core/src/icons/icons.tsx @@ -18,7 +18,7 @@ import { SvgIconProps } from '@material-ui/core'; import PeopleIcon from '@material-ui/icons/People'; import PersonIcon from '@material-ui/icons/Person'; import React, { FC } from 'react'; -import { useApp } from '../api/app/AppContext'; +import { useApp } from '@backstage/core-api'; import { IconComponent, SystemIconKey, SystemIcons } from './types'; export const defaultSystemIcons: SystemIcons = { @@ -27,7 +27,7 @@ export const defaultSystemIcons: SystemIcons = { }; const overridableSystemIcon = (key: SystemIconKey): IconComponent => { - const Component: FC = (props) => { + const Component: FC = props => { const app = useApp(); const Icon = app.getSystemIcon(key); return ; diff --git a/packages/core/src/icons/types.ts b/packages/core/src/icons/types.ts index 30e0ba53b2..599cb969df 100644 --- a/packages/core/src/icons/types.ts +++ b/packages/core/src/icons/types.ts @@ -16,7 +16,6 @@ import { ComponentType } from 'react'; import { SvgIconProps } from '@material-ui/core'; - export type IconComponent = ComponentType; export type SystemIconKey = 'user' | 'group'; export type SystemIcons = { [key in SystemIconKey]: IconComponent }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 415f167067..be9cdc432d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +export * from '@backstage/core-api'; + export * from './api'; export { default as Page } from './layout/Page'; export { gradients, pageTheme } from './layout/Page'; @@ -21,6 +23,7 @@ export type { PageTheme } from './layout/Page'; export { default as CodeSnippet } from './components/CodeSnippet'; export { default as Content } from './layout/Content/Content'; export { default as ContentHeader } from './layout/ContentHeader/ContentHeader'; +export { default as DismissableBanner } from './components/DismissableBanner'; export { default as Header } from './layout/Header/Header'; export { default as HeaderLabel } from './layout/HeaderLabel'; export { default as HomepageTimer } from './layout/HomepageTimer'; @@ -28,6 +31,8 @@ export { default as InfoCard } from './layout/InfoCard'; export { CardTab, TabbedCard } from './layout/TabbedCard'; export { default as ErrorBoundary } from './layout/ErrorBoundary'; export * from './layout/Sidebar'; +export * from './layout/LoginPage'; +export { AlertDisplay } from './components/AlertDisplay'; export { default as HorizontalScrollGrid } from './components/HorizontalScrollGrid'; export { default as ProgressCard } from './components/ProgressBars/ProgressCard'; export { default as CircleProgress } from './components/ProgressBars/CircleProgress'; @@ -45,3 +50,4 @@ export { default as TrendLine } from './components/TrendLine'; export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular'; export * from './components/Status'; export { default as WarningPanel } from './components/WarningPanel'; +export type { IconComponent } from './icons'; diff --git a/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx b/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx index 6f5a1b24cc..703c27e32c 100644 --- a/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx +++ b/packages/core/src/layout/ContentHeader/ContentHeader.test.tsx @@ -20,7 +20,9 @@ import ContentHeader from './ContentHeader'; import { wrapInThemedTestApp } from '@backstage/test-utils'; jest.mock('react-helmet', () => { - return ({ defaultTitle }: any) =>
defaultTitle: {defaultTitle}
; + return { + Helmet: ({ defaultTitle }: any) =>
defaultTitle: {defaultTitle}
, + }; }); describe('', () => { diff --git a/packages/core/src/layout/ContentHeader/ContentHeader.tsx b/packages/core/src/layout/ContentHeader/ContentHeader.tsx index 409cfe6b90..1a51d5d19b 100644 --- a/packages/core/src/layout/ContentHeader/ContentHeader.tsx +++ b/packages/core/src/layout/ContentHeader/ContentHeader.tsx @@ -20,7 +20,7 @@ import React, { ComponentType, Fragment, FC } from 'react'; import { Typography, makeStyles } from '@material-ui/core'; -import Helmet from 'react-helmet'; +import { Helmet } from 'react-helmet'; const useStyles = makeStyles(theme => ({ container: { diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx index 14241c1ee7..747f2f6036 100644 --- a/packages/core/src/layout/ErrorPage/ErrorPage.tsx +++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx @@ -26,7 +26,7 @@ interface IErrorPageProps { statusMessage: string; } -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ container: { padding: theme.spacing(8), }, diff --git a/packages/core/src/layout/Header/Header.stories.tsx b/packages/core/src/layout/Header/Header.stories.tsx new file mode 100644 index 0000000000..e1198d7b65 --- /dev/null +++ b/packages/core/src/layout/Header/Header.stories.tsx @@ -0,0 +1,94 @@ +/* + * 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 Header from '.'; +import HeaderLabel from '../HeaderLabel'; +import Page, { pageTheme } from '../Page'; + +export default { + title: 'Header', + component: Header, +}; + +const labels = ( + <> + + + + +); + +export const Home = () => ( + +
+ {labels} +
+
+); + +export const HomeWithSubtitle = () => ( +
+ {labels} +
+); + +export const Tool = () => ( + +
+ {labels} +
+
+); + +export const Service = () => ( + +
+ {labels} +
+
+); + +export const Website = () => ( + +
+ {labels} +
+
+); + +export const Library = () => ( + +
+ {labels} +
+
+); + +export const App = () => ( + +
+ {labels} +
+
+); + +export const Other = () => ( + +
+ {labels} +
+
+); diff --git a/packages/core/src/layout/Header/Header.test.tsx b/packages/core/src/layout/Header/Header.test.tsx index 85b15e33aa..afedf2ad35 100644 --- a/packages/core/src/layout/Header/Header.test.tsx +++ b/packages/core/src/layout/Header/Header.test.tsx @@ -20,7 +20,9 @@ import { wrapInThemedTestApp } from '@backstage/test-utils'; import Header from './Header'; jest.mock('react-helmet', () => { - return ({ defaultTitle }: any) =>
defaultTitle: {defaultTitle}
; + return { + Helmet: ({ defaultTitle }: any) =>
defaultTitle: {defaultTitle}
, + }; }); describe('
', () => { diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index ab3d0080e4..47fcc324a7 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -15,15 +15,14 @@ */ import React, { Fragment, ReactNode, CSSProperties, FC } from 'react'; -import Helmet from 'react-helmet'; +import { Helmet } from 'react-helmet'; import { Typography, Tooltip, makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; import { Theme } from '../Page/Page'; -// import { Link } from 'shared/components'; import Waves from './Waves'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ header: { gridArea: 'pageHeader', padding: theme.spacing(3), @@ -149,7 +148,7 @@ const SubtitleFragment: FC = ({ classes, subtitle }) => { } return ( - + {subtitle} ); @@ -175,7 +174,7 @@ export const Header: FC = ({ - {(theme) => ( + {theme => (
diff --git a/packages/core/src/api/app/LoginPage/LoginPage.tsx b/packages/core/src/layout/LoginPage/LoginPage.tsx similarity index 92% rename from packages/core/src/api/app/LoginPage/LoginPage.tsx rename to packages/core/src/layout/LoginPage/LoginPage.tsx index cdf0b55fd4..e7c83d563c 100644 --- a/packages/core/src/api/app/LoginPage/LoginPage.tsx +++ b/packages/core/src/layout/LoginPage/LoginPage.tsx @@ -16,10 +16,10 @@ import React, { FC, useState } from 'react'; import GitHubIcon from '@material-ui/icons/GitHub'; -import Page from '../../../layout/Page'; -import Header from '../../../layout/Header'; -import Content from '../../../layout/Content/Content'; -import ContentHeader from '../../../layout/ContentHeader/ContentHeader'; +import Page from '../Page'; +import Header from '../Header'; +import Content from '../Content/Content'; +import ContentHeader from '../ContentHeader/ContentHeader'; import { Grid, Typography, @@ -29,13 +29,13 @@ import { ListItem, Link, } from '@material-ui/core'; -import InfoCard from '../../../layout/InfoCard/InfoCard'; +import InfoCard from '../InfoCard/InfoCard'; enum AuthType { GitHub, } -const LoginPage: FC<{}> = () => { +export const LoginPage: FC<{}> = () => { const [githubUsername, setGithubUsername] = useState(String); const [githubPersonalAuthToken, setGithubPersonalAuthToken] = useState( String, @@ -72,11 +72,11 @@ const LoginPage: FC<{}> = () => { 'Content-Type': 'application/x-www-form-urlencoded', }), }) - .then((response) => { + .then(response => { if (response.status === 200) return response.json(); throw Error(`${response.status} ${response.statusText}`); }) - .then((data) => { + .then(data => { const info = { username: username, token: token, @@ -176,5 +176,3 @@ const LoginPage: FC<{}> = () => { ); }; - -export default LoginPage; diff --git a/packages/core/src/api/app/LoginPage/index.ts b/packages/core/src/layout/LoginPage/index.ts similarity index 93% rename from packages/core/src/api/app/LoginPage/index.ts rename to packages/core/src/layout/LoginPage/index.ts index 094029448c..caa94bd6d7 100644 --- a/packages/core/src/api/app/LoginPage/index.ts +++ b/packages/core/src/layout/LoginPage/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './LoginPage'; +export { LoginPage } from './LoginPage'; diff --git a/packages/core/src/layout/Page/PageThemeProvider.ts b/packages/core/src/layout/Page/PageThemeProvider.ts index a767033029..9c31fcb4ac 100644 --- a/packages/core/src/layout/Page/PageThemeProvider.ts +++ b/packages/core/src/layout/Page/PageThemeProvider.ts @@ -89,4 +89,19 @@ export const pageTheme: Record = { tool: { gradient: gradients.purpleBlue, }, + service: { + gradient: gradients.green, + }, + website: { + gradient: gradients.purple, + }, + library: { + gradient: gradients.sunset, + }, + other: { + gradient: gradients.brown, + }, + app: { + gradient: gradients.redOrange, + }, }; diff --git a/packages/core/src/layout/Sidebar/Intro.tsx b/packages/core/src/layout/Sidebar/Intro.tsx index 595697c466..b4bb56588e 100644 --- a/packages/core/src/layout/Sidebar/Intro.tsx +++ b/packages/core/src/layout/Sidebar/Intro.tsx @@ -26,7 +26,7 @@ import { } from './config'; import { SidebarDivider } from './Items'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ introCard: { color: '#b5b5b5', // XXX (@koroeskohr): should I be using a Mui theme variable? @@ -74,7 +74,7 @@ type IntroCardProps = { onClose: () => void; }; -export const IntroCard: FC = (props) => { +export const IntroCard: FC = props => { const classes = useStyles(); const { text, onClose } = props; const handleClose = () => onClose(); @@ -109,7 +109,7 @@ type SidebarIntroCardProps = { onDismiss: () => void; }; -const SidebarIntroCard: FC = (props) => { +const SidebarIntroCard: FC = props => { const { text, onDismiss } = props; const [collapsing, setCollapsing] = useState(false); const startDismissing = () => { @@ -138,10 +138,10 @@ export const SidebarIntro: FC = () => { }); const dismissStarred = () => { - setDismissedIntro((state) => ({ ...state, starredItemsDismissed: true })); + setDismissedIntro(state => ({ ...state, starredItemsDismissed: true })); }; const dismissRecentlyViewed = () => { - setDismissedIntro((state) => ({ + setDismissedIntro(state => ({ ...state, recentlyViewedItemsDismissed: true, })); diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index bd5faa5631..4696f53a37 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -29,7 +29,7 @@ import { NavLink } from 'react-router-dom'; import { sidebarConfig, SidebarContext } from './config'; import { IconComponent } from '../../icons'; -const useStyles = makeStyles((theme) => { +const useStyles = makeStyles(theme => { const { selectedIndicatorWidth, drawerWidthClosed, @@ -139,7 +139,7 @@ export const SidebarItem: FC = ({ Boolean(match && !disableSelected)} + isActive={match => Boolean(match && !disableSelected)} exact to={to} onClick={onClick} @@ -153,7 +153,7 @@ export const SidebarItem: FC = ({ Boolean(match && !disableSelected)} + isActive={match => Boolean(match && !disableSelected)} exact to={to} onClick={onClick} @@ -175,11 +175,11 @@ type SidebarSearchFieldProps = { onSearch: (input: string) => void; }; -export const SidebarSearchField: FC = (props) => { +export const SidebarSearchField: FC = props => { const [input, setInput] = useState(''); const classes = useStyles(); - const handleEnter: KeyboardEventHandler = (ev) => { + const handleEnter: KeyboardEventHandler = ev => { if (ev.key === 'Enter') { props.onSearch(input); } diff --git a/packages/core/src/layout/Sidebar/Page.tsx b/packages/core/src/layout/Sidebar/Page.tsx index 79df2852d9..750f764c2d 100644 --- a/packages/core/src/layout/Sidebar/Page.tsx +++ b/packages/core/src/layout/Sidebar/Page.tsx @@ -44,7 +44,7 @@ export const SidebarPinStateContext = createContext( }, ); -export const SidebarPage: FC<{}> = (props) => { +export const SidebarPage: FC<{}> = props => { const [isPinned, setIsPinned] = useState(LocalStorage.getSidebarPinState()); useEffect(() => { diff --git a/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx b/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx index 7f4c8890f9..32bc9d9632 100644 --- a/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx +++ b/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx @@ -19,7 +19,7 @@ import { useObservable } from 'react-use'; import LightIcon from '@material-ui/icons/WbSunny'; import DarkIcon from '@material-ui/icons/Brightness2'; import AutoIcon from '@material-ui/icons/BrightnessAuto'; -import { appThemeApiRef, useApi } from '../../api'; +import { appThemeApiRef, useApi } from '@backstage/core-api'; import { SidebarItem } from './Items'; export const SidebarThemeToggle: FC<{}> = () => { diff --git a/packages/dev-utils/src/devApp/apiFactories.test.ts b/packages/dev-utils/src/devApp/apiFactories.test.ts index 55455346b9..be3ae2cdbe 100644 --- a/packages/dev-utils/src/devApp/apiFactories.test.ts +++ b/packages/dev-utils/src/devApp/apiFactories.test.ts @@ -15,12 +15,14 @@ */ import * as apiFactories from './apiFactories'; -import { ApiTestRegistry } from '@backstage/core'; +import { ApiTestRegistry, ApiFactory } from '@backstage/core'; describe('apiFactories', () => { it('should be possible to get an instance of each API', () => { const registry = new ApiTestRegistry(); - const factories = Object.values(apiFactories); + const factories: ApiFactory[] = Object.values( + apiFactories, + ); for (const factory of factories) { registry.register(factory); diff --git a/packages/dev-utils/src/devApp/apiFactories.ts b/packages/dev-utils/src/devApp/apiFactories.ts index a03faf0033..162375cf1c 100644 --- a/packages/dev-utils/src/devApp/apiFactories.ts +++ b/packages/dev-utils/src/devApp/apiFactories.ts @@ -20,6 +20,8 @@ import { ErrorApiForwarder, AlertApi, createApiFactory, + ErrorAlerter, + AlertApiForwarder, } from '@backstage/core'; // TODO(rugvip): We should likely figure out how to reuse all of these between apps @@ -30,18 +32,12 @@ import { export const alertApiFactory = createApiFactory({ implements: alertApiRef, deps: {}, - factory: (): AlertApi => ({ - // TODO: Figure out how to ship a nicer implementation without having - // to export any external references. - post(alertConfig) { - // eslint-disable-next-line no-alert - alert(`Alert[${alertConfig.severity}]: ${alertConfig.message}`); - }, - }), + factory: (): AlertApi => new AlertApiForwarder(), }); export const errorApiFactory = createApiFactory({ implements: errorApiRef, deps: { alertApi: alertApiRef }, - factory: ({ alertApi }) => new ErrorApiForwarder(alertApi), + factory: ({ alertApi }) => + new ErrorAlerter(alertApi, new ErrorApiForwarder()), }); diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 1922e056a3..72c9581a84 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -29,6 +29,7 @@ import { createPlugin, ApiTestRegistry, ApiHolder, + AlertDisplay, } from '@backstage/core'; import * as defaultApiFactories from './apiFactories'; @@ -77,6 +78,7 @@ class DevAppBuilder { const DevApp: FC<{}> = () => { return ( + {sidebar} diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index 5f9bde16c3..b8f78f5d2c 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -12,7 +12,7 @@ module.exports = { '@storybook/addon-storysource', 'storybook-dark-mode/register', ], - webpackFinal: async (config) => { + webpackFinal: async config => { const coreSrc = path.resolve(__dirname, '../../core/src'); // Mirror config in packages/cli/src/lib/bundler diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 209b7371dc..883fb1452a 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -22,6 +22,6 @@ "@storybook/addon-storysource": "^5.3.18", "@storybook/addons": "^5.3.17", "@storybook/react": "^5.3.17", - "storybook-dark-mode": "^0.4.1" + "storybook-dark-mode": "^0.6.1" } } diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 6b85bdfe1c..fe82e544e7 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -25,10 +25,16 @@ "morgan": "^1.10.0", "winston": "^3.2.1", "yn": "^4.0.0", - "passport": "0.4.1", - "passport-google-oauth20": "2.0.0", - "@types/passport": "1.0.3", - "@types/passport-google-oauth20": "2.0.3" + "passport": "^0.4.1", + "passport-google-oauth20": "^2.0.0", + "passport-oauth2-refresh": "^2.0.0", + "passport-oauth2": "^1.5.0", + "cookie-parser": "^1.4.5", + "@types/passport-oauth2-refresh": "^1.1.1", + "@types/passport": "^1.0.3", + "@types/passport-google-oauth20": "^2.0.3", + "@types/cookie-parser": "^1.4.2", + "@types/passport-oauth2": "^1.4.9" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.6", diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts index 845c46a4da..327bf0260b 100644 --- a/plugins/auth-backend/src/providers/google/provider.test.ts +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -14,10 +14,15 @@ * limitations under the License. */ -import { GoogleAuthProvider } from './provider'; +import { + GoogleAuthProvider, + THOUSAND_DAYS_MS, + TEN_MINUTES_MS, +} from './provider'; import passport from 'passport'; import express from 'express'; import * as utils from './../utils'; +import refresh from 'passport-oauth2-refresh'; const googleAuthProviderConfig = { provider: 'google', @@ -51,11 +56,16 @@ describe('GoogleAuthProvider', () => { }); describe('start authentication handler', () => { - const mockResponse: any = ({} as unknown) as express.Response; + const mockResponse = ({ + send: jest.fn().mockReturnThis(), + status: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), + } as unknown) as express.Response; const mockNext: express.NextFunction = jest.fn(); it('should initiate authenticate request with provided scopes', () => { const mockRequest = ({ + header: () => 'XMLHttpRequest', query: { scope: 'a,b', }, @@ -74,11 +84,36 @@ describe('GoogleAuthProvider', () => { scope: 'a,b', accessType: 'offline', prompt: 'consent', + state: expect.any(String), }); }); + it('should set a nonce cookie', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + query: { + scope: 'a,b', + }, + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + googleAuthProvider.start(mockRequest, mockResponse, mockNext); + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith( + 'google-nonce', + expect.any(String), + expect.objectContaining({ + maxAge: TEN_MINUTES_MS, + path: `/auth/${googleAuthProviderConfig.provider}/handler`, + }), + ); + }); + it('should throw error if no scopes provided', () => { const mockRequest = ({ + header: () => 'XMLHttpRequest', query: {}, } as unknown) as express.Request; @@ -92,11 +127,14 @@ describe('GoogleAuthProvider', () => { }); describe('logout handler', () => { - const mockRequest = ({} as unknown) as express.Request; + const mockRequest = ({ + header: () => 'XMLHttpRequest', + } as unknown) as express.Request; it('should perform logout and respond with 200', () => { const mockResponse: any = ({ send: jest.fn(), + cookie: jest.fn(), } as unknown) as express.Response; const googleAuthProvider = new GoogleAuthProvider( @@ -110,23 +148,83 @@ describe('GoogleAuthProvider', () => { googleAuthProvider.logout(mockRequest, mockResponse); expect(spyResponse).toBeCalledTimes(1); expect(spyResponse).toBeCalledWith('logout!'); + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith( + 'google-refresh-token', + '', + expect.objectContaining({ maxAge: 0 }), + ); }); }); describe('redirect frame handler', () => { - const mockRequest = ({} as unknown) as express.Request; const mockResponse: any = ({ - send: jest.fn(), + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + cookie: jest.fn().mockReturnThis(), } as unknown) as express.Response; const mockNext: express.NextFunction = jest.fn(); - it('should call authenticate and post a response ', () => { + it('should call authenticate and post a response', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-nonce': 'NONCE' }, + query: { + state: 'NONCE', + }, + } as unknown) as express.Request; + const spyPostMessage = jest .spyOn(utils, 'postMessageResponse') .mockImplementation(() => jest.fn()); const spyPassport = jest .spyOn(passport, 'authenticate') + .mockImplementation((_x, callbackFunc) => { + const cb = callbackFunc as Function; + cb(null, { refreshToken: 'REFRESH_TOKEN' }); + return jest.fn(); + }); + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext); + expect(spyPassport).toBeCalledTimes(1); + expect(spyPostMessage).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledTimes(1); + expect(mockResponse.cookie).toBeCalledWith( + 'google-refresh-token', + 'REFRESH_TOKEN', + expect.objectContaining({ + path: '/auth/google', + sameSite: 'none', + httpOnly: true, + maxAge: THOUSAND_DAYS_MS, + }), + ); + }); + + it('should respond with a error message if no refresh token returned', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-nonce': 'NONCE' }, + query: { + state: 'NONCE', + }, + } as unknown) as express.Request; + + const spyPassport = jest + .spyOn(passport, 'authenticate') + .mockImplementation((_x, callbackFunc) => { + const cb = callbackFunc as Function; + cb(null, {}); + return jest.fn(); + }); + + const spyPostMessage = jest + .spyOn(utils, 'postMessageResponse') .mockImplementation(() => jest.fn()); const googleAuthProvider = new GoogleAuthProvider( @@ -134,10 +232,100 @@ describe('GoogleAuthProvider', () => { ); googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext); - const callbackFunc = spyPassport.mock.calls[0][1] as Function; - callbackFunc(); expect(spyPassport).toBeCalledTimes(1); expect(spyPostMessage).toBeCalledTimes(1); + expect(spyPostMessage).toBeCalledWith(mockResponse, { + type: 'auth-result', + error: new Error('Missing refresh token'), + }); + }); + + it('should respond with a error message if auth failed', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-nonce': 'NONCE' }, + query: { + state: 'NONCE', + }, + } as unknown) as express.Request; + + const spyPassport = jest + .spyOn(passport, 'authenticate') + .mockImplementation((_x, callbackFunc) => { + const cb = callbackFunc as Function; + cb(new Error('TokenError'), null); + return jest.fn(); + }); + + const spyPostMessage = jest + .spyOn(utils, 'postMessageResponse') + .mockImplementation(() => jest.fn()); + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext); + expect(spyPassport).toBeCalledTimes(1); + expect(spyPostMessage).toBeCalledTimes(1); + expect(spyPostMessage).toBeCalledWith(mockResponse, { + type: 'auth-result', + error: new Error('Google auth failed, Error: TokenError'), + }); + }); + + it('should respond with a error message if cookie nonce is missing', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: {}, + query: { state: 'NONCE' }, + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith('Missing nonce'); + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); + }); + + it('should respond with a error message if state nonce is missing', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-nonce': 'NONCE' }, + query: {}, + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith('Missing nonce'); + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); + }); + + it('should respond with a error message if nonce mismatch', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-nonce': 'NONCA' }, + query: { state: 'NONCEB' }, + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith('Invalid nonce'); + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); }); }); @@ -159,4 +347,176 @@ describe('GoogleAuthProvider', () => { }).toThrow(); }); }); + + describe('refresh token handler', () => { + const mockResponse = ({ + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + } as unknown) as express.Response; + + describe('no refresh token cookie', () => { + it('should respond with a 401', () => { + const mockRequest = ({ + cookies: jest.fn(), + header: () => 'XMLHttpRequest', + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + googleAuthProvider.refresh(mockRequest, mockResponse); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith('Missing session cookie'); + + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); + }); + }); + + describe('refresh token cookie, no scope', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-refresh-token': 'REFRESH_TOKEN' }, + query: {}, + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + it('should request for a new access token and fail if no access token returned', () => { + const spyRefresh = jest + .spyOn(refresh, 'requestNewAccessToken') + .mockImplementation((_x, _y, _z, callbackFunc) => { + const cb = callbackFunc as Function; + cb(undefined, undefined, undefined, {}); + }); + + googleAuthProvider.refresh(mockRequest, mockResponse); + expect(spyRefresh).toBeCalledTimes(1); + expect(spyRefresh).toBeCalledWith( + 'google', + 'REFRESH_TOKEN', + {}, + expect.any(Function), + ); + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith( + 'Failed to refresh access token', + ); + }); + + it('should request for a new access token and return 401 if any error', () => { + const spyRefresh = jest + .spyOn(refresh, 'requestNewAccessToken') + .mockImplementation((_x, _y, _z, callbackFunc) => { + const cb = callbackFunc as Function; + cb({ error: 'ERROR' }, undefined, undefined, {}); + }); + + googleAuthProvider.refresh(mockRequest, mockResponse); + expect(spyRefresh).toBeCalledTimes(1); + expect(spyRefresh).toBeCalledWith( + 'google', + 'REFRESH_TOKEN', + {}, + expect.any(Function), + ); + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith( + 'Failed to refresh access token', + ); + }); + + it('should fetch and return a new access token', () => { + const spyRefresh = jest + .spyOn(refresh, 'requestNewAccessToken') + .mockImplementation((_x, _y, _z, callbackFunc) => { + const cb = callbackFunc as Function; + cb(undefined, 'ACCESS_TOKEN', undefined, { + expires_in: 'EXPIRES_IN', + id_token: 'ID_TOKEN', + }); + }); + + googleAuthProvider.refresh(mockRequest, mockResponse); + expect(spyRefresh).toBeCalledTimes(1); + expect(spyRefresh).toBeCalledWith( + 'google', + 'REFRESH_TOKEN', + {}, + expect.any(Function), + ); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith({ + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 'EXPIRES_IN', + scope: undefined, + }); + }); + }); + + describe('refresh token cookie and scope', () => { + const mockRequest = ({ + header: () => 'XMLHttpRequest', + cookies: { 'google-refresh-token': 'REFRESH_TOKEN' }, + query: { + scope: 'a,b', + }, + } as unknown) as express.Request; + + const googleAuthProvider = new GoogleAuthProvider( + googleAuthProviderConfig, + ); + + it('should fetch and return a new access token with scopes', () => { + const spyRefresh = jest + .spyOn(refresh, 'requestNewAccessToken') + .mockImplementation((_x, _y, _z, callbackFunc) => { + const cb = callbackFunc as Function; + cb(undefined, 'ACCESS_TOKEN', undefined, { + expires_in: 'EXPIRES_IN', + id_token: 'ID_TOKEN', + scope: 'a,b', + }); + }); + + googleAuthProvider.refresh(mockRequest, mockResponse); + expect(spyRefresh).toBeCalledTimes(1); + expect(spyRefresh).toBeCalledWith( + 'google', + 'REFRESH_TOKEN', + { scope: 'a,b' }, + expect.any(Function), + ); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith({ + accessToken: 'ACCESS_TOKEN', + idToken: 'ID_TOKEN', + expiresInSeconds: 'EXPIRES_IN', + scope: 'a,b', + }); + }); + + it('ensures x-requested-with header', () => { + const mockHeaderRequest = ({ + header: () => 'TEST', + } as unknown) as express.Request; + + googleAuthProvider.refresh(mockHeaderRequest, mockResponse); + expect(mockResponse.send).toBeCalledTimes(1); + expect(mockResponse.send).toBeCalledWith( + 'Invalid X-Requested-With header', + ); + expect(mockResponse.status).toBeCalledTimes(1); + expect(mockResponse.status).toBeCalledWith(401); + }); + }); + }); }); diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 0ef7e19d3e..cb080e2fd3 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -15,16 +15,20 @@ */ import passport from 'passport'; -import express from 'express'; +import express, { CookieOptions } from 'express'; +import crypto from 'crypto'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import refresh from 'passport-oauth2-refresh'; import { AuthProvider, AuthProviderRouteHandlers, AuthProviderConfig, } from './../types'; -import { postMessageResponse } from './../utils'; +import { postMessageResponse, ensuresXRequestedWith } from './../utils'; import { InputError } from '@backstage/backend-common'; +export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000; +export const TEN_MINUTES_MS = 600 * 1000; export class GoogleAuthProvider implements AuthProvider, AuthProviderRouteHandlers { private readonly providerConfig: AuthProviderConfig; @@ -37,6 +41,19 @@ export class GoogleAuthProvider res: express.Response, next: express.NextFunction, ) { + const nonce = crypto.randomBytes(16).toString('base64'); + + const options: CookieOptions = { + maxAge: TEN_MINUTES_MS, + secure: false, + sameSite: 'none', + domain: 'localhost', + path: `/auth/${this.providerConfig.provider}/handler`, + httpOnly: true, + }; + + res.cookie(`${this.providerConfig.provider}-nonce`, nonce, options); + const scope = req.query.scope?.toString() ?? ''; if (!scope) { throw new InputError('missing scope parameter'); @@ -45,6 +62,7 @@ export class GoogleAuthProvider scope, accessType: 'offline', prompt: 'consent', + state: nonce, })(req, res, next); } @@ -53,16 +71,106 @@ export class GoogleAuthProvider res: express.Response, next: express.NextFunction, ) { - return passport.authenticate('google', (_, user) => { - postMessageResponse(res, { + const cookieNonce = req.cookies[`${this.providerConfig.provider}-nonce`]; + const stateNonce = req.query.state; + + if (!cookieNonce || !stateNonce) { + return res.status(401).send('Missing nonce'); + } + + if (cookieNonce !== stateNonce) { + return res.status(401).send('Invalid nonce'); + } + + return passport.authenticate('google', (err, user) => { + if (err) { + return postMessageResponse(res, { + type: 'auth-result', + error: new Error(`Google auth failed, ${err}`), + }); + } + + const { refreshToken } = user; + + if (!refreshToken) { + return postMessageResponse(res, { + type: 'auth-result', + error: new Error('Missing refresh token'), + }); + } + + delete user.refreshToken; + + const options: CookieOptions = { + maxAge: THOUSAND_DAYS_MS, + secure: false, + sameSite: 'none', + domain: 'localhost', + path: `/auth/${this.providerConfig.provider}`, + httpOnly: true, + }; + + res.cookie( + `${this.providerConfig.provider}-refresh-token`, + refreshToken, + options, + ); + return postMessageResponse(res, { type: 'auth-result', payload: user, }); })(req, res, next); } - async logout(_req: express.Request, res: express.Response) { - res.send('logout!'); + async logout(req: express.Request, res: express.Response) { + if (!ensuresXRequestedWith(req)) { + return res.status(401).send('Invalid X-Requested-With header'); + } + + const options: CookieOptions = { + maxAge: 0, + secure: false, + sameSite: 'none', + domain: 'localhost', + path: `/auth/${this.providerConfig.provider}`, + httpOnly: true, + }; + + res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options); + return res.send('logout!'); + } + + async refresh(req: express.Request, res: express.Response) { + if (!ensuresXRequestedWith(req)) { + return res.status(401).send('Invalid X-Requested-With header'); + } + + const refreshToken = + req.cookies[`${this.providerConfig.provider}-refresh-token`]; + + if (!refreshToken) { + return res.status(401).send('Missing session cookie'); + } + + const scope = req.query.scope?.toString() ?? ''; + const refreshTokenRequestParams = scope ? { scope } : {}; + + return refresh.requestNewAccessToken( + this.providerConfig.provider, + refreshToken, + refreshTokenRequestParams, + (err, accessToken, _refreshToken, params) => { + if (err || !accessToken) { + return res.status(401).send('Failed to refresh access token'); + } + return res.send({ + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }); + }, + ); } strategy(): passport.Strategy { @@ -81,6 +189,8 @@ export class GoogleAuthProvider idToken: params.id_token, accessToken, refreshToken, + scope: params.scope, + expiresInSeconds: params.expires_in, }); }, ); diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 6658299033..1b33391c27 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -20,11 +20,11 @@ import { ProviderFactories } from './factories'; export const defaultRouter = (provider: AuthProviderRouteHandlers) => { const router = Router(); - router.get('/start', provider.start); - router.get('/handler/frame', provider.frameHandler); - router.get('/logout', provider.logout); + router.get('/start', provider.start.bind(provider)); + router.get('/handler/frame', provider.frameHandler.bind(provider)); + router.get('/logout', provider.logout.bind(provider)); if (provider.refresh) { - router.get('/refreshToken', provider.refresh); + router.get('/refresh', provider.refresh.bind(provider)); } return router; }; diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 4350f36200..36941c6850 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -58,16 +58,25 @@ export type AuthProviderFactory = { new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers; }; -export type AuthInfo = { - profile: passport.Profile; +export type AuthInfoBase = { accessToken: string; + idToken?: string; expiresInSeconds?: number; + scope: string; +}; + +export type AuthInfoWithProfile = AuthInfoBase & { + profile: passport.Profile; +}; + +export type AuthInfoPrivate = AuthInfoWithProfile & { + refreshToken: string; }; export type AuthResponse = | { type: 'auth-result'; - payload: AuthInfo; + payload: AuthInfoBase | AuthInfoWithProfile; } | { type: 'auth-result'; diff --git a/plugins/auth-backend/src/providers/utils.ts b/plugins/auth-backend/src/providers/utils.ts index 7fb906ad7c..83229e55d4 100644 --- a/plugins/auth-backend/src/providers/utils.ts +++ b/plugins/auth-backend/src/providers/utils.ts @@ -39,3 +39,12 @@ export const postMessageResponse = ( `); }; + +export const ensuresXRequestedWith = (req: express.Request) => { + const requiredHeader = req.header('X-Requested-With'); + + if (!requiredHeader || requiredHeader !== 'XMLHttpRequest') { + return false; + } + return true; +}; diff --git a/plugins/auth-backend/src/run.ts b/plugins/auth-backend/src/run.ts index 0a684c379e..a2c2601258 100644 --- a/plugins/auth-backend/src/run.ts +++ b/plugins/auth-backend/src/run.ts @@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); -startStandaloneServer({ port, enableCors, logger }).catch((err) => { +startStandaloneServer({ port, enableCors, logger }).catch(err => { logger.error(err); process.exit(1); }); diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index cdde95d9d0..a2e3b3e1db 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -17,6 +17,9 @@ import express from 'express'; import Router from 'express-promise-router'; import passport from 'passport'; +import cookieParser from 'cookie-parser'; +import refresh from 'passport-oauth2-refresh'; +import OAuth2Strategy from 'passport-oauth2'; import { Logger } from 'winston'; import { providers } from './../providers/config'; import { makeProvider } from '../providers'; @@ -39,6 +42,9 @@ export async function createRouter( ); logger.info(`Configuring provider: ${providerId}`); passport.use(strategy); + if (strategy instanceof OAuth2Strategy) { + refresh.use(strategy); + } providerRouters[providerId] = providerRouter; } @@ -52,6 +58,7 @@ export async function createRouter( router.use(passport.initialize()); router.use(passport.session()); + router.use(cookieParser()); for (const providerId in providerRouters) { if (providerRouters.hasOwnProperty(providerId)) { diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 9c0b066f4f..f32ffffb6c 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -17,6 +17,7 @@ "dependencies": { "@backstage/backend-common": "^0.1.1-alpha.6", "@types/node-fetch": "^2.5.7", + "@types/supertest": "^2.0.8", "compression": "^1.7.4", "cors": "^2.8.5", "express": "^4.17.1", @@ -37,9 +38,10 @@ "devDependencies": { "@backstage/cli": "^0.1.1-alpha.6", "@types/lodash": "^4.14.151", - "@types/uuid": "^7.0.3", + "@types/uuid": "^8.0.0", "@types/yup": "^0.28.2", "jest-fetch-mock": "^3.0.3", + "supertest": "^4.0.2", "tsc-watch": "^4.2.3" }, "files": [ diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 697da0ea35..9d0d8fcaf7 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -14,32 +14,47 @@ * limitations under the License. */ -import { NotFoundError } from '@backstage/backend-common'; import { Database } from '../database'; import { DescriptorEnvelope } from '../ingestion/types'; -import { EntitiesCatalog } from './types'; +import { EntitiesCatalog, EntityFilters } from './types'; export class DatabaseEntitiesCatalog implements EntitiesCatalog { constructor(private readonly database: Database) {} - async entities(): Promise { + async entities(filters?: EntityFilters): Promise { const items = await this.database.transaction(tx => - this.database.entities(tx), + this.database.entities(tx, filters), ); return items.map(i => i.entity); } - async entity( + async entityByUid(uid: string): Promise { + const matches = await this.database.transaction(tx => + this.database.entities(tx, [{ key: 'uid', values: [uid] }]), + ); + + return matches.length ? matches[0].entity : undefined; + } + + async entityByName( kind: string, name: string, namespace: string | undefined, ): Promise { - const item = await this.database.transaction(tx => - this.database.entity(tx, kind, name, namespace), + const matches = await this.database.transaction(tx => + this.database.entities(tx, [ + { key: 'kind', values: [kind] }, + { key: 'name', values: [name] }, + { + key: 'namespace', + values: + !namespace || namespace === 'default' + ? [null, 'default'] + : [namespace], + }, + ]), ); - if (!item) { - throw new NotFoundError('Entity cannot be found'); - } - return item.entity; + + return matches.length ? matches[0].entity : undefined; } } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts new file mode 100644 index 0000000000..0181b7fc28 --- /dev/null +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.test.ts @@ -0,0 +1,82 @@ +/* + * 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 { DatabaseLocationsCatalog } from './DatabaseLocationsCatalog'; +import knex from 'knex'; +import path from 'path'; + +import { Database } from '../database'; +import { ReaderOutput } from '../ingestion/types'; +import { getVoidLogger } from '@backstage/backend-common'; + +describe('DatabaseLocationsCatalog', () => { + const database = knex({ + client: 'sqlite3', + connection: ':memory:', + useNullAsDefault: true, + }); + database.client.pool.on('createSuccess', (_eventId: any, resource: any) => { + resource.run('PRAGMA foreign_keys = ON', () => {}); + }); + let db: Database; + let catalog: DatabaseLocationsCatalog; + + const mockLocationReader = { + read: async (type: string, target: string): Promise => { + if (type !== 'valid_type') { + throw new Error(`Unknown location type ${type}`); + } + if (target === 'valid_target') { + return Promise.resolve([{ type: 'data', data: {} }]); + } + throw new Error( + `Can't read location at ${target} with error: Something is broken`, + ); + }, + }; + + beforeEach(async () => { + await database.migrate.latest({ + directory: path.resolve(__dirname, '../database/migrations'), + loadExtensions: ['.ts'], + }); + db = new Database(database, getVoidLogger()); + catalog = new DatabaseLocationsCatalog(db, mockLocationReader); + }); + + it('resolves to location with id', async () => { + return expect( + catalog.addLocation({ type: 'valid_type', target: 'valid_target' }), + ).resolves.toEqual({ + id: expect.anything(), + type: 'valid_type', + target: 'valid_target', + }); + }); + it('rejects for invalid type', async () => { + const type = 'invalid_type'; + return expect( + catalog.addLocation({ type, target: 'valid_target' }), + ).rejects.toThrow(/Unknown location type/); + }); + it('rejects for unreadable target ', async () => { + const target = 'invalid_target'; + return expect( + catalog.addLocation({ type: 'valid_type', target }), + ).rejects.toThrow( + `Can't read location at ${target} with error: Something is broken`, + ); + }); +}); diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index e9f1512074..6e3c9ae306 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -16,11 +16,24 @@ import { Database } from '../database'; import { AddLocation, Location, LocationsCatalog } from './types'; +import { LocationReader } from '../ingestion'; export class DatabaseLocationsCatalog implements LocationsCatalog { - constructor(private readonly database: Database) {} + constructor( + private readonly database: Database, + private readonly reader: LocationReader, + ) {} async addLocation(location: AddLocation): Promise { + const outputs = await this.reader.read(location.type, location.target); + outputs.forEach(output => { + if (output.type === 'error') { + throw new Error( + `Can't read location at ${location.target}, ${output.error}`, + ); + } + }); + const added = await this.database.addLocation(location); return added; } @@ -35,7 +48,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { } async location(id: string): Promise { - const item = await this.location(id); + const item = await this.database.location(id); return item; } } diff --git a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts index 17f7603fb7..371cdf1f76 100644 --- a/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/StaticEntitiesCatalog.ts @@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog { return lodash.cloneDeep(this._entities); } - async entity( + async entityByUid(uid: string): Promise { + const item = this._entities.find(e => uid === e.metadata?.uid); + if (!item) { + throw new NotFoundError('Entity cannot be found'); + } + return lodash.cloneDeep(item); + } + + async entityByName( kind: string, name: string, namespace: string | undefined, diff --git a/plugins/catalog-backend/src/catalog/types.ts b/plugins/catalog-backend/src/catalog/types.ts index 92c059d25b..d5627ea86a 100644 --- a/plugins/catalog-backend/src/catalog/types.ts +++ b/plugins/catalog-backend/src/catalog/types.ts @@ -18,15 +18,22 @@ import * as yup from 'yup'; import { DescriptorEnvelope } from '../ingestion'; // -// Items +// Entities // +export type EntityFilter = { + key: string; + values: (string | null)[]; +}; +export type EntityFilters = EntityFilter[]; + export type EntitiesCatalog = { - entities(): Promise; - entity( + entities(filters?: EntityFilters): Promise; + entityByUid(uid: string): Promise; + entityByName( kind: string, - name: string, namespace: string | undefined, + name: string, ): Promise; }; diff --git a/plugins/catalog-backend/src/database/Database.test.ts b/plugins/catalog-backend/src/database/Database.test.ts index 0eb64916e5..a82385aae4 100644 --- a/plugins/catalog-backend/src/database/Database.test.ts +++ b/plugins/catalog-backend/src/database/Database.test.ts @@ -21,6 +21,7 @@ import { } from '@backstage/backend-common'; import Knex from 'knex'; import path from 'path'; +import { DescriptorEnvelope } from '../ingestion'; import { Database } from './Database'; import { AddDatabaseLocation, @@ -242,4 +243,112 @@ describe('Database', () => { ).rejects.toThrow(ConflictError); }); }); + + describe('entities', () => { + it('can get all entities with empty filters list', async () => { + const catalog = new Database(database, getVoidLogger()); + const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' }; + const e2: DescriptorEnvelope = { + apiVersion: 'a', + kind: 'b', + spec: { c: null }, + }; + await catalog.transaction(async tx => { + await catalog.addEntity(tx, { entity: e1 }); + await catalog.addEntity(tx, { entity: e2 }); + }); + const result = await catalog.transaction(async tx => + catalog.entities(tx, []), + ); + expect(result.length).toEqual(2); + expect(result).toEqual( + expect.arrayContaining([ + { locationId: undefined, entity: expect.objectContaining(e1) }, + { locationId: undefined, entity: expect.objectContaining(e2) }, + ]), + ); + }); + + it('can get all specific entities for matching filters (naive case)', async () => { + const catalog = new Database(database, getVoidLogger()); + const entities: DescriptorEnvelope[] = [ + { apiVersion: 'a', kind: 'b' }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: 'some' }, + }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: null }, + }, + ]; + + await catalog.transaction(async tx => { + for (const entity of entities) { + await catalog.addEntity(tx, { entity }); + } + }); + + await expect( + catalog.transaction(async tx => + catalog.entities(tx, [ + { key: 'kind', values: ['b'] }, + { key: 'spec.c', values: ['some'] }, + ]), + ), + ).resolves.toEqual([ + { locationId: undefined, entity: expect.objectContaining(entities[1]) }, + ]); + }); + + it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => { + const catalog = new Database(database, getVoidLogger()); + const entities: DescriptorEnvelope[] = [ + { apiVersion: 'a', kind: 'b' }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: 'some' }, + }, + { + apiVersion: 'a', + kind: 'b', + spec: { c: null }, + }, + ]; + + await catalog.transaction(async tx => { + for (const entity of entities) { + await catalog.addEntity(tx, { entity }); + } + }); + + const rows = await catalog.transaction(async tx => + catalog.entities(tx, [ + { key: 'kind', values: ['b'] }, + { key: 'spec.c', values: [null, 'some'] }, + ]), + ); + + expect(rows.length).toEqual(3); + expect(rows).toEqual( + expect.arrayContaining([ + { + locationId: undefined, + entity: expect.objectContaining(entities[0]), + }, + { + locationId: undefined, + entity: expect.objectContaining(entities[1]), + }, + { + locationId: undefined, + entity: expect.objectContaining(entities[2]), + }, + ]), + ); + }); + }); }); diff --git a/plugins/catalog-backend/src/database/Database.ts b/plugins/catalog-backend/src/database/Database.ts index cd4ca731a3..614f7d09bf 100644 --- a/plugins/catalog-backend/src/database/Database.ts +++ b/plugins/catalog-backend/src/database/Database.ts @@ -23,6 +23,7 @@ import Knex from 'knex'; import lodash from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { Logger } from 'winston'; +import { EntityFilters } from '../catalog'; import { DescriptorEnvelope, EntityMeta } from '../ingestion'; import { buildEntitySearch } from './search'; import { @@ -186,11 +187,12 @@ export class Database { } const newEntity = lodash.cloneDeep(request.entity); - newEntity.metadata = Object.assign({}, newEntity.metadata, { + newEntity.metadata = { + ...newEntity.metadata, uid: generateUid(), etag: generateEtag(), generation: 1, - }); + }; const newRow = toEntityRow(request.locationId, newEntity); await tx('entities').insert(newRow); @@ -275,11 +277,12 @@ export class Database { ? oldRow.generation : oldRow.generation + 1; const newEntity = lodash.cloneDeep(request.entity); - newEntity.metadata = Object.assign({}, request.entity.metadata, { + newEntity.metadata = { + ...newEntity.metadata, uid: oldRow.id, etag: newEtag, generation: newGeneration, - }); + }; // Preserve annotations that were set on the old version of the entity, // unless the new version overwrites them @@ -309,10 +312,30 @@ export class Database { return { locationId: request.locationId, entity: newEntity }; } - async entities(tx: Knex.Transaction): Promise { - const rows = await tx('entities') + async entities( + tx: Knex.Transaction, + filters?: EntityFilters, + ): Promise { + let builder = tx('entities'); + for (const [index, filter] of (filters ?? []).entries()) { + builder = builder + .leftOuterJoin(`entities_search as t${index}`, function join() { + this.on('entities.id', '=', `t${index}.entity_id`).onIn( + `t${index}.value`, + filter.values.filter(x => x), + ); + if (filter.values.some(x => !x)) { + this.orOnNull(`t${index}.value`); + } + }) + .where(`t${index}.key`, '=', filter.key); + } + + const rows = await builder .orderBy('namespace', 'name') - .select(); + .select('entities.*') + .groupBy('id'); + return rows.map(row => toEntityResponse(row)); } @@ -336,9 +359,7 @@ export class Database { async addLocation(location: AddDatabaseLocation): Promise { return await this.database.transaction(async tx => { const existingLocation = await tx('locations') - .where({ - target: location.target, - }) + .where({ target: location.target }) .select(); if (existingLocation?.[0]) { diff --git a/plugins/catalog-backend/src/database/DatabaseManager.test.ts b/plugins/catalog-backend/src/database/DatabaseManager.test.ts index 2da3c33a00..ca50f518cb 100644 --- a/plugins/catalog-backend/src/database/DatabaseManager.test.ts +++ b/plugins/catalog-backend/src/database/DatabaseManager.test.ts @@ -15,6 +15,7 @@ */ import { getVoidLogger } from '@backstage/backend-common'; +import Knex from 'knex'; import { ComponentDescriptor, DescriptorParser, @@ -24,7 +25,6 @@ import { import { Database } from './Database'; import { DatabaseManager } from './DatabaseManager'; import { DatabaseLocationUpdateLogStatus, DbLocationsRow } from './types'; -import Knex from 'knex'; describe('DatabaseManager', () => { describe('refreshLocations', () => { diff --git a/plugins/catalog-backend/src/service/router.test.ts b/plugins/catalog-backend/src/service/router.test.ts new file mode 100644 index 0000000000..8c29362015 --- /dev/null +++ b/plugins/catalog-backend/src/service/router.test.ts @@ -0,0 +1,198 @@ +/* + * 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 { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; +import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog'; +import { DescriptorEnvelope } from '../ingestion'; +import { createRouter } from './router'; + +class MockEntitiesCatalog implements EntitiesCatalog { + entities = jest.fn(); + entityByUid = jest.fn(); + entityByName = jest.fn(); +} + +class MockLocationsCatalog implements LocationsCatalog { + addLocation = jest.fn(); + removeLocation = jest.fn(); + locations = jest.fn(); + location = jest.fn(); +} + +describe('createRouter', () => { + describe('entities', () => { + it('happy path: lists entities', async () => { + const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }]; + + const catalog = new MockEntitiesCatalog(); + catalog.entities.mockResolvedValueOnce(entities); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(entities); + }); + + it('parses single and multiple request parameters and passes them down', async () => { + const catalog = new MockEntitiesCatalog(); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c='); + + expect(response.status).toEqual(200); + expect(catalog.entities).toHaveBeenCalledWith([ + { key: 'a', values: ['1', null, '3'] }, + { key: 'b', values: ['4'] }, + { key: 'c', values: [null] }, + ]); + }); + }); + + describe('entityByUid', () => { + it('can fetch entity by uid', async () => { + const entity: DescriptorEnvelope = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + }, + }; + const catalog = new MockEntitiesCatalog(); + catalog.entityByUid.mockResolvedValue(entity); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities/by-uid/zzz'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(expect.objectContaining(entity)); + }); + + it('responds with a 404 for missing entities', async () => { + const catalog = new MockEntitiesCatalog(); + catalog.entityByUid.mockResolvedValue(undefined); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities/by-uid/zzz'); + + expect(response.status).toEqual(404); + expect(response.text).toMatch(/uid/); + }); + }); + + describe('entityByName', () => { + it('can fetch entity by name', async () => { + const entity: DescriptorEnvelope = { + apiVersion: 'a', + kind: 'b', + metadata: { + name: 'c', + namespace: 'd', + }, + }; + const catalog = new MockEntitiesCatalog(); + catalog.entityByName.mockResolvedValue(entity); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities/by-name/b/d/c'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(expect.objectContaining(entity)); + }); + + it('responds with a 404 for missing entities', async () => { + const catalog = new MockEntitiesCatalog(); + catalog.entityByName.mockResolvedValue(undefined); + + const router = await createRouter({ + entitiesCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/entities/by-name//b/d/c'); + + expect(response.status).toEqual(404); + expect(response.text).toMatch(/name/); + }); + }); + + describe('locations', () => { + it('happy path: lists locations', async () => { + const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }]; + + const catalog = new MockLocationsCatalog(); + catalog.locations.mockResolvedValueOnce(locations); + + const router = await createRouter({ + locationsCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).get('/locations'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual(locations); + }); + + it('rejects malformed locations', async () => { + const location = ({ + id: 'a', + typez: 'b', + target: 'c', + } as unknown) as Location; + + const catalog = new MockLocationsCatalog(); + const router = await createRouter({ + locationsCatalog: catalog, + logger: getVoidLogger(), + }); + + const app = express().use(router); + const response = await request(app).post('/locations').send(location); + + expect(response.status).toEqual(400); + }); + }); +}); diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts index af714e4026..c3318a7377 100644 --- a/plugins/catalog-backend/src/service/router.ts +++ b/plugins/catalog-backend/src/service/router.ts @@ -14,12 +14,14 @@ * limitations under the License. */ +import { errorHandler, InputError } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { addLocationSchema, EntitiesCatalog, + EntityFilters, LocationsCatalog, } from '../catalog'; import { validateRequestBody } from './util'; @@ -34,17 +36,43 @@ export async function createRouter( options: RouterOptions, ): Promise { const { entitiesCatalog, locationsCatalog } = options; + const router = Router(); + router.use(express.json()); if (entitiesCatalog) { - // Entities - router.get('/entities', async (_req, res) => { - const entities = await entitiesCatalog.entities(); - res.status(200).send(entities); - }); + router + .get('/entities', async (req, res) => { + const filters = translateQueryToEntityFilters(req); + const entities = await entitiesCatalog.entities(filters); + res.status(200).send(entities); + }) + .get('/entities/by-uid/:uid', async (req, res) => { + const { uid } = req.params; + const entity = await entitiesCatalog.entityByUid(uid); + if (!entity) { + res.status(404).send(`No entity with uid ${uid}`); + } + res.status(200).send(entity); + }) + .get('/entities/by-name/:kind/:namespace/:name', async (req, res) => { + const { kind, namespace, name } = req.params; + const entity = await entitiesCatalog.entityByName( + kind, + name, + namespace, + ); + if (!entity) { + res + .status(404) + .send( + `No entity with kind ${kind} namespace ${namespace} name ${name}`, + ); + } + res.status(200).send(entity); + }); } - // Locations if (locationsCatalog) { router .post('/locations', async (req, res) => { @@ -68,5 +96,29 @@ export async function createRouter( }); } + router.use(errorHandler()); return router; } + +function translateQueryToEntityFilters( + request: express.Request, +): EntityFilters { + const filters: EntityFilters = []; + + for (const [key, valueOrValues] of Object.entries(request.query)) { + const values = Array.isArray(valueOrValues) + ? valueOrValues + : [valueOrValues]; + + if (values.some(v => typeof v !== 'string')) { + throw new InputError('Complex query parameters are not supported'); + } + + filters.push({ + key, + values: values.map(v => v || null) as string[], + }); + } + + return filters; +} diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 4097b76098..0f91e1ed8e 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -157,40 +157,3 @@ export type KindParser = { envelope: DescriptorEnvelope, ): Promise; }; - -export class ParserError extends Error { - constructor(message?: string, private _entityName?: string | undefined) { - super(message); - } - get entityName() { - return this._entityName; - } -} - -export type ReaderOutput = - | { type: 'error'; error: Error } - | { type: 'data'; data: object }; - -export type LocationReader = { - /** - * Reads the contents of a single location. - * - * @param type The type of location to read - * @param target The location target (type-specific) - * @returns The parsed contents, as an array of unverified descriptors or - * errors where the individual documents could not be parsed. - * @throws An error if the location as a whole could not be read - */ - read(type: string, target: string): Promise; -}; - -export type LocationSource = { - /** - * Reads the contents of a single location. - * - * @param target The location target to read - * @returns The parsed contents, as an array of unverified descriptors - * @throws An error if the location target could not be read - */ - read(target: string): Promise; -}; diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx new file mode 100644 index 0000000000..762a881302 --- /dev/null +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 { wrapInThemedTestApp } from '@backstage/test-utils'; +import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; + +describe('Catalog Filter', () => { + it('should render the different groups', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = render( + wrapInThemedTestApp(), + ); + + for (const group of mockGroups) { + expect(await findByText(group.name)).toBeInTheDocument(); + } + }); + + it('should render the different items and their names', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'first', + label: 'First Label', + }, + { + id: 'second', + label: 'Second Label', + }, + ], + }, + ]; + + const { findByText } = render( + wrapInThemedTestApp(), + ); + + const [group] = mockGroups; + for (const item of group.items) { + expect(await findByText(item.label)).toBeInTheDocument(); + } + }); + + it('should render the count in each item', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'first', + label: 'First Label', + count: 100, + }, + { + id: 'second', + label: 'Second Label', + count: 400, + }, + ], + }, + ]; + + const { findByText } = render( + wrapInThemedTestApp(), + ); + + const [group] = mockGroups; + for (const item of group.items) { + expect(await findByText(item.count!.toString())).toBeInTheDocument(); + } + }); + + it('should fire the callback when an item is clicked', async () => { + const mockGroups: CatalogFilterGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'first', + label: 'First Label', + count: 100, + }, + { + id: 'second', + label: 'Second Label', + count: 400, + }, + ], + }, + ]; + + const onSelectedChangeHandler = jest.fn(); + + const { findByText } = render( + wrapInThemedTestApp( + , + ), + ); + + const item = mockGroups[0].items[0]; + + const element = await findByText(item.label); + + fireEvent.click(element); + + expect(onSelectedChangeHandler).toHaveBeenCalledWith(item); + }); +}); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx new file mode 100644 index 0000000000..eb45d8a80b --- /dev/null +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -0,0 +1,117 @@ +/* + * 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 { + Card, + List, + ListItemIcon, + ListItemText, + MenuItem, + Typography, + Theme, + makeStyles, +} from '@material-ui/core'; +import type { IconComponent } from '@backstage/core'; + +export type CatalogFilterItem = { + id: string; + label: string; + icon?: IconComponent; + count?: number; + loading?: boolean; +}; + +export type CatalogFilterGroup = { + name: string; + items: CatalogFilterItem[]; +}; + +export type CatalogFilterProps = { + groups: CatalogFilterGroup[]; + selectedId?: string; + onSelectedChange?: (item: CatalogFilterItem) => void; +}; + +const useStyles = makeStyles(theme => ({ + root: { + backgroundColor: 'rgba(0, 0, 0, .11)', + boxShadow: 'none', + }, + title: { + margin: theme.spacing(1, 0, 0, 1), + textTransform: 'uppercase', + fontSize: 12, + fontWeight: 'bold', + }, + listIcon: { + minWidth: 30, + color: theme.palette.text.primary, + }, + menuItem: { + minHeight: theme.spacing(6), + }, + groupWrapper: { + margin: theme.spacing(1, 1, 2, 1), + }, + menuTitle: { + fontWeight: 500, + }, +})); + +export const CatalogFilter: React.FC = ({ + groups, + selectedId, + onSelectedChange, +}) => { + const classes = useStyles(); + return ( + + {groups.map(group => ( + + + {group.name} + + + + {group.items.map(item => ( + onSelectedChange?.(item)} + selected={item.id === selectedId} + className={classes.menuItem} + > + {item.icon && ( + + + + )} + + + {item.label} + + + {item.count} + + ))} + + + + ))} + + ); +}; diff --git a/plugins/catalog/src/components/CatalogFilter/index.ts b/plugins/catalog/src/components/CatalogFilter/index.ts new file mode 100644 index 0000000000..5103b16307 --- /dev/null +++ b/plugins/catalog/src/components/CatalogFilter/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 { CatalogFilter } from './CatalogFilter'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index d4cd6e7606..5949365b29 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -25,6 +25,9 @@ const errorApi = { post: () => {} }; const catalogApi = { getEntities: () => Promise.resolve([{ kind: '' }]) }; describe('CatalogPage', () => { + // this test right now causes some red lines in the log output when running tests + // related to some theme issues in mui-table + // https://github.com/mbrn/material-table/issues/1293 it('should render', async () => { const rendered = render( wrapInTheme( diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 63bb4e0771..8a2b448d97 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -18,6 +18,7 @@ import React, { FC } from 'react'; import { Content, ContentHeader, + DismissableBanner, Header, HomepageTimer, SupportButton, @@ -27,7 +28,21 @@ import { } from '@backstage/core'; import { useAsync } from 'react-use'; import CatalogTable from '../CatalogTable/CatalogTable'; -import { Button } from '@material-ui/core'; +import { + CatalogFilter, + CatalogFilterItem, +} from '../CatalogFilter/CatalogFilter'; +import { Button, makeStyles, Typography, Link } from '@material-ui/core'; +import { filterGroups, defaultFilter } from '../../data/filters'; + +const useStyles = makeStyles(theme => ({ + contentWrapper: { + display: 'grid', + gridTemplateAreas: "'filters' 'table'", + gridTemplateColumns: '250px 1fr', + gridColumnGap: theme.spacing(2), + }, +})); import { catalogApiRef } from '../..'; import { envelopeToComponent } from '../../data/utils'; @@ -35,6 +50,15 @@ import { envelopeToComponent } from '../../data/utils'; const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); const { value, error, loading } = useAsync(() => catalogApi.getEntities()); + const [selectedFilter, setSelectedFilter] = React.useState( + defaultFilter, + ); + + const onFilterSelected = React.useCallback( + selected => setSelectedFilter(selected), + [], + ); + const styles = useStyles(); return ( @@ -42,17 +66,43 @@ const CatalogPage: FC<{}> = () => {
+ + + 👋🏼 + {' '} + Welcome to Backstage, we are happy to have you. Start by checking + out our{' '} + + getting started + {' '} + page. +
+ } + /> All your components - +
+
+ +
+ +
); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 99debb12d4..ec09906368 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -20,15 +20,17 @@ import CatalogTable from './CatalogTable'; import { Component } from '../../data/component'; const components: Component[] = [ - { name: 'component1' }, - { name: 'component2' }, - { name: 'component3' }, + { name: 'component1', kind: 'Component', description: 'Placeholder' }, + { name: 'component2', kind: 'Component', description: 'Placeholder' }, + { name: 'component3', kind: 'Component', description: 'Placeholder' }, ]; describe('CatalogTable component', () => { it('should render loading when loading prop it set to true', async () => { const rendered = render( - wrapInThemedTestApp(), + wrapInThemedTestApp( + , + ), ); const progress = await rendered.findByTestId('progress'); expect(progress).toBeInTheDocument(); @@ -38,6 +40,7 @@ describe('CatalogTable component', () => { const rendered = render( wrapInThemedTestApp( { it('should display component names when loading has finished and no error occurred', async () => { const rendered = render( wrapInThemedTestApp( - , + , ), ); + expect( + await rendered.findByText(`Owned (${components.length})`), + ).toBeInTheDocument(); expect(await rendered.findByText('component1')).toBeInTheDocument(); expect(await rendered.findByText('component2')).toBeInTheDocument(); expect(await rendered.findByText('component3')).toBeInTheDocument(); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 27e59973b5..f5fa45dc7e 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -39,6 +39,7 @@ const columns: TableColumn[] = [ type CatalogTableProps = { components: Component[]; + titlePreamble: string; loading: boolean; error?: any; }; @@ -46,6 +47,7 @@ const CatalogTable: FC = ({ components, loading, error, + titlePreamble, }) => { if (loading) { return ; @@ -63,7 +65,7 @@ const CatalogTable: FC = ({
); diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts new file mode 100644 index 0000000000..66eb55b478 --- /dev/null +++ b/plugins/catalog/src/data/filters.ts @@ -0,0 +1,53 @@ +/* + * 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 { + CatalogFilterGroup, + CatalogFilterItem, +} from '../components/CatalogFilter/CatalogFilter'; +import SettingsIcon from '@material-ui/icons/Settings'; +import StarIcon from '@material-ui/icons/Star'; + +export const filterGroups: CatalogFilterGroup[] = [ + { + name: 'Personal', + items: [ + { + id: 'owned', + label: 'Owned', + count: 123, + icon: SettingsIcon, + }, + { + id: 'starred', + label: 'Starred', + count: 10, + icon: StarIcon, + }, + ], + }, + { + name: 'Spotify', + items: [ + { + id: 'all', + label: 'All Services', + count: 123, + }, + ], + }, +]; + +export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/catalog/src/data/mock-factory.ts b/plugins/catalog/src/data/mock-factory.ts new file mode 100644 index 0000000000..19f04195f9 --- /dev/null +++ b/plugins/catalog/src/data/mock-factory.ts @@ -0,0 +1,48 @@ +/* + * 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 { Component, ComponentFactory } from './component'; +import mock from './mock-factory-data.json'; + +const ARTIFICIAL_TIMEOUT = 800; +let inMemoryStore = [...mock]; +export const MockComponentFactory: ComponentFactory = { + getAllComponents(): Promise { + return new Promise(resolve => + setTimeout(() => resolve(inMemoryStore), ARTIFICIAL_TIMEOUT), + ); + }, + getComponentByName(name: string): Promise { + return new Promise((resolve, reject) => + setTimeout(() => { + const mockComponent = inMemoryStore.find( + component => component.name === name, + ); + if (mockComponent) return resolve(mockComponent); + return reject({ code: 'Component not found!' }); + }, ARTIFICIAL_TIMEOUT), + ); + }, + removeComponentByName(name: string): Promise { + return new Promise(resolve => + setTimeout(() => { + inMemoryStore = inMemoryStore.filter( + component => component.name !== name, + ); + resolve(true); + }, ARTIFICIAL_TIMEOUT), + ); + }, +}; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index cf9edee4da..97e470a065 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -21,7 +21,7 @@ import ComponentPage from './components/ComponentPage/ComponentPage'; export const plugin = createPlugin({ id: 'catalog', register({ router }) { - router.registerRoute('/catalog', CatalogPage); + router.registerRoute('/', CatalogPage); router.registerRoute('/catalog/:name/', ComponentPage); }, }); diff --git a/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx b/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx index 4bab4b5921..9f94cff480 100644 --- a/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx +++ b/plugins/circleci/src/components/PluginHeader/PluginHeader.tsx @@ -22,7 +22,7 @@ import SettingsIcon from '@material-ui/icons/Settings'; import { useSettings } from '../../state'; export type Props = { title?: string }; -export const PluginHeader: FC = ({ title = 'Circle CI' }) => { +export const PluginHeader: FC = ({ title = 'CircleCI' }) => { const [, { showSettings }] = useSettings(); const location = useLocation(); const notRoot = !location.pathname.match(/\/circleci\/?$/); diff --git a/plugins/circleci/src/components/Settings/Settings.tsx b/plugins/circleci/src/components/Settings/Settings.tsx index e853bb46bb..f0ed3f2ceb 100644 --- a/plugins/circleci/src/components/Settings/Settings.tsx +++ b/plugins/circleci/src/components/Settings/Settings.tsx @@ -80,7 +80,7 @@ const Settings = () => { value={token} fullWidth variant="outlined" - onChange={(e) => setToken(e.target.value)} + onChange={e => setToken(e.target.value)} /> @@ -90,7 +90,7 @@ const Settings = () => { label="Owner" variant="outlined" value={owner} - onChange={(e) => setOwner(e.target.value)} + onChange={e => setOwner(e.target.value)} /> @@ -100,7 +100,7 @@ const Settings = () => { fullWidth variant="outlined" value={repo} - onChange={(e) => setRepo(e.target.value)} + onChange={e => setRepo(e.target.value)} /> diff --git a/plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx b/plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx index 8493913576..75d09610b5 100644 --- a/plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx +++ b/plugins/circleci/src/pages/BuildWithStepsPage/BuildWithStepsPage.tsx @@ -35,7 +35,7 @@ const BuildName: FC<{ build?: BuildWithSteps }> = ({ build }) => ( ); -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ neutral: {}, failed: { position: 'relative', diff --git a/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx b/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx index e8e1bfe958..dd526c1b5f 100644 --- a/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx +++ b/plugins/circleci/src/pages/BuildWithStepsPage/lib/ActionOutput/ActionOutput.tsx @@ -50,8 +50,8 @@ export const ActionOutput: FC<{ const [messages, setMessages] = useState([]); useEffect(() => { fetch(url) - .then((res) => res.json()) - .then((actionOutput) => { + .then(res => res.json()) + .then(actionOutput => { if (typeof actionOutput !== 'undefined') { setMessages( actionOutput.map(({ message }: { message: string }) => message), diff --git a/plugins/circleci/src/state/useAsyncPolling.ts b/plugins/circleci/src/state/useAsyncPolling.ts index 2f8de0c2fe..7ea0755368 100644 --- a/plugins/circleci/src/state/useAsyncPolling.ts +++ b/plugins/circleci/src/state/useAsyncPolling.ts @@ -26,7 +26,7 @@ export const useAsyncPolling = ( while (isPolling.current === true) { await pollingFn(); - await new Promise((resolve) => setTimeout(resolve, interval)); + await new Promise(resolve => setTimeout(resolve, interval)); } }; diff --git a/plugins/circleci/src/state/useSettings.ts b/plugins/circleci/src/state/useSettings.ts index 5482abeb8b..c8dc9ce39b 100644 --- a/plugins/circleci/src/state/useSettings.ts +++ b/plugins/circleci/src/state/useSettings.ts @@ -29,7 +29,7 @@ export function useSettings() { if ( stateFromStorage && Object.keys(stateFromStorage).some( - (k) => (settings as any)[k] !== stateFromStorage[k], + k => (settings as any)[k] !== stateFromStorage[k], ) ) dispatch({ diff --git a/plugins/identity-backend/src/run.ts b/plugins/identity-backend/src/run.ts index 0a684c379e..a2c2601258 100644 --- a/plugins/identity-backend/src/run.ts +++ b/plugins/identity-backend/src/run.ts @@ -22,7 +22,7 @@ const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); -startStandaloneServer({ port, enableCors, logger }).catch((err) => { +startStandaloneServer({ port, enableCors, logger }).catch(err => { logger.error(err); process.exit(1); }); diff --git a/plugins/lighthouse/src/components/AuditView/index.test.tsx b/plugins/lighthouse/src/components/AuditView/index.test.tsx index 330b88517a..4e261fcf39 100644 --- a/plugins/lighthouse/src/components/AuditView/index.test.tsx +++ b/plugins/lighthouse/src/components/AuditView/index.test.tsx @@ -49,7 +49,7 @@ describe('AuditView', () => { apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('https://lighthouse')], ]); - id = websiteResponse.audits.find((a) => a.status === 'COMPLETED') + id = websiteResponse.audits.find(a => a.status === 'COMPLETED') ?.id as string; useParams.mockReturnValue({ id }); }); @@ -101,7 +101,7 @@ describe('AuditView', () => { await rendered.findByTestId('audit-sidebar'); - websiteResponse.audits.forEach((a) => { + websiteResponse.audits.forEach(a => { expect( rendered.queryByText(formatTime(a.timeCreated)), ).toBeInTheDocument(); @@ -119,14 +119,14 @@ describe('AuditView', () => { await rendered.findByTestId('audit-sidebar'); - const audit = websiteResponse.audits.find((a) => a.id === id) as Audit; + const audit = websiteResponse.audits.find(a => a.id === id) as Audit; const auditElement = rendered.getByText(formatTime(audit.timeCreated)); expect(auditElement.parentElement?.parentElement?.className).toContain( 'selected', ); const notSelectedAudit = websiteResponse.audits.find( - (a) => a.id !== id, + a => a.id !== id, ) as Audit; const notSelectedAuditElement = rendered.getByText( formatTime(notSelectedAudit.timeCreated), @@ -147,7 +147,7 @@ describe('AuditView', () => { await rendered.findByTestId('audit-sidebar'); - websiteResponse.audits.forEach((a) => { + websiteResponse.audits.forEach(a => { expect( rendered.getByText(formatTime(a.timeCreated)).parentElement ?.parentElement, @@ -186,7 +186,7 @@ describe('AuditView', () => { describe.skip('when a loading audit is accessed', () => { it('shows a loading view', async () => { - id = websiteResponse.audits.find((a) => a.status === 'RUNNING') + id = websiteResponse.audits.find(a => a.status === 'RUNNING') ?.id as string; useParams.mockReturnValueOnce({ id }); @@ -206,7 +206,7 @@ describe('AuditView', () => { describe.skip('when a failed audit is accessed', () => { it('shows an error message', async () => { - id = websiteResponse.audits.find((a) => a.status === 'FAILED') + id = websiteResponse.audits.find(a => a.status === 'FAILED') ?.id as string; useParams.mockReturnValueOnce({ id }); diff --git a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx index f47dc3d475..79897539c4 100644 --- a/plugins/lighthouse/src/components/CreateAudit/index.test.tsx +++ b/plugins/lighthouse/src/components/CreateAudit/index.test.tsx @@ -53,7 +53,7 @@ describe('CreateAudit', () => { let errorApi: ErrorApi; beforeEach(() => { - errorApi = { post: jest.fn() }; + errorApi = { post: jest.fn(), error$: jest.fn() }; apis = ApiRegistry.from([ [lighthouseApiRef, new LighthouseRestApi('http://lighthouse')], [errorApiRef, errorApi], @@ -165,7 +165,7 @@ describe('CreateAudit', () => { fireEvent.click(rendered.getByText(/Create Audit/)); await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled()); - await new Promise((r) => setTimeout(r, 0)); + await new Promise(r => setTimeout(r, 0)); expect(errorApi.post).toHaveBeenCalledWith(expect.any(Error)); }); diff --git a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx index fe4ba7845a..d279afac25 100644 --- a/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx +++ b/plugins/register-component/src/components/RegisterComponentForm/RegisterComponentForm.tsx @@ -28,7 +28,7 @@ import { BackstageTheme } from '@backstage/theme'; import { Progress } from '@backstage/core'; import { ComponentIdValidators } from '../../util/validate'; -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ form: { alignItems: 'flex-start', display: 'flex', diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx index 922416972a..e6fed50cef 100644 --- a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx @@ -40,6 +40,7 @@ const ScaffolderPage: React.FC<{}> = () => { return (
Create a new component {' '} diff --git a/plugins/sentry-backend/README.md b/plugins/sentry-backend/README.md index 412306dc8b..efd4c1b472 100644 --- a/plugins/sentry-backend/README.md +++ b/plugins/sentry-backend/README.md @@ -1,3 +1,3 @@ # sentry-backend -Simple plugin forwarding requests to [Sentry](https://sentry.io) API. +Simple plugin forwarding requests to [Sentry](https://sentry.io) API. diff --git a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx index 408014b790..617bdb3ce9 100644 --- a/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx +++ b/plugins/sentry/src/components/ErrorCell/ErrorCell.tsx @@ -22,7 +22,7 @@ import { BackstageTheme } from '@backstage/theme'; function stripText(text: string, maxLength: number) { return text.length > maxLength ? `${text.substr(0, maxLength)}...` : text; } -const useStyles = makeStyles((theme) => ({ +const useStyles = makeStyles(theme => ({ root: { minWidth: 260, position: 'relative', diff --git a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx index 3e5ed98dba..e861c6bbe5 100644 --- a/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx +++ b/plugins/sentry/src/components/SentryIssuesTable/SentryIssuesTable.tsx @@ -24,16 +24,16 @@ import { ErrorGraph } from '../ErrorGraph/ErrorGraph'; const columns: TableColumn[] = [ { title: 'Error', - render: (data) => , + render: data => , }, { title: 'Graph', - render: (data) => , + render: data => , }, { title: 'First seen', field: 'firstSeen', - render: (data) => { + render: data => { const { firstSeen } = data as SentryIssue; return format(firstSeen); }, @@ -41,7 +41,7 @@ const columns: TableColumn[] = [ { title: 'Last seen', field: 'lastSeen', - render: (data) => { + render: data => { const { lastSeen } = data as SentryIssue; return format(lastSeen); }, diff --git a/plugins/sentry/src/data/mock-api.ts b/plugins/sentry/src/data/mock-api.ts index e26cce1762..215e9f7734 100644 --- a/plugins/sentry/src/data/mock-api.ts +++ b/plugins/sentry/src/data/mock-api.ts @@ -33,7 +33,7 @@ function getMockIssues(number: number): SentryIssue[] { } export class MockSentryApi implements SentryApi { fetchIssues(): Promise { - return new Promise((resolve) => { + return new Promise(resolve => { setTimeout(() => resolve(getMockIssues(14)), 800); }); } diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index 93285f5214..3a9c75a3f7 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -5,7 +5,10 @@ "main:src": "src/index.ts", "types": "src/index.ts", "license": "Apache-2.0", - "private": true, + "private": false, + "publishConfig": { + "access": "public" + }, "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", diff --git a/plugins/tech-radar/src/components/Radar/Radar.jsx b/plugins/tech-radar/src/components/Radar/Radar.jsx index ee956bfb89..11f763f465 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.jsx +++ b/plugins/tech-radar/src/components/Radar/Radar.jsx @@ -105,14 +105,14 @@ export default class Radar extends React.Component { static adjustEntries(entries, activeEntry, quadrants, rings, radius) { let seed = 42; entries.forEach((entry, idx) => { - const quadrant = quadrants.find((q) => { + const quadrant = quadrants.find(q => { const match = typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant; return q.id === match; }); - const ring = rings.find((r) => { + const ring = rings.find(r => { const match = typeof entry.ring === 'object' ? entry.ring.id : entry.ring; return r.id === match; @@ -190,7 +190,7 @@ export default class Radar extends React.Component { return ( { + ref={node => { this.node = node; }} width={width} @@ -205,7 +205,7 @@ export default class Radar extends React.Component { quadrants={quadrants} rings={rings} activeEntry={activeEntry} - onEntryMouseEnter={(entry) => this._setActiveEntry(entry)} + onEntryMouseEnter={entry => this._setActiveEntry(entry)} onEntryMouseLeave={() => this._clearActiveEntry()} /> diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx index 83487d637e..072f80ca20 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.jsx @@ -49,16 +49,16 @@ class RadarBubble extends React.PureComponent { this._updatePosition(); } - _setRect = (rect) => { + _setRect = rect => { this.rect = rect; }; - _setNode = (node) => { + _setNode = node => { this.node = node; }; - _setText = (text) => { + _setText = text => { this.text = text; }; - _setPath = (path) => { + _setPath = path => { this.path = path; }; diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx index f48a8e9514..ce6c046242 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.jsx @@ -84,7 +84,7 @@ class RadarGrid extends React.PureComponent { />, ]; - const ringNodes = rings.map((r) => r.outerRadius).map(makeRingNode); + const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode); return axisNodes.concat(ringNodes); } diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx index 5fb7b5a1ba..674dd34cbb 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.jsx @@ -88,7 +88,7 @@ class RadarLegend extends React.PureComponent {

{quadrant.name}

- {rings.map((ring) => + {rings.map(ring => RadarLegend._renderRing( ring, RadarLegend._getSegment(segments, quadrant, ring), @@ -117,7 +117,7 @@ class RadarLegend extends React.PureComponent {

(empty)

) : (
    - {entries.map((entry) => { + {entries.map(entry => { let node = {entry.title}; if (entry.url) { @@ -175,7 +175,7 @@ class RadarLegend extends React.PureComponent { return ( - {quadrants.map((quadrant) => + {quadrants.map(quadrant => RadarLegend._renderQuadrant( segments, quadrant, diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx index afa4c4cd95..b93d20cbff 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.jsx @@ -46,16 +46,16 @@ export default class RadarPlot extends React.PureComponent { rings={rings} entries={entries} onEntryMouseEnter={ - onEntryMouseEnter && ((entry) => onEntryMouseEnter(entry)) + onEntryMouseEnter && (entry => onEntryMouseEnter(entry)) } onEntryMouseLeave={ - onEntryMouseLeave && ((entry) => onEntryMouseLeave(entry)) + onEntryMouseLeave && (entry => onEntryMouseLeave(entry)) } /> - {entries.map((entry) => ( + {entries.map(entry => ( =0.1.1" + websocket-driver@>=0.5.1: version "0.7.3" resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.3.tgz#a2d4e0d4f4f116f1e6297eba58b05d430100e9f9" @@ -21467,12 +21483,7 @@ ws@^6.1.2, ws@^6.2.1: dependencies: async-limiter "~1.0.0" -ws@^7.0.0: - version "7.2.3" - resolved "https://registry.npmjs.org/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46" - integrity sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ== - -ws@^7.2.3: +ws@^7.0.0, ws@^7.2.3: version "7.3.0" resolved "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd" integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w== @@ -21529,7 +21540,7 @@ y18n@^3.2.1: resolved "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41" integrity sha1-bRX7qITAhnnA136I53WegR4H+kE= -"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0: +y18n@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b" integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w== @@ -21578,10 +21589,10 @@ yargs-parser@^10.0.0: dependencies: camelcase "^4.1.0" -yargs-parser@^11.1.1: - version "11.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" - integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ== +yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -21624,24 +21635,6 @@ yargs-parser@^9.0.2: dependencies: camelcase "^4.1.0" -yargs@12.0.5: - version "12.0.5" - resolved "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13" - integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw== - dependencies: - cliui "^4.0.0" - decamelize "^1.2.0" - find-up "^3.0.0" - get-caller-file "^1.0.1" - os-locale "^3.0.0" - require-directory "^2.1.1" - require-main-filename "^1.0.1" - set-blocking "^2.0.0" - string-width "^2.0.0" - which-module "^2.0.0" - y18n "^3.2.1 || ^4.0.0" - yargs-parser "^11.1.1" - yargs@^11.0.0: version "11.1.1" resolved "https://registry.npmjs.org/yargs/-/yargs-11.1.1.tgz#5052efe3446a4df5ed669c995886cc0f13702766" @@ -21660,6 +21653,22 @@ yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" +yargs@^13.3.2: + version "13.3.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.2" + yargs@^14.2.2: version "14.2.3" resolved "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414"