diff --git a/.yarnrc b/.yarnrc index 524dcb98eb..f465c5c5e6 100644 --- a/.yarnrc +++ b/.yarnrc @@ -5,3 +5,4 @@ registry "https://registry.npmjs.org/" lastUpdateCheck 1580389148099 yarn-path ".yarn/releases/yarn-1.22.1.js" +network-timeout 600000 diff --git a/docs/FAQ.md b/docs/FAQ.md index e38fa84e73..7d568a840e 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -15,11 +15,11 @@ No, but it can be! Backstage is designed to be a developer portal for all your infrastructure tooling, services, and documentation. So, it's not a monitoring platform — but that doesn't mean you can't integrate a monitoring tool into Backstage by writing -[a plugin](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage). +[a plugin](/docs/FAQ.md#what-is-a-plugin-in-backstage). ### How is Backstage licensed? -Backstage was released as open sourced software by Spotify and is licensed under +Backstage was released as open source software by Spotify and is licensed under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). ### Why did we open source Backstage? @@ -36,16 +36,16 @@ more, read our blog post, Yes, we've already started releasing open source versions of some of the plugins we use here, and we'll continue to do so. -[Plugins](https://github.com/spotify/faq#what-is-a-plugin-in-backstage) are the +[Plugins](/docs/FAQ.md#what-is-a-plugin-in-backstage) are the building blocks of functionality in Backstage. We have over 120 plugins inside Spotify — many of those are specialized for our use, so will remain internal and proprietary to us. But we estimate that about a third of our existing plugins make good open source candidates. (And we'll probably end up writing some brand -new ones, too.) ​ +new ones, too.) ### What's the roadmap for Backstage? -​ We envision three phases, which you can learn about in +We envision three phases, which you can learn about in [our project roadmap](https://github.com/spotify/backstage#project-roadmap). Even though the open source version of Backstage is relatively new compared to our internal version, we have already begun work on various aspects of all three @@ -80,142 +80,138 @@ knew that we would like to use. It strikes a good balance between power, customizability, and ease of use. A core focus of Backstage is to make plugin developers productive with as few hurdles as possible. Material-UI lets plugin makers get going easily with both -well-known tech and a large flora of components. ​ +well-known tech and a large flora of components. ### What technology does Backstage use? -​ The code base is a large-scale React application that uses TypeScript. For +The code base is a large-scale React application that uses TypeScript. For [Phase 2](https://github.com/spotify/backstage#project-roadmap), we plan to use -Node.js and GraphQL. ​ +Node.js and GraphQL. ### What is the end-to-end user flow? The happy path story. -​ There are three main user profiles for Backstage: the integrator, the -contributor, and the software engineer. ​ The **integrator** hosts the Backstage -app and configures which plugins are available to use in the app. ​ The -**contributor** adds functionality to the app by writing plugins. ​ The -**software engineer** uses the app's functionality and interacts with its -plugins. ​ +There are three main user profiles for Backstage: the integrator, the +contributor, and the software engineer. -### What is the use of a "plugin" in Backstage? +The **integrator** hosts the Backstage app and configures which plugins are available to use in the app. -​ A Backstage Plugin adds functionality to Backstage. ​ +The **contributor** adds functionality to the app by writing plugins. +The **software engineer** uses the app's functionality and interacts with its plugins. + +### What is a "plugin" in Backstage? + +Plugins are what provide the feature functionality in Backstage. They are used to integrate different systems into Backstage's frontend, so that the developer gets a consistent UX, no matter what tool or service is being accessed on the other side. + +Each plugin is treated as a self-contained web app and can include almost any type of content. Plugins all use a common set of platform APIs and reusable UI components. Plugins can fetch data either from the backend or an API exposed through the proxy. + +Learn more about [the different components](https://github.com/spotify/backstage#overview) that make up Backstage. ### Do I have to write plugins in TypeScript? -​ No, you can use JavaScript if you prefer. ​ We want to keep the Backstage core -APIs in TypeScript, but aren't forcing it on individual plugins. ​ +No, you can use JavaScript if you prefer. We want to keep the Backstage core +APIs in TypeScript, but aren't forcing it on individual plugins. ### How do I find out if a plugin already exists? -​ Before you write a plugin, +Before you write a plugin, [search the plugin issues](https://github.com/spotify/backstage/issues?q=is%3Aissue+label%3Aplugin+) to see if it already exists or is in the works. If no one's thought of it yet, great! Open a new issue as [a plugin suggestion](https://github.com/spotify/backstage/issues/new/choose) and describe what your plugin will do. This will help coordinate our -contributors' efforts and avoid duplicating existing functionality. ​ In the +contributors' efforts and avoid duplicating existing functionality. In the future, we will create [a plugin gallery](https://github.com/spotify/backstage/issues/260) where people -can browse and search for all available plugins. ​ +can browse and search for all available plugins. ### Which plugin is used the most at Spotify? -​ By far, our most-used plugin is our TechDocs plugin, which we use for creating -technical documentation. Our philosophy at Spotify is to treat "docs like code", -where you write documentation using the same workflow as you write your code. -This makes it easier to create, find, and update documentation. We hope to -release -[the open source version](https://github.com/spotify/backstage/issues/687) in -the future. (See also: -"[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/faq#will-spotifys-internal-plugins-be-open-sourced-too)" -above) ​ +By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. This makes it easier to create, find, and update documentation. We hope to release [the open source version](https://github.com/spotify/backstage/issues/687) in the future. (See also: "[Will Spotify's internal plugins be open sourced, too?](/docs/FAQ.md#will-spotifys-internal-plugins-be-open-sourced-too)" above) ### Are you planning to have plugins baked into the repo? Or should they be developed in separate repos? -​ Contributors can add open source plugins to the plugins directory in +Contributors can add open source plugins to the plugins directory in [this monorepo](https://github.com/spotify/backstage). Integrators can then configure which open source plugins are available to use in their instance of the app. Open source plugins are downloaded as npm packages published in the -open source repository. ​ While we encourage using the open source model, we +open source repository. While we encourage using the open source model, we know there are cases where contributors might want to experiment internally or keep their plugins closed source. Contributors writing closed source plugins should develop them in the plugins directory in their own Backstage repository. -Integrators also configure closed source plugins locally from the monorepo. ​ +Integrators also configure closed source plugins locally from the monorepo. ### Any plans for integrating with other repository managers, such as GitLab or Bitbucket? -​ We chose GitHub because it is the tool that we are most familiar with, so that +We chose GitHub because it is the tool that we are most familiar with, so that will naturally lead to integrations for GitHub being developed at an early -stage. ​ Hosting this project on GitHub does not exclude integrations with +stage. Hosting this project on GitHub does not exclude integrations with alternatives, such as [GitLab](https://github.com/spotify/backstage/issues?q=is%3Aissue+is%3Aopen+GitLab) or Bitbucket. We believe that in time there will be plugins that will provide -functionality for these tools as well. Hopefully, contributed by the community! -​ Also note, implementations of Backstage can be hosted wherever you feel suits -your needs best. ​ +functionality for these tools as well. Hopefully, contributed by the community! Also note, implementations of Backstage can be hosted wherever you feel suits +your needs best. ### Who maintains Backstage? -​ Spotify will maintain the open source core, but we envision different parts of +Spotify will maintain the open source core, but we envision different parts of the project being maintained by various companies and contributors. We also envision a large, diverse ecosystem of open source plugins, which would be -maintained by their original authors/contributors or by the community. ​ When it +maintained by their original authors/contributors or by the community. When it comes to [deployment](https://github.com/spotify/backstage/blob/master/DEPLOYMENT.md), the system integrator (typically, the infrastructure team in your organization) -maintains Backstage in your own environment. ​ +maintains Backstage in your own environment. ### Does Spotify provide a managed version of Backstage? -​ No, this is not a service offering. We build the piece of software, and +No, this is not a service offering. We build the piece of software, and someone in your infrastructure team is responsible for [deploying](https://github.com/spotify/backstage/blob/master/DEPLOYMENT.md) and -maintaining it. ​ +maintaining it. ### How secure is Backstage? -​ We take security seriously. When it comes to packages and code we scan our +We take security seriously. When it comes to packages and code we scan our repositories periodically and update our packages to the latest versions. When it comes to deployment of Backstage within an organisation it depends on the deployment and security setup in your organisation. Reach out to us on [Discord](https://discord.gg/MUpMjP2) if you have specific queries. Please report sensitive security issues via Spotify's -[bug-bounty program](https://hackerone.com/spotify) rather than GitHub. ​ +[bug-bounty program](https://hackerone.com/spotify) rather than GitHub. ### Does Backstage collect any information that is shared with Spotify? -​ No. Backstage does not collect any telemetry from any third party using the +No. Backstage does not collect any telemetry from any third party using the platform. Spotify, and the open source community, does have access to [GitHub Insights](https://github.com/features/insights), which contains -information such as contributors, commits, traffic, and dependencies. ​ +information such as contributors, commits, traffic, and dependencies. Backstage is an open platform, but you are in control of your own data. You control who has access to any data you provide to your version of Backstage and -who that data is shared with. ​ +who that data is shared with. ### Can Backstage be used to build something other than a developer portal? -​ Yes. The core frontend framework could be used for building any large-scale +Yes. The core frontend framework could be used for building any large-scale web application where (1) multiple teams are building separate parts of the app, -and (2) you want the overall experience to be consistent. ​ That being said, in +and (2) you want the overall experience to be consistent. That being said, in [Phase 2](https://github.com/spotify/backstage#project-roadmap) of the project we will add features that are needed for developer portals and systems for -managing software ecosystems. Our ambition will be to keep Backstage modular. ​ +managing software ecosystems. Our ambition will be to keep Backstage modular. ### How can I get involved? -​ Jump right in! Come help us fix some of the +Jump right in! Come help us fix some of the [early bugs and first issues](https://github.com/spotify/backstage/labels/good%20first%20issue) or reach [a new milestone](https://github.com/spotify/backstage/milestones). Or write an open source plugin for Backstage, like this [Lighthouse plugin](https://github.com/spotify/backstage/tree/master/plugins/lighthouse). -​ See all the ways you can +See all the ways you can [contribute here](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md). -We'd love to have you as part of the community. ​ +We'd love to have you as part of the community. ### Can I join the Backstage team? -​ If you're interested in being part of the Backstage team, reach out to +If you're interested in being part of the Backstage team, reach out to [fossopportunities@spotify.com](mailto:fossopportunities@spotify.com) diff --git a/docs/auth/README.md b/docs/auth/README.md index 081d10add6..9809f57168 100644 --- a/docs/auth/README.md +++ b/docs/auth/README.md @@ -80,7 +80,7 @@ sign-in methods. More details are provided in dedicated sections of the documentation. -- [OAuth](./oauth): Description of the generic OAuth flow implemented by the +- [OAuth](./oauth.md): Description of the generic OAuth flow implemented by the [auth-backend](../../plugins/auth-backend). -- [Glossary](./glossary): Glossary of some common terms related to the auth +- [Glossary](./glossary.md): Glossary of some common terms related to the auth flows. diff --git a/packages/app/package.json b/packages/app/package.json index 118b4c15ff..46769e7dfb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -76,13 +76,6 @@ "pathRewrite": { "^/circleci/api/": "/" } - }, - "/catalog/api": { - "target": "http://localhost:7000", - "changeOrigin": true, - "pathRewrite": { - "^/catalog/api/": "/catalog/" - } } } } diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index 65e8ff64e6..26c3466ba1 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -26,6 +26,7 @@ describe('App', () => { { data: { app: { title: 'Test' }, + backend: { baseUrl: 'http://localhost:7000' }, }, context: 'test', }, diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index f4515cbf01..ccaeb2828b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -31,7 +31,7 @@ const app = createApp({ plugins: Object.values(plugins), components: { SignInPage: props => ( - + ), }, }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 5011b16eaf..4ac527ef45 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,10 +26,14 @@ import { FeatureFlags, GoogleAuth, GithubAuth, + OktaAuth, + GitlabAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oktaAuthApiRef, + gitlabAuthApiRef, storageApiRef, WebStorage, } from '@backstage/core'; @@ -54,6 +58,8 @@ export const apis = (config: ConfigApi) => { // eslint-disable-next-line no-console console.log(`Creating APIs for ${config.getString('app.title')}`); + const backendUrl = config.getString('backend.baseUrl'); + const builder = ApiRegistry.builder(); const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); @@ -76,7 +82,7 @@ export const apis = (config: ConfigApi) => { builder.add( googleAuthApiRef, GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', + apiOrigin: backendUrl, basePath: '/auth/', oauthRequestApi, }), @@ -85,6 +91,24 @@ export const apis = (config: ConfigApi) => { const githubAuthApi = builder.add( githubAuthApiRef, GithubAuth.create({ + apiOrigin: backendUrl, + basePath: '/auth/', + oauthRequestApi, + }), + ); + + builder.add( + oktaAuthApiRef, + OktaAuth.create({ + apiOrigin: backendUrl, + basePath: '/auth/', + oauthRequestApi, + }), + ); + + builder.add( + gitlabAuthApiRef, + GitlabAuth.create({ apiOrigin: 'http://localhost:7000', basePath: '/auth/', oauthRequestApi, @@ -102,8 +126,8 @@ export const apis = (config: ConfigApi) => { builder.add( catalogApiRef, new CatalogClient({ - apiOrigin: 'http://localhost:3000', - basePath: '/catalog/api', + apiOrigin: backendUrl, + basePath: '/catalog', }), ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 4b92f8281e..a86b92ab43 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -22,6 +22,7 @@ import ExploreIcon from '@material-ui/icons/Explore'; import BuildIcon from '@material-ui/icons/BuildRounded'; import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; import MapIcon from '@material-ui/icons/MyLocation'; +import LibraryBooks from '@material-ui/icons/LibraryBooks'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; @@ -89,6 +90,7 @@ const Root: FC<{}> = ({ children }) => ( {/* Global nav, not org-specific */} + {/* End global nav */} diff --git a/packages/backend/Dockerfile b/packages/backend/Dockerfile new file mode 100644 index 0000000000..3e8ba36cec --- /dev/null +++ b/packages/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM node:12 + +WORKDIR /usr/src/app + +COPY . . + +RUN yarn install --frozen-lockfile --production + +CMD ["node", "packages/backend"] diff --git a/packages/backend/package.json b/packages/backend/package.json index 3be1f5ee3e..64ca2c3113 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,6 +10,7 @@ }, "scripts": { "build": "backstage-cli backend:build", + "build-image": "backstage-cli backend:build-image example-backend", "start": "backstage-cli backend:dev", "lint": "backstage-cli lint", "test": "backstage-cli test", diff --git a/packages/cli/src/commands/backend/build.ts b/packages/cli/src/commands/backend/build.ts index a9cdc51fa9..ceca5b0286 100644 --- a/packages/cli/src/commands/backend/build.ts +++ b/packages/cli/src/commands/backend/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildPackage, Output } from '../../lib/packager'; +import { buildPackage, Output } from '../../lib/builder'; export default async () => { await buildPackage({ diff --git a/packages/cli/src/commands/backend/buildImage.ts b/packages/cli/src/commands/backend/buildImage.ts new file mode 100644 index 0000000000..a847f1ef54 --- /dev/null +++ b/packages/cli/src/commands/backend/buildImage.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 fs from 'fs-extra'; +import { createDistWorkspace } from '../../lib/packager'; +import { paths } from '../../lib/paths'; +import { run } from '../../lib/run'; + +export default async (imageTag: string) => { + const tempDistWorkspace = await createDistWorkspace(['example-backend'], { + files: [ + 'package.json', + 'yarn.lock', + 'app-config.yaml', + { src: paths.resolveTarget('Dockerfile'), dest: 'Dockerfile' }, + ], + }); + + console.log(`Dist workspace ready at ${tempDistWorkspace}`); + + await run('docker', ['build', '.', '-t', imageTag], { + cwd: tempDistWorkspace, + }); + + await fs.remove(tempDistWorkspace); +}; diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/build.ts index 19e4b17352..bd5bbc5e9f 100644 --- a/packages/cli/src/commands/build.ts +++ b/packages/cli/src/commands/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildPackage, Output } from '../lib/packager'; +import { buildPackage, Output } from '../lib/builder'; import { Command } from 'commander'; export default async (cmd: Command) => { diff --git a/packages/cli/src/commands/pack.ts b/packages/cli/src/commands/pack.ts index 51c7e36086..1f81b89767 100644 --- a/packages/cli/src/commands/pack.ts +++ b/packages/cli/src/commands/pack.ts @@ -19,10 +19,15 @@ import { paths } from '../lib/paths'; const SKIPPED_KEYS = ['access', 'registry', 'tag']; -export const pre = async () => { - const pkgPath = paths.resolveTarget('package.json'); +const PKG_PATH = 'package.json'; +const PKG_BACKUP_PATH = 'package.json-prepack'; - const pkg = await fs.readJson(pkgPath); +export const pre = async () => { + const pkgPath = paths.resolveTarget(PKG_PATH); + + const pkgContent = await fs.readFile(pkgPath, 'utf8'); + const pkg = JSON.parse(pkgContent); + await fs.writeFile(PKG_BACKUP_PATH, pkgContent); for (const key of Object.keys(pkg.publishConfig ?? {})) { if (!SKIPPED_KEYS.includes(key)) { @@ -33,5 +38,6 @@ export const pre = async () => { }; export const post = async () => { - // postpack is a noop for now, since it's not called anyway + // postpack isn't called by yarn right now, so it needs to be called manually + await fs.move(PKG_BACKUP_PATH, PKG_PATH, { overwrite: true }); }; diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts index 7e4e5cd36a..d62ffbeebd 100644 --- a/packages/cli/src/commands/plugin/build.ts +++ b/packages/cli/src/commands/plugin/build.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { buildPackage, Output } from '../../lib/packager'; +import { buildPackage, Output } from '../../lib/builder'; export default async () => { await buildPackage({ diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e67a52c795..bed32bcf8b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -46,6 +46,15 @@ const main = (argv: string[]) => { .description('Build a backend plugin') .action(lazyAction(() => import('./commands/backend/build'), 'default')); + program + .command('backend:build-image ') + .description( + 'Builds a docker image from the package, with all local deps included', + ) + .action( + lazyAction(() => import('./commands/backend/buildImage'), 'default'), + ); + program .command('backend:dev') .description('Start local development server with HMR for the backend') diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/builder/config.ts similarity index 100% rename from packages/cli/src/lib/packager/config.ts rename to packages/cli/src/lib/builder/config.ts diff --git a/packages/cli/src/lib/builder/index.ts b/packages/cli/src/lib/builder/index.ts new file mode 100644 index 0000000000..17aae25ec4 --- /dev/null +++ b/packages/cli/src/lib/builder/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 { buildPackage } from './packager'; +export { Output } from './types'; +export type { BuildOptions } from './types'; diff --git a/packages/cli/src/lib/packager/packager.ts b/packages/cli/src/lib/builder/packager.ts similarity index 100% rename from packages/cli/src/lib/packager/packager.ts rename to packages/cli/src/lib/builder/packager.ts diff --git a/packages/cli/src/lib/packager/types.ts b/packages/cli/src/lib/builder/types.ts similarity index 100% rename from packages/cli/src/lib/packager/types.ts rename to packages/cli/src/lib/builder/types.ts diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts index 17aae25ec4..5e309ab953 100644 --- a/packages/cli/src/lib/packager/index.ts +++ b/packages/cli/src/lib/packager/index.ts @@ -14,6 +14,133 @@ * limitations under the License. */ -export { buildPackage } from './packager'; -export { Output } from './types'; -export type { BuildOptions } from './types'; +import fs from 'fs-extra'; +import { resolve as resolvePath, relative as relativePath } from 'path'; +import { paths } from '../paths'; +import { run } from '../run'; +import tar from 'tar'; +import { tmpdir } from 'os'; + +type LernaPackage = { + name: string; + private: boolean; + location: string; + scripts: Record; +}; + +type FileEntry = + | string + | { + src: string; + dest: string; + }; + +type Options = { + /** + * Target directory for the dist workspace, defaults to a temporary directory + */ + targetDir?: string; + + /** + * Files to copy into the target workspace. + * + * Defaults to ['yarn.lock', 'package.json']. + */ + files?: FileEntry[]; +}; + +/** + * Uses `yarn pack` to package local packages and unpacks them into a dist workspace. + * The target workspace will end up containing dist version of each package and + * will be suitable for packaging e.g. into a docker image. + * + * This creates a structure that is functionally similar to if the packages where + * installed from NPM, but uses yarn workspaces to link to them at runtime. + */ +export async function createDistWorkspace( + packageNames: string[], + options: Options, +) { + const targetDir = + options.targetDir ?? + (await fs.mkdtemp(resolvePath(tmpdir(), 'dist-workspace'))); + + const targets = await findTargetPackages(packageNames); + + await moveToDistWorkspace(targetDir, targets); + + const files: FileEntry[] = options.files ?? ['yarn.lock', 'package.json']; + + for (const file of files) { + const src = typeof file === 'string' ? file : file.src; + const dest = typeof file === 'string' ? file : file.dest; + await fs.copy(paths.resolveTargetRoot(src), resolvePath(targetDir, dest)); + } + return targetDir; +} + +async function moveToDistWorkspace( + workspaceDir: string, + localPackages: LernaPackage[], +): Promise { + await Promise.all( + localPackages.map(async (target, index) => { + console.log(`Repacking ${target.name} into dist workspace`); + const archive = `temp-package-${index}.tgz`; + const archivePath = resolvePath(workspaceDir, archive); + + await run('yarn', ['pack', '--filename', archivePath], { + cwd: target.location, + }); + // TODO(Rugvip): yarn pack doesn't call postpack, once the bug is fixed this can be removed + if (target.scripts.postpack) { + await run('yarn', ['postpack'], { cwd: target.location }); + } + + const outputDir = relativePath(paths.targetRoot, target.location); + const absoluteOutputPath = resolvePath(workspaceDir, outputDir); + await fs.ensureDir(absoluteOutputPath); + + await tar.extract({ + file: archivePath, + cwd: absoluteOutputPath, + strip: 1, + }); + await fs.remove(archivePath); + }), + ); +} + +async function findTargetPackages(pkgNames: string[]): Promise { + const LernaProject = require('@lerna/project'); + const PackageGraph = require('@lerna/package-graph'); + + const project = new LernaProject(paths.targetDir); + const packages = await project.getPackages(); + const graph = new PackageGraph(packages); + + const targets = new Map(); + const searchNames = pkgNames.slice(); + + while (searchNames.length) { + const name = searchNames.pop()!; + + if (targets.has(name)) { + continue; + } + + const node = graph.get(name); + if (!node) { + throw new Error(`Package '${name}' not found`); + } + + const pkgDeps = Object.keys(node.pkg.dependencies); + const localDeps: string[] = Array.from(node.localDependencies.keys()); + const filteredDeps = localDeps.filter(dep => pkgDeps.includes(dep)); + + searchNames.push(...filteredDeps); + targets.set(name, node.pkg); + } + + return Array.from(targets.values()); +} diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index d5c0e9c0a4..c9a17f0153 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -233,3 +233,33 @@ export const githubAuthApiRef = createApiRef< id: 'core.auth.github', description: 'Provides authentication towards Github APIs', }); + +/** + * Provides authentication towards Okta APIs. + * + * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ + * for a full list of supported scopes. + */ +export const oktaAuthApiRef = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionStateApi +>({ + id: 'core.auth.okta', + description: 'Provides authentication towards Okta APIs', +}); + +/** + * Provides authentication towards Gitlab APIs. + * + * See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token + * for a full list of supported scopes. + */ +export const gitlabAuthApiRef = createApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi +>({ + id: 'core.auth.gitlab', + description: 'Provides authentication towards Gitlab APIs', +}); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index ddf03034dc..9309cfd437 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -27,7 +27,10 @@ import { } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { + AuthSessionStore, + StaticAuthSessionManager, +} from '../../../../lib/AuthSessionManager'; import { Observable } from '../../../../types'; type CreateOptions = { @@ -91,7 +94,13 @@ class GithubAuth implements OAuthApi, SessionStateApi { sessionScopes: (session: GithubSession) => session.providerInfo.scopes, }); - return new GithubAuth(sessionManager); + const authSessionStore = new AuthSessionStore({ + manager: sessionManager, + storageKey: 'githubSession', + sessionScopes: (session: GithubSession) => session.providerInfo.scopes, + }); + + return new GithubAuth(authSessionStore); } sessionState$(): Observable { diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts new file mode 100644 index 0000000000..18346b3208 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -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 GitlabAuth from './GitlabAuth'; + +describe('GitlabAuth', () => { + it('should get access token', async () => { + const getSession = jest + .fn() + .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); + const gitlabAuth = new GitlabAuth({ getSession } as any); + + expect(await gitlabAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should normalize scope', () => { + const tests = [ + { + arguments: ['read_user api write_repository'], + expect: new Set(['read_user', 'api', 'write_repository']), + }, + { + arguments: ['read_repository sudo'], + expect: new Set(['read_repository', 'sudo']), + }, + ]; + + for (const test of tests) { + expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect); + } + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts new file mode 100644 index 0000000000..20e13f8a03 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -0,0 +1,135 @@ +/* + * 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 GitlabIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { GitlabSession } from './types'; +import { + OAuthApi, + SessionStateApi, + SessionState, + ProfileInfo, + BackstageIdentity, + AuthRequestOptions, +} from '../../../definitions/auth'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type GitlabAuthResponse = { + providerInfo: { + accessToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'gitlab', + title: 'Gitlab', + icon: GitlabIcon, +}; + +class GitlabAuth implements OAuthApi, SessionStateApi { + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi, + sessionTransform(res: GitlabAuthResponse): GitlabSession { + return { + ...res, + providerInfo: { + accessToken: res.providerInfo.accessToken, + scopes: GitlabAuth.normalizeScope(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new StaticAuthSessionManager({ + connector, + defaultScopes: new Set(['read_user']), + sessionScopes: (session: GitlabSession) => session.providerInfo.scopes, + }); + + return new GitlabAuth(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken(scope?: string, options?: AuthRequestOptions) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: GitlabAuth.normalizeScope(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + static normalizeScope(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) ? scope : scope.split(' '); + return new Set(scopeList); + } +} +export default GitlabAuth; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts new file mode 100644 index 0000000000..ee17b7de06 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/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 './types'; +export { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts new file mode 100644 index 0000000000..b03dfec084 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type GitlabSession = { + providerInfo: { + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index f13368b5c4..9d89ba5b04 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -16,3 +16,5 @@ export * from './google'; export * from './github'; +export * from './gitlab'; +export * from './okta'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts new file mode 100644 index 0000000000..ab6c46c9b4 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -0,0 +1,129 @@ +/* + * 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 OktaAuth from './OktaAuth'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +const PREFIX = 'okta.'; + +describe('OktaAuth', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + providerInfo: { + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`not-a-scope`]), + }, + }) + .mockRejectedValue(error); + const oktaAuth = new OktaAuth({ getSession } as any); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oktaAuth.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oktaAuth.getAccessToken('more'); + const promise2 = oktaAuth.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + providerInfo: { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }, + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + providerInfo: { + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + // Grab the expired session first + await expect(oktaAuth.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oktaAuth.getIdToken(); + const promise2 = oktaAuth.getIdToken(); + const promise3 = oktaAuth.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); + + it.each([ + ['openid', ['openid']], + ['profile email', ['profile', 'email']], + [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], + ['groups.read', [`${PREFIX}groups.read`]], + [`${PREFIX}groups.manage groups.read, openid`, [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid']], + [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts new file mode 100644 index 0000000000..f10ec4ebce --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -0,0 +1,186 @@ +/* + * 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 OktaIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { OktaSession } from './types'; +import { + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + ProfileInfo, + SessionStateApi, + SessionState, + BackstageIdentityApi, + AuthRequestOptions, + BackstageIdentity, +} from '../../../definitions/auth'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type OktaAuthResponse = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'okta', + title: 'Okta', + icon: OktaIcon, +}; + +const OKTA_OIDC_SCOPES: Set = new Set( + ['openid', 'profile', 'email', 'phone', 'address', 'groups', 'offline_access'] +) + +const OKTA_SCOPE_PREFIX: string = 'okta.' + +class OktaAuth implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionStateApi +{ + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OktaAuthResponse): OktaSession { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OktaAuth.normalizeScopes(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + 'email', + 'profile', + 'offline_access', + ]), + sessionScopes: session => session.scopes, + sessionShouldRefresh: session => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OktaAuth(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken( + scope?: string, + options?: AuthRequestOptions + ) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: OktaAuth.normalizeScopes(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { + return new Set(); + } + + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); + + const normalizedScopes = scopeList.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}` + }); + + return new Set(normalizedScopes); + } +} + +export default OktaAuth; \ No newline at end of file diff --git a/packages/core-api/src/apis/implementations/auth/okta/index.ts b/packages/core-api/src/apis/implementations/auth/okta/index.ts new file mode 100644 index 0000000000..2bef0ce0db --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/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 './types'; +export { default as OktaAuth } from './OktaAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/types.ts b/packages/core-api/src/apis/implementations/auth/okta/types.ts new file mode 100644 index 0000000000..e3392ba1d6 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type OktaSession = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts index 3e3a40d02a..f2b8558f74 100644 --- a/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts +++ b/packages/core-api/src/lib/AuthSessionManager/AuthSessionStore.ts @@ -36,6 +36,8 @@ type Options = { /** * AuthSessionStore decorates another SessionManager with a functionality * to store the session in local storage. + * + * Session is serialized to JSON with special support for following types: Set. */ export class AuthSessionStore implements SessionManager { private readonly manager: SessionManager; @@ -90,7 +92,12 @@ export class AuthSessionStore implements SessionManager { try { const sessionJson = localStorage.getItem(this.storageKey); if (sessionJson) { - const session = JSON.parse(sessionJson); + const session = JSON.parse(sessionJson, (_key, value) => { + if (value?.__type === 'Set') { + return new Set(value.__value); + } + return value; + }); return session; } @@ -105,7 +112,18 @@ export class AuthSessionStore implements SessionManager { if (session === undefined) { localStorage.removeItem(this.storageKey); } else { - localStorage.setItem(this.storageKey, JSON.stringify(session)); + localStorage.setItem( + this.storageKey, + JSON.stringify(session, (_key, value) => { + if (value instanceof Set) { + return { + __type: 'Set', + __value: Array.from(value), + }; + } + return value; + }), + ); } } } diff --git a/packages/core-api/src/lib/AuthSessionManager/index.ts b/packages/core-api/src/lib/AuthSessionManager/index.ts index 16a8d3c378..5f4dde8662 100644 --- a/packages/core-api/src/lib/AuthSessionManager/index.ts +++ b/packages/core-api/src/lib/AuthSessionManager/index.ts @@ -16,4 +16,5 @@ export { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; export { StaticAuthSessionManager } from './StaticAuthSessionManager'; +export { AuthSessionStore } from './AuthSessionStore'; export * from './types'; diff --git a/packages/core/package.json b/packages/core/package.json index 10a6c5d045..e49e4616ae 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -40,7 +40,7 @@ "classnames": "^2.2.6", "clsx": "^1.1.0", "lodash": "^4.17.15", - "material-table": "^1.58.0", + "material-table": "1.62.x", "prop-types": "^15.7.2", "rc-progress": "^3.0.0", "react": "^16.12.0", diff --git a/packages/core/src/components/Table/Table.stories.tsx b/packages/core/src/components/Table/Table.stories.tsx index 3709c301fd..11fa0c44dc 100644 --- a/packages/core/src/components/Table/Table.stories.tsx +++ b/packages/core/src/components/Table/Table.stories.tsx @@ -185,3 +185,38 @@ export const SubvalueTable = () => { ); }; + +export const DenseTable = () => { + 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 ( +
+ + + ); +}; diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx index c02aca5659..89aa2dce1f 100644 --- a/packages/core/src/components/Table/Table.tsx +++ b/packages/core/src/components/Table/Table.tsx @@ -14,18 +14,8 @@ * limitations under the License. */ -import React, { FC, forwardRef } from 'react'; -import MTable, { - MTableCell, - MTableHeader, - MTableToolbar, - MaterialTableProps, - Options, - Column, -} from 'material-table'; import { BackstageTheme } from '@backstage/theme'; -import { makeStyles, useTheme, Typography } from '@material-ui/core'; - +import { makeStyles, Typography, useTheme } 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'; import ArrowUpward from '@material-ui/icons/ArrowUpward'; @@ -42,6 +32,14 @@ import Remove from '@material-ui/icons/Remove'; import SaveAlt from '@material-ui/icons/SaveAlt'; import Search from '@material-ui/icons/Search'; import ViewColumn from '@material-ui/icons/ViewColumn'; +import MTable, { + Column, + MaterialTableProps, + MTableHeader, + MTableToolbar, + Options, +} from 'material-table'; +import React, { forwardRef } from 'react'; const tableIcons = { Add: forwardRef((props, ref: React.Ref) => ( @@ -97,14 +95,6 @@ const tableIcons = { )), }; -const useCellStyles = makeStyles(theme => ({ - root: { - color: theme.palette.grey[500], - padding: theme.spacing(0, 2, 0, 2.5), - height: '56px', - }, -})); - const useHeaderStyles = makeStyles(theme => ({ header: { padding: theme.spacing(1, 2, 1, 2.5), @@ -131,10 +121,10 @@ const useToolbarStyles = makeStyles(theme => ({ }, })); -const convertColumns = ( - columns: TableColumn[], +function convertColumns( + columns: TableColumn[], theme: BackstageTheme, -): TableColumn[] => { +): TableColumn[] { return columns.map(column => { const headerStyle: React.CSSProperties = {}; const cellStyle: React.CSSProperties = {}; @@ -150,26 +140,26 @@ const convertColumns = ( cellStyle, }; }); -}; +} -export interface TableColumn extends Column<{}> { +export interface TableColumn extends Column { highlight?: boolean; width?: string; } -export interface TableProps extends MaterialTableProps<{}> { - columns: TableColumn[]; +export interface TableProps + extends MaterialTableProps { + columns: TableColumn[]; subtitle?: string; } -export const Table: FC = ({ +export function Table({ columns, options, title, subtitle, ...props -}) => { - const cellClasses = useCellStyles(); +}: TableProps) { const headerClasses = useHeaderStyles(); const toolbarClasses = useToolbarStyles(); const theme = useTheme(); @@ -183,11 +173,8 @@ export const Table: FC = ({ }; return ( - components={{ - Cell: cellProps => ( - - ), Header: headerProps => ( ), @@ -211,4 +198,4 @@ export const Table: FC = ({ {...props} /> ); -}; +} diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts index aeade2b24f..c10399dc59 100644 --- a/packages/core/src/components/Table/index.ts +++ b/packages/core/src/components/Table/index.ts @@ -15,5 +15,5 @@ */ export { Table } from './Table'; -export type { TableColumn } from './Table'; +export type { TableColumn, TableProps } from './Table'; export { SubvalueCell } from './SubvalueCell'; diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 2617a27aa9..838b126569 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -42,14 +42,15 @@ export type Tab = { id: string; label: string; }; + export const HeaderTabs: React.FC<{ tabs: Tab[]; - onChange?: (index: Number) => void; + onChange?: (index: number) => void; }> = ({ tabs, onChange }) => { - const [selectedTab, setSelectedTab] = useState(0); + const [selectedTab, setSelectedTab] = useState(0); const styles = useStyles(); - const handleChange = (_: React.ChangeEvent<{}>, index: Number) => { + const handleChange = (_: React.ChangeEvent<{}>, index: number) => { setSelectedTab(index); if (onChange) onChange(index); }; diff --git a/packages/core/src/layout/ItemCard/ItemCard.stories.tsx b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx new file mode 100644 index 0000000000..46b93da370 --- /dev/null +++ b/packages/core/src/layout/ItemCard/ItemCard.stories.tsx @@ -0,0 +1,67 @@ +/* + * 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 { ItemCard } from '.'; +import { Grid } from '@material-ui/core'; + +export default { + title: 'Item Card', + component: ItemCard, +}; + +export const Default = () => ( + + + {}} + /> + + + {}} + /> + + +); + +export const Tags = () => ( + + + + + + + + +); diff --git a/packages/core/src/layout/ItemCard/ItemCard.tsx b/packages/core/src/layout/ItemCard/ItemCard.tsx new file mode 100644 index 0000000000..86ad53e8dd --- /dev/null +++ b/packages/core/src/layout/ItemCard/ItemCard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React, { FC } from 'react'; +import { Button, Card, Chip, Typography, makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles(theme => ({ + header: { + color: theme.palette.common.white, + padding: theme.spacing(2, 2, 6), + backgroundImage: + 'linear-gradient(-137deg, rgb(25, 230, 140) 0%, rgb(29, 127, 110) 100%)', + }, + content: { + padding: theme.spacing(2), + }, + description: { + height: 175, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + footer: { + display: 'flex', + flexDirection: 'row-reverse', + }, +})); + +type ItemCardProps = { + description: string; + tags?: string[]; + title: string; + type?: string; + label: string; + onClick?: () => void; +}; +export const ItemCard: FC = ({ + description, + tags, + title, + type, + label, + onClick, +}) => { + const classes = useStyles(); + + return ( + +
+ {type ?? {type}} + {title} +
+
+ {tags?.map(tag => ( + + ))} + + {description} + +
+ +
+
+
+ ); +}; diff --git a/packages/core/src/layout/ItemCard/index.ts b/packages/core/src/layout/ItemCard/index.ts new file mode 100644 index 0000000000..b38dd2acc2 --- /dev/null +++ b/packages/core/src/layout/ItemCard/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 { ItemCard } from './ItemCard'; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 36e8e1088b..937169c8bf 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -22,7 +22,9 @@ import { SidebarContext } from './config'; import { googleAuthApiRef, githubAuthApiRef, + gitlabAuthApiRef, identityApiRef, + oktaAuthApiRef, useApi, } from '@backstage/core-api'; import { @@ -56,6 +58,16 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + + { + const oktaAuthApi = useApi(oktaAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const identity = await oktaAuthApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await oktaAuthApi.getProfile(); + + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => + oktaAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await oktaAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Okta + + + ); +}; + +const loader: ProviderLoader = async apis => { + const oktaAuthApi = apis.get(oktaAuthApiRef)!; + + const identity = await oktaAuthApi.getBackstageIdentity({ + optional: true, + }); + + if (!identity) { + return undefined; + } + + const profile = await oktaAuthApi.getProfile(); + + return { + userId: identity.id, + profile: profile!, + getIdToken: () => + oktaAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await oktaAuthApi.logout(); + }, + }; +}; + +export const oktaProvider: SignInProvider = { Component, loader }; \ No newline at end of file diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 96dbc30a89..a195b2042a 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -18,6 +18,7 @@ import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { guestProvider } from './guestProvider'; import { googleProvider } from './googleProvider'; import { customProvider } from './customProvider'; +import { oktaProvider } from './oktaProvider'; import { SignInPageProps, SignInResult, @@ -30,12 +31,13 @@ import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; // Separate list here to avoid exporting internal types -export type SignInProviderId = 'guest' | 'google' | 'custom'; +export type SignInProviderId = 'guest' | 'google' | 'custom' | 'okta'; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, google: googleProvider, custom: customProvider, + okta: oktaProvider, }; export const useSignInProviders = ( diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index e2de159ec6..e8245119f3 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -26,3 +26,4 @@ export * from './Sidebar'; export * from './SignInPage'; export * from './TabbedCard'; export * from './HeaderTabs'; +export * from './ItemCard'; diff --git a/packages/dev-utils/src/devApp/apiFactories.ts b/packages/dev-utils/src/devApp/apiFactories.ts index 7ac6aff261..35295b4631 100644 --- a/packages/dev-utils/src/devApp/apiFactories.ts +++ b/packages/dev-utils/src/devApp/apiFactories.ts @@ -28,6 +28,8 @@ import { googleAuthApiRef, GithubAuth, githubAuthApiRef, + GitlabAuth, + gitlabAuthApiRef, } from '@backstage/core'; // TODO(rugvip): We should likely figure out how to reuse all of these between apps @@ -75,3 +77,14 @@ export const githubAuthApiFactory = createApiFactory({ oauthRequestApi, }), }); + +export const gitlabAuthApiFactory = createApiFactory({ + implements: gitlabAuthApiRef, + deps: { oauthRequestApi: oauthRequestApiRef }, + factory: ({ oauthRequestApi }) => + GitlabAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +}); diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 3b61ae7af8..5b00a3c3e0 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -6,11 +6,15 @@ import { OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + gitlabAuthApiRef, + oktaAuthApiRef, AlertApiForwarder, ErrorApiForwarder, ErrorAlerter, GoogleAuth, GithubAuth, + GitlabAuth, + OktaAuth, identityApiRef, } from '@backstage/core'; @@ -50,4 +54,22 @@ builder.add( }), ); +builder.add( + gitlabAuthApiRef, + GitlabAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + +builder.add( + oktaAuthApiRef, + OktaAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + export const apis = builder.build(); diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 501e288754..f0ed4905d5 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -41,7 +41,8 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "react-router": "^6.0.0-alpha.5", - "react-router-dom": "^6.0.0-alpha.5" + "react-router-dom": "^6.0.0-alpha.5", + "zen-observable": "^0.8.15" }, "devDependencies": { "@types/jest": "^25.2.2", diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts new file mode 100644 index 0000000000..668d973830 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.test.ts @@ -0,0 +1,142 @@ +/* + * 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 { MockStorageApi } from './MockStorageApi'; +import { StorageApi } from '@backstage/core-api'; + +describe('WebStorage Storage API', () => { + const createMockStorage = (): StorageApi => { + return MockStorageApi.create(); + }; + + it('should return undefined for values which are unset', async () => { + const storage = createMockStorage(); + + expect(storage.get('myfakekey')).toBeUndefined(); + }); + + it('should allow the setting and getting of the simple data structures', async () => { + const storage = createMockStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + expect(storage.get('myfakekey')).toBe('helloimastring'); + expect(storage.get('mysecondfakekey')).toBe(1234); + expect(storage.get('mythirdfakekey')).toBe(true); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = createMockStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.get('myfakekey')).toEqual(mockData); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createMockStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createMockStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + newValue: undefined, + }); + }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = createMockStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.get(keyName)).not.toBe(secondStorage.get(keyName)); + expect(firstStorage.get(keyName)).toBe('boop'); + expect(secondStorage.get(keyName)).toBe('deerp'); + }); + + it('should not clash with other namesapces when creating buckets', async () => { + const rootStorage = createMockStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.get('deep/test2')).toBe(undefined); + }); +}); diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts new file mode 100644 index 0000000000..a3a4ef16d6 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/MockStorageApi.ts @@ -0,0 +1,90 @@ +/* + * 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 { + Observable, + StorageApi, + storageApiRef, + StorageValueChange, +} from '@backstage/core-api'; +import ObservableImpl from 'zen-observable'; + +export type MockStorageBucket = { [key: string]: any }; + +export class MockStorageApi implements StorageApi { + static factory = { + implements: storageApiRef, + deps: {}, + factory: () => MockStorageApi.create(), + }; + + private readonly namespace: string; + private readonly data: MockStorageBucket; + + private constructor(namespace: string, data?: MockStorageBucket) { + this.namespace = namespace; + this.data = { ...data }; + } + + static create(data?: MockStorageBucket) { + return new MockStorageApi('', data); + } + + forBucket(name: string): StorageApi { + return new MockStorageApi(`${this.namespace}/${name}`, this.data); + } + + get(key: string): T | undefined { + return this.data[this.getKeyName(key)]; + } + + async set(key: string, data: T): Promise { + this.data[this.getKeyName(key)] = data; + this.notifyChanges({ key, newValue: data }); + } + + async remove(key: string): Promise { + delete this.data[this.getKeyName(key)]; + this.notifyChanges({ key, newValue: undefined }); + } + + observe$(key: string): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueChange) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + private readonly observable = new ObservableImpl( + subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }, + ); +} diff --git a/packages/test-utils/src/testUtils/apis/StorageApi/index.ts b/packages/test-utils/src/testUtils/apis/StorageApi/index.ts new file mode 100644 index 0000000000..ec4557b4c7 --- /dev/null +++ b/packages/test-utils/src/testUtils/apis/StorageApi/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 { MockStorageApi } from './MockStorageApi'; +export type { MockStorageBucket } from './MockStorageApi'; diff --git a/packages/test-utils/src/testUtils/apis/index.ts b/packages/test-utils/src/testUtils/apis/index.ts index 55d6d10dd6..88229061de 100644 --- a/packages/test-utils/src/testUtils/apis/index.ts +++ b/packages/test-utils/src/testUtils/apis/index.ts @@ -15,3 +15,4 @@ */ export * from './ErrorApi'; +export * from './StorageApi'; diff --git a/packages/test-utils/src/testUtils/mockApiRegistry.ts b/packages/test-utils/src/testUtils/mockApiRegistry.ts index 96d12a9df2..15733ead88 100644 --- a/packages/test-utils/src/testUtils/mockApiRegistry.ts +++ b/packages/test-utils/src/testUtils/mockApiRegistry.ts @@ -15,12 +15,13 @@ */ import { ApiTestRegistry } from '@backstage/core-api'; -import { MockErrorApi } from './apis'; +import { MockErrorApi, MockStorageApi } from './apis'; export function createMockApiRegistry(): ApiTestRegistry { const registry = new ApiTestRegistry(); registry.register(MockErrorApi.factory); + registry.register(MockStorageApi.factory); return registry; } diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 955b182ae9..dc68db11d6 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -118,9 +118,12 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { verticalAlign: 'middle', lineHeight: '1', margin: 0, - padding: '8px', + padding: theme.spacing(3, 2, 3, 2.5), borderBottom: 0, }, + sizeSmall: { + padding: theme.spacing(1, 2, 1, 2.5), + }, head: { wordBreak: 'break-word', overflow: 'hidden', diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md index 147853f06f..ff0b1c7a3d 100644 --- a/plugins/auth-backend/README.md +++ b/plugins/auth-backend/README.md @@ -7,17 +7,46 @@ This is the backend part of the auth plugin. It responds to auth requests from the frontend, and fulfills them by delegating to the appropriate provider in the backend. -## Requirements - -Needs AUTH_GOOGLE_CLIENT_ID and AUTH_GOOGLE_CLIENT_SECRET set in the environment for the backend to startup - ## Local development -export AUTH_GOOGLE_CLIENT_ID= -read -r AUTH_GOOGLE_CLIENT_SECRET - -export AUTH_GOOGLE_CLIENT_SECRET -run `yarn start` in packages/backend folder +Choose your OAuth Providers, replace `x` with actual value and then start backend: +Example for Google Oauth Provider at root directory: + +```bash +export AUTH_GOOGLE_CLIENT_ID=x +export AUTH_GOOGLE_CLIENT_SECRET=x +yarn --cwd packages/backend start +``` + +### Google + +```bash +export AUTH_GOOGLE_CLIENT_ID=x +export AUTH_GOOGLE_CLIENT_SECRET=x +``` + +### Github + +```bash +export AUTH_GITHUB_CLIENT_ID=x +export AUTH_GITHUB_CLIENT_SECRET=x +``` + +### Gitlab + +```bash +export GITLAB_BASE_URL=x # default is https://gitlab.com +export AUTH_GITLAB_CLIENT_ID=x +export AUTH_GITLAB_CLIENT_SECRET=x +``` + +### Okta + +```bash +export AUTH_OKTA_AUDIENCE=x +export AUTH_OKTA_CLIENT_ID=x +export AUTH_OKTA_CLIENT_SECRET=x +``` ### SAML diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 2de098c7eb..d756704a62 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -39,7 +39,10 @@ "morgan": "^1.10.0", "passport": "^0.4.1", "passport-github2": "^0.1.12", + "passport-gitlab2": "^5.0.0", "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.5.0", + "passport-okta-oauth": "^0.0.1", "passport-saml": "^1.3.3", "uuid": "^8.0.0", "winston": "^3.2.1", @@ -57,6 +60,7 @@ "jest-fetch-mock": "^3.0.3" }, "files": [ - "dist" + "dist", + "migrations" ] } diff --git a/plugins/auth-backend/src/lib/OAuthProvider.ts b/plugins/auth-backend/src/lib/OAuthProvider.ts index b7179659f9..5434b6a4ba 100644 --- a/plugins/auth-backend/src/lib/OAuthProvider.ts +++ b/plugins/auth-backend/src/lib/OAuthProvider.ts @@ -33,6 +33,7 @@ export type Options = { providerId: string; secure: boolean; disableRefresh?: boolean; + persistScopes?: boolean; baseUrl: string; appOrigin: string; tokenIssuer: TokenIssuer; @@ -105,6 +106,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers { throw new InputError('missing scope parameter'); } + if (this.options.persistScopes) { + this.setScopesCookie(res, scope); + } + const nonce = crypto.randomBytes(16).toString('base64'); // set a nonce cookie before redirecting to oauth provider this.setNonceCookie(res, nonce); @@ -137,6 +142,14 @@ export class OAuthProvider implements AuthProviderRouteHandlers { req, ); + if (this.options.persistScopes) { + const grantedScopes = this.getScopesFromCookie( + req, + this.options.providerId, + ); + response.providerInfo.scope = grantedScopes; + } + if (!this.options.disableRefresh) { // throw error if missing refresh token if (!refreshToken) { @@ -241,6 +254,21 @@ export class OAuthProvider implements AuthProviderRouteHandlers { }); }; + private setScopesCookie = (res: express.Response, scope: string) => { + res.cookie(`${this.options.providerId}-scope`, scope, { + maxAge: TEN_MINUTES_MS, + secure: this.options.secure, + sameSite: 'none', + domain: this.domain, + path: `${this.basePath}/${this.options.providerId}/handler`, + httpOnly: true, + }); + }; + + private getScopesFromCookie = (req: express.Request, providerId: string) => { + return req.cookies[`${providerId}-scope`]; + }; + private setRefreshTokenCookie = ( res: express.Response, refreshToken: string, diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts index 1167946560..4cc0de4444 100644 --- a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts @@ -137,7 +137,7 @@ export const executeRefreshTokenStrategy = async ( params: any, ) => { if (err) { - reject(new Error(`Failed to refresh access token ${err}`)); + reject(new Error(`Failed to refresh access token ${err.toString()}`)); } if (!accessToken) { reject( diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 5096b0698a..7fa375bb6b 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -17,7 +17,9 @@ import Router from 'express-promise-router'; import { createGithubProvider } from './github'; import { createGoogleProvider } from './google'; +import { createGitlabProvider } from './gitlab'; import { createSamlProvider } from './saml'; +import { createOktaProvider } from './okta'; import { AuthProviderFactory, AuthProviderConfig } from './types'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; @@ -25,7 +27,9 @@ import { TokenIssuer } from '../identity'; const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, github: createGithubProvider, + gitlab: createGitlabProvider, saml: createSamlProvider, + okta: createOktaProvider, }; export const createAuthProviderRouter = ( diff --git a/plugins/auth-backend/src/providers/github/provider.ts b/plugins/auth-backend/src/providers/github/provider.ts index 01652a6d7e..ec444733ba 100644 --- a/plugins/auth-backend/src/providers/github/provider.ts +++ b/plugins/auth-backend/src/providers/github/provider.ts @@ -115,6 +115,7 @@ export function createGithubProvider( envProviders[env] = new OAuthProvider(new GithubAuthProvider(opts), { disableRefresh: true, + persistScopes: true, providerId: 'github', secure, baseUrl, diff --git a/plugins/auth-backend/src/providers/gitlab/index.ts b/plugins/auth-backend/src/providers/gitlab/index.ts new file mode 100644 index 0000000000..7fba2dd95c --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/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 { createGitlabProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.test.ts b/plugins/auth-backend/src/providers/gitlab/provider.test.ts new file mode 100644 index 0000000000..5eec6c47f9 --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/provider.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { GitlabAuthProvider } from './provider'; + +describe('GitlabAuthProvider', () => { + it('should transform to type OAuthResponse', () => { + const tests = [ + { + arguments: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + rawProfile: { + id: 'uid-123', + username: 'jimmymarkum', + provider: 'gitlab', + displayName: 'Jimmy Markum', + emails: [ + { + value: 'jimmymarkum@gmail.com', + }, + ], + avatarUrl: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + params: { + scope: 'user_read write_repository', + expires_in: 100, + }, + }, + expect: { + providerInfo: { + accessToken: '19xasczxcm9n7gacn9jdgm19me', + expiresInSeconds: 100, + scope: 'user_read write_repository', + }, + profile: { + email: 'jimmymarkum@gmail.com', + displayName: 'Jimmy Markum', + picture: + 'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg', + }, + }, + }, + { + arguments: { + accessToken: + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + rawProfile: { + id: 'ipd12039', + username: 'daveboyle', + provider: 'gitlab', + displayName: 'Dave Boyle', + emails: [ + { + value: 'daveboyle@gitlab.org', + }, + ], + }, + params: { + scope: 'read_repository', + }, + }, + expect: { + providerInfo: { + accessToken: + 'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe', + scope: 'read_repository', + }, + profile: { + displayName: 'Dave Boyle', + email: 'daveboyle@gitlab.org', + }, + }, + }, + ]; + + for (const test of tests) { + expect( + GitlabAuthProvider.transformOAuthResponse( + test.arguments.accessToken, + test.arguments.rawProfile, + test.arguments.params, + ), + ).toEqual(test.expect); + } + }); +}); diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts new file mode 100644 index 0000000000..890fbb3531 --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -0,0 +1,176 @@ +/* + * 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 express from 'express'; +import { Strategy as GitlabStrategy } from 'passport-gitlab2'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + makeProfileInfo, +} from '../../lib/PassportStrategyHelper'; +import { + OAuthProviderHandlers, + AuthProviderConfig, + RedirectInfo, + EnvironmentProviderConfig, + OAuthProviderOptions, + OAuthProviderConfig, + OAuthResponse, + PassportDoneCallback, +} from '../types'; +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { + EnvironmentHandlers, + EnvironmentHandler, +} from '../../lib/EnvironmentHandler'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../../identity'; +import passport from 'passport'; + +export class GitlabAuthProvider implements OAuthProviderHandlers { + private readonly _strategy: GitlabStrategy; + + static transformPassportProfile(rawProfile: any): passport.Profile { + const profile: passport.Profile = { + id: rawProfile.id, + username: rawProfile.username, + provider: rawProfile.provider, + displayName: rawProfile.displayName, + }; + + if (rawProfile.emails && rawProfile.emails.length > 0) { + profile.emails = rawProfile.emails; + } + if (rawProfile.avatarUrl) { + profile.photos = [{ value: rawProfile.avatarUrl }]; + } + + return profile; + } + + static transformOAuthResponse( + accessToken: string, + rawProfile: any, + params: any = {}, + ): OAuthResponse { + const passportProfile = GitlabAuthProvider.transformPassportProfile( + rawProfile, + ); + + const profile = makeProfileInfo(passportProfile, params.id_token); + const providerInfo = { + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + idToken: params.id_token, + }; + + if (params.expires_in) { + providerInfo.expiresInSeconds = params.expires_in; + } + if (params.id_token) { + providerInfo.idToken = params.id_token; + } + return { + providerInfo, + profile, + }; + } + + constructor(options: OAuthProviderOptions) { + this._strategy = new GitlabStrategy( + { ...options }, + ( + accessToken: any, + _: any, + params: any, + rawProfile: any, + done: PassportDoneCallback, + ) => { + const oauthResponse = GitlabAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ); + done(undefined, oauthResponse); + }, + ); + } + + async start( + req: express.Request, + options: Record, + ): Promise { + return await executeRedirectStrategy(req, this._strategy, options); + } + + async handler(req: express.Request): Promise<{ response: OAuthResponse }> { + return await executeFrameHandlerStrategy( + req, + this._strategy, + ); + } +} + +export function createGitlabProvider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const { + secure, + appOrigin, + clientId, + clientSecret, + audience, + } = (envConfig as unknown) as OAuthProviderConfig; + const callbackURLParam = `?env=${env}`; + + const opts = { + clientID: clientId, + clientSecret: clientSecret, + callbackURL: `${baseUrl}/gitlab/handler/frame${callbackURLParam}`, + baseURL: audience, + }; + + if (!opts.clientID || !opts.clientSecret) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars', + ); + } + + logger.warn( + 'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), { + disableRefresh: true, + providerId: 'gitlab', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/auth-backend/src/providers/gitlab/types.d.ts b/plugins/auth-backend/src/providers/gitlab/types.d.ts new file mode 100644 index 0000000000..8cd7373c52 --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/types.d.ts @@ -0,0 +1,25 @@ +/* + * 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. + */ + +declare module 'passport-gitlab2' { + import { Request } from 'express'; + import { StrategyCreated } from 'passport'; + + export class Strategy { + constructor(options: any, verify: any); + authenticate(this: StrategyCreated, req: Request, options?: any): any; + } +} diff --git a/plugins/auth-backend/src/providers/okta/index.ts b/plugins/auth-backend/src/providers/okta/index.ts new file mode 100644 index 0000000000..bc32601ac2 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { createOktaProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts new file mode 100644 index 0000000000..e73843bb0c --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -0,0 +1,213 @@ +/* + * 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 express from 'express'; +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { Strategy as OktaStrategy } from 'passport-okta-oauth'; +import passport from 'passport'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + executeFetchUserProfileStrategy, +} from '../../lib/PassportStrategyHelper'; +import { + OAuthProviderHandlers, + RedirectInfo, + AuthProviderConfig, + EnvironmentProviderConfig, + OAuthProviderOptions, + OAuthProviderConfig, + OAuthResponse, + PassportDoneCallback, +} from '../types'; +import { + EnvironmentHandler, + EnvironmentHandlers, +} from '../../lib/EnvironmentHandler'; +import { Logger } from 'winston'; +import { StateStore } from 'passport-oauth2'; +import { TokenIssuer } from '../../identity'; + +type PrivateInfo = { + refreshToken: string; +}; + +export class OktaAuthProvider implements OAuthProviderHandlers { + + private readonly _strategy: any; + + /** + * Due to passport-okta-oauth forcing options.state = true, + * passport-oauth2 requires express-session to be installed + * so that the 'state' parameter of the oauth2 flow can be stored. + * This implementation of StateStore matches the NullStore found within + * passport-oauth2, which is the StateStore implementation used when options.state = false, + * allowing us to avoid using express-session in order to integrate with Okta. + */ + private _store: StateStore = { + store(_req: express.Request, cb: any) { + cb(null, null); + }, + verify(_req: express.Request, _state: string, cb: any) { + cb(null, true); + }, + } + + constructor(options: OAuthProviderOptions) { + this._strategy = new OktaStrategy({ + passReqToCallback: false as true, + ...options, + store: this._store, + response_type: 'code', + }, ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); + + done( + undefined, + { + providerInfo: { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + profile, + }, + { + refreshToken, + }, + ) + }); + } + + async start( + req: express.Request, + options: Record + ): Promise { + const providerOptions = { + ...options, + accessType: 'offline', + prompt: 'consent', + }; + return await executeRedirectStrategy(req, this._strategy, providerOptions); + } + + async handler( + req: express.Request + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(refreshToken: string, scope: string): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + refreshToken, + scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + + } + + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + // TODO(Rugvip): Hardcoded to the local part of the email for now + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export function createOktaProvider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const config = (envConfig as unknown) as OAuthProviderConfig; + const { secure, appOrigin } = config; + const callbackURLParam = `?env=${env}`; + const opts = { + audience: config.audience, + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${baseUrl}/okta/handler/frame${callbackURLParam}`, + }; + + if (!opts.clientID || !opts.clientSecret || !opts.audience) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', + ); + } + + logger.warn( + 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new OktaAuthProvider(opts), { + disableRefresh: false, + providerId: 'okta', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/auth-backend/src/providers/okta/types.d.ts b/plugins/auth-backend/src/providers/okta/types.d.ts new file mode 100644 index 0000000000..bed6d24043 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/types.d.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. + */ +declare module 'passport-okta-oauth' { + + export class Strategy { + constructor(options: any, verify: any) + } +} + \ No newline at end of file diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 56d4db79fa..aec4f9dfd0 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -55,6 +55,10 @@ export type OAuthProviderConfig = { * Client Secret of the auth provider. */ clientSecret: string; + /** + * The location of the OAuth Authorization Server + */ + audience?: string; }; export type EnvironmentProviderConfig = { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 28718e85a1..fb533a4bdd 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -76,12 +76,30 @@ export async function createRouter( clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!, }, }, + gitlab: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_GITLAB_CLIENT_ID!, + clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!, + audience: process.env.GITLAB_BASE_URL! || 'https://gitlab.com', + }, + }, saml: { development: { entryPoint: 'http://localhost:7001/', issuer: 'passport-saml', }, }, + okta: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_OKTA_CLIENT_ID!, + clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!, + audience: process.env.AUTH_OKTA_AUDIENCE, + }, + }, }, }, }; diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data index 01675db363..76f97ccf3a 100755 --- a/plugins/catalog-backend/scripts/mock-data +++ b/plugins/catalog-backend/scripts/mock-data @@ -15,4 +15,5 @@ for URL in \ --request POST 'localhost:7000/catalog/locations' \ --header 'Content-Type: application/json' \ --data-raw "{\"type\": \"github\", \"target\": \"https://github.com/spotify/backstage/blob/master/packages/catalog-model/examples/${URL}\"}" + echo done diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index cc4cc62fdb..a98eec0875 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -14,195 +14,248 @@ * limitations under the License. */ +import { Entity } from '@backstage/catalog-model'; +import { + ApiProvider, + ApiRegistry, + IdentityApi, + identityApiRef, + storageApiRef, +} from '@backstage/core'; +import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter'; -import { EntityGroup } from '../../data/filters'; +import { CatalogApi, catalogApiRef } from '../../api/types'; +import { EntityFilterGroupsProvider } from '../../filter'; +import { ButtonGroup, CatalogFilter } from './CatalogFilter'; describe('Catalog Filter', () => { - const comp1 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-1', - }, - spec: { - owner: 'team', - }, + const catalogApi: Partial = { + getEntities: () => + Promise.resolve([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity1', + }, + spec: { + owner: 'tools@example.com', + type: 'service', + }, + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'Entity2', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + }, + }, + ] as Entity[]), }; - const comp2 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-2', - }, - spec: { - owner: 'team', - }, + + const indentityApi: Partial = { + getUserId: () => 'tools@example.com', }; - const comp3 = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', - metadata: { - name: 'my-component-3', - }, - spec: { - owner: '', - }, - }; - const defaultFilterProps = { - selectedFilter: EntityGroup.ALL, - onFilterChange: (type: EntityGroup) => type, - entitiesByFilter: { - [EntityGroup.ALL]: [comp1, comp2, comp3], - [EntityGroup.STARRED]: [comp1], - [EntityGroup.OWNED]: [comp1], - }, - }; - it('should render the different groups', async () => { - const mockGroups: CatalogFilterGroup[] = [ - { name: 'Test Group 1', items: [] }, - { name: 'Test Group 2', items: [] }, - ]; - const { findByText } = render( + + const renderWrapped = (children: React.ReactNode) => + render( wrapInTestApp( - , + + {children}, + , ), ); + it('should render the different groups', async () => { + const mockGroups: ButtonGroup[] = [ + { name: 'Test Group 1', items: [] }, + { name: 'Test Group 2', items: [] }, + ]; + const { findByText } = renderWrapped( + , + ); for (const group of mockGroups) { expect(await findByText(group.name)).toBeInTheDocument(); } }); it('should render the different items and their names', async () => { - const mockGroups: CatalogFilterGroup[] = [ + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', + filterFn: () => false, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + const { findByText } = renderWrapped( + , ); - const [group] = mockGroups; - for (const item of group.items) { + for (const item of mockGroups[0].items) { expect(await findByText(item.label)).toBeInTheDocument(); } }); - it('should render the count in each item', async () => { - const mockGroups: CatalogFilterGroup[] = [ + it('selects the first item if no desired initial one is set', async () => { + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', - count: 3, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 1, + filterFn: () => false, }, ], }, ]; - const { getAllByText } = render( - wrapInTestApp( - , - ), + const onChange = jest.fn(); + + renderWrapped( + , ); - for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) { - const matcher = new RegExp( - `(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`, - ); - const items = await getAllByText(matcher); - items.forEach(el => expect(el).toBeInTheDocument()); - } + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); }); - it('should fire the callback when an item is clicked', async () => { - const mockGroups: CatalogFilterGroup[] = [ + it('selects the initial item', async () => { + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', - count: 100, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 400, + filterFn: () => false, }, ], }, ]; - const onSelectedChangeHandler = jest.fn(); + const onChange = jest.fn(); - const { findByText } = render( - wrapInTestApp( - , - ), + renderWrapped( + , ); - const item = mockGroups[0].items[0]; - - const element = await findByText(item.label); - - fireEvent.click(element); - - expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); }); - it('should render a component when a function is passed to the count component', async () => { - const mockGroups: CatalogFilterGroup[] = [ + it('can change the selected item', async () => { + const mockGroups: ButtonGroup[] = [ { name: 'Test Group 1', items: [ { - id: EntityGroup.ALL, + id: 'all', label: 'First Label', - count: () => BACKSTAGE!, + filterFn: () => true, }, { - id: EntityGroup.STARRED, + id: 'starred', label: 'Second Label', - count: 400, + filterFn: () => false, }, ], }, ]; - const { findByText } = render( - wrapInTestApp( - , - ), + + const onChange = jest.fn(); + + const { findByText } = renderWrapped( + , ); - expect(await findByText('Test Group 1')).toBeInTheDocument(); + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'all', + label: 'First Label', + }); + }); + + fireEvent.click(await findByText('Second Label')); + + await waitFor(() => { + expect(onChange).toHaveBeenLastCalledWith({ + id: 'starred', + label: 'Second Label', + }); + }); + }); + + it('displays match counts properly', async () => { + const mockGroups: ButtonGroup[] = [ + { + name: 'Test Group 1', + items: [ + { + id: 'owned', + label: 'First Label', + filterFn: entity => entity.spec?.owner === 'tools@example.com', + }, + ], + }, + ]; + + const { findByText } = renderWrapped( + , + ); + + expect(await findByText('1')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 61a83fccd8..90a1e15ad0 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -14,31 +14,36 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import { Entity } from '@backstage/catalog-model'; +import { IconComponent } from '@backstage/core'; import { Card, List, ListItemIcon, + ListItemSecondaryAction, ListItemText, - MenuItem, - Typography, - Theme, makeStyles, + MenuItem, + Theme, + Typography, } from '@material-ui/core'; -import type { IconComponent } from '@backstage/core'; -import { EntityGroup } from '../../data/filters'; -import { EntitiesByFilter } from '../../hooks/useEntities'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; -export type CatalogFilterItem = { - id: EntityGroup; - label: string; - icon?: IconComponent; - count?: number | FC; -}; - -export type CatalogFilterGroup = { +export type ButtonGroup = { name: string; - items: CatalogFilterItem[]; + items: { + id: string; + label: string; + icon?: IconComponent; + filterFn: (entity: Entity) => boolean; + }[]; }; const useStyles = makeStyles(theme => ({ @@ -67,21 +72,56 @@ const useStyles = makeStyles(theme => ({ }, })); -export const CatalogFilter: FC<{ - selectedFilter: EntityGroup; - onFilterChange: (type: EntityGroup) => void; - entitiesByFilter: EntitiesByFilter; - groups: CatalogFilterGroup[]; -}> = ({ - selectedFilter: selectedId, - onFilterChange: setSelectedFilter, - entitiesByFilter, - groups, -}) => { +type OnChangeCallback = (item: { id: string; label: string }) => void; + +type Props = { + buttonGroups: ButtonGroup[]; + initiallySelected: string; + onChange?: OnChangeCallback; +}; + +/** + * The main filter group in the sidebar, toggling owned/starred/all. + */ +export const CatalogFilter = ({ + buttonGroups, + onChange, + initiallySelected, +}: Props) => { const classes = useStyles(); + const { currentFilter, setCurrentFilter, getFilterCount } = useFilter( + buttonGroups, + initiallySelected, + ); + + const onChangeRef = useRef(); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const setCurrent = useCallback( + (item: { id: string; label: string }) => { + setCurrentFilter(item.id); + onChangeRef.current?.({ id: item.id, label: item.label }); + }, + [setCurrentFilter], + ); + + // Make one initial onChange to inform the surroundings about the selected + // item + useEffect(() => { + const items = buttonGroups.flatMap(g => g.items); + const item = items.find(i => i.id === initiallySelected) || items[0]; + if (item) { + onChangeRef.current?.({ id: item.id, label: item.label }); + } + // intentionally only happens on startup + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( - {groups.map(group => ( + {buttonGroups.map(group => ( {group.name} @@ -93,10 +133,8 @@ export const CatalogFilter: FC<{ key={item.id} button divider - onClick={() => { - setSelectedFilter(item.id); - }} - selected={item.id === selectedId} + onClick={() => setCurrent(item)} + selected={item.id === currentFilter} className={classes.menuItem} > {item.icon && ( @@ -109,7 +147,9 @@ export const CatalogFilter: FC<{ {item.label} - {entitiesByFilter[item.id]?.length ?? '-'} + + {getFilterCount(item.id) ?? '-'} + ))} @@ -119,3 +159,53 @@ export const CatalogFilter: FC<{ ); }; + +function useFilter( + buttonGroups: ButtonGroup[], + initiallySelected: string, +): { + currentFilter: string; + setCurrentFilter: (filterId: string) => void; + getFilterCount: (filterId: string) => number | undefined; +} { + const [currentFilter, setCurrentFilter] = useState(initiallySelected); + + const filterGroup = useMemo( + () => ({ + filters: Object.fromEntries( + buttonGroups.flatMap(g => g.items).map(i => [i.id, i.filterFn]), + ), + }), + [buttonGroups], + ); + + const { setSelectedFilters, state } = useEntityFilterGroup( + 'primary-sidebar', + filterGroup, + [initiallySelected], + ); + + const setCurrent = useCallback( + (filterId: string) => { + setCurrentFilter(filterId); + setSelectedFilters([filterId]); + }, + [setCurrentFilter, setSelectedFilters], + ); + + const getFilterCount = useCallback( + (filterId: string) => { + if (state.type !== 'ready') { + return undefined; + } + return state.state.filters[filterId].matchCount; + }, + [state], + ); + + return { + currentFilter, + setCurrentFilter: setCurrent, + getFilterCount, + }; +} diff --git a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx index 65715e04c7..39b3c66013 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogLayout.tsx @@ -14,26 +14,29 @@ * limitations under the License. */ -import React, { FC } from 'react'; import { Header, HomepageTimer, + identityApiRef, Page, pageTheme, - identityApiRef, useApi, } from '@backstage/core'; +import React from 'react'; import { getTimeBasedGreeting } from './utils/timeUtil'; -const CatalogLayout: FC<{}> = props => { - const { children } = props; +type Props = { + children?: React.ReactNode; +}; + +const CatalogLayout = ({ children }: Props) => { const greeting = getTimeBasedGreeting(); - const identityApi = useApi(identityApiRef); + const userId = useApi(identityApiRef).getUserId(); return (
{ - const mockErrorApi = new MockErrorApi(); const catalogApi: Partial = { getEntities: () => Promise.resolve([ { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { name: 'Entity1', }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', spec: { owner: 'tools@example.com', type: 'service', }, }, { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', metadata: { name: 'Entity2', }, - apiVersion: 'backstage.io/v1alpha1', - kind: 'Component', spec: { owner: 'not-tools@example.com', type: 'service', @@ -62,49 +60,32 @@ describe('CatalogPage', () => { getLocationByEntity: () => Promise.resolve({ id: 'id', type: 'github', target: 'url' }), }; - const mockIndentityApi: Partial = { + const indentityApi: Partial = { getUserId: () => 'tools@example.com', }; + const renderWrapped = (children: React.ReactNode) => + render( + wrapInTestApp( + + {children}, + , + ), + ); + // 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 { findByText } = render( - wrapInTestApp( - - - , - ), - ); - - const items = await findByText(/All Services \(2\)/); - expect(items).toBeInTheDocument(); - }); - it('should filter by owner', async () => { - const { findByText, getByText } = render( - wrapInTestApp( - - - , - ), - ); - fireEvent.click(getByText(/Owned/)); - const items = await findByText(/Owned \(1\)/); - expect(items).toBeInTheDocument(); + const { findByText, getByText } = renderWrapped(); + expect(await findByText(/Owned \(1\)/)).toBeInTheDocument(); + fireEvent.click(getByText(/All/)); + expect(await findByText(/All \(2\)/)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index ef77cf573f..e59b753363 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,39 +14,26 @@ * limitations under the License. */ -import { Entity, LocationSpec } from '@backstage/catalog-model'; import { Content, ContentHeader, - DismissableBanner, - HeaderTabs, + identityApiRef, SupportButton, + useApi, } from '@backstage/core'; -import CatalogLayout from './CatalogLayout'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; -import { - Button, - Link, - makeStyles, - Typography, - withStyles, -} from '@material-ui/core'; -import Edit from '@material-ui/icons/Edit'; -import GitHub from '@material-ui/icons/GitHub'; -import Star from '@material-ui/icons/Star'; -import StarOutline from '@material-ui/icons/StarBorder'; -import React, { FC } from 'react'; +import { Button, makeStyles } from '@material-ui/core'; +import SettingsIcon from '@material-ui/icons/Settings'; +import StarIcon from '@material-ui/icons/Star'; +import React, { useMemo, useState } from 'react'; import { Link as RouterLink } from 'react-router-dom'; -import { CatalogFilter } from '../CatalogFilter/CatalogFilter'; +import { EntityFilterGroupsProvider, useFilteredEntities } from '../../filter'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; +import { CatalogFilter, ButtonGroup } from '../CatalogFilter/CatalogFilter'; import { CatalogTable } from '../CatalogTable/CatalogTable'; -import { useEntities } from '../../hooks/useEntities'; -import { findLocationForEntityMeta } from '../../data/utils'; -import { - getCatalogFilterItemByType, - EntityGroup, - filterGroups, - labeledEntityTypes, -} from '../../data/filters'; +import CatalogLayout from './CatalogLayout'; +import { CatalogTabs, LabeledComponentType } from './CatalogTabs'; +import { WelcomeBanner } from './WelcomeBanner'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -55,138 +42,116 @@ const useStyles = makeStyles(theme => ({ gridTemplateColumns: '250px 1fr', gridColumnGap: theme.spacing(2), }, - emoji: { - fontSize: '125%', - marginRight: theme.spacing(2), - }, })); -export const CatalogPage: FC<{}> = () => { - const { - entitiesByFilter, - error, - loading, - selectedFilter, - setSelectedFilter, - toggleStarredEntity, - isStarredEntity, - selectTypeFilter, - } = useEntities(); - - const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL]; - +const CatalogPageContents = () => { const styles = useStyles(); + const { loading, error, matchingEntities } = useFilteredEntities(); + const { isStarredEntity } = useStarredEntities(); + const userId = useApi(identityApiRef).getUserId(); + const [selectedTab, setSelectedTab] = useState(); + const [selectedSidebarItem, setSelectedSidebarItem] = useState(); - const YellowStar = withStyles({ - root: { - color: '#f3ba37', - }, - })(Star); + const tabs = useMemo( + () => [ + { + id: 'service', + label: 'Services', + }, + { + id: 'website', + label: 'Websites', + }, + { + id: 'library', + label: 'Libraries', + }, + { + id: 'documentation', + label: 'Documentation', + }, + { + id: 'other', + label: 'Other', + }, + ], + [], + ); - const actions = [ - (rowData: Entity) => { - const location = findLocationForEntityMeta(rowData.metadata); - return { - icon: GitHub, - tooltip: 'View on GitHub', - onClick: () => { - if (!location) return; - window.open(location.target, '_blank'); - }, - hidden: location?.type !== 'github', - }; - }, - (rowData: Entity) => { - const createEditLink = (location: LocationSpec): string => { - switch (location.type) { - case 'github': - return location.target.replace('/blob/', '/edit/'); - default: - return location.target; - } - }; - - const location = findLocationForEntityMeta(rowData.metadata); - - return { - icon: Edit, - tooltip: 'Edit', - iconProps: { size: 'small' }, - onClick: () => { - if (!location) return; - window.open(createEditLink(location), '_blank'); - }, - hidden: location?.type !== 'github', - }; - }, - (rowData: Entity) => { - const isStarred = isStarredEntity(rowData); - return { - icon: isStarred ? YellowStar : StarOutline, - tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites', - onClick: () => toggleStarredEntity(rowData), - }; - }, - ]; + const filterGroups = useMemo( + () => [ + { + name: 'Personal', + items: [ + { + id: 'owned', + label: 'Owned', + icon: SettingsIcon, + filterFn: entity => entity.spec?.owner === userId, + }, + { + id: 'starred', + label: 'Starred', + icon: StarIcon, + filterFn: isStarredEntity, + }, + ], + }, + { + name: 'Company', // TODO: Replace with Company name, read from app config. + items: [ + { + id: 'all', + label: 'All', + filterFn: () => true, + }, + ], + }, + ], + [isStarredEntity, userId], + ); return ( - { - selectTypeFilter(labeledEntityTypes[index as number].id); - }} + setSelectedTab(label)} /> - - - 👋🏼 - - Welcome to Backstage, we are happy to have you. Start by checking - out our{' '} - - getting started - {' '} - page. - - } - id="catalog_page_welcome_banner" - /> - + + All your software catalog entities
setSelectedSidebarItem(label)} + initiallySelected="owned" />
); }; + +export const CatalogPage = () => ( + + + +); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx new file mode 100644 index 0000000000..ae1e2a4e5a --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/CatalogTabs.tsx @@ -0,0 +1,85 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { HeaderTabs } from '@backstage/core'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { FilterGroup, useEntityFilterGroup } from '../../filter'; + +/** + * A component type, and a human readable label for it. + */ +export type LabeledComponentType = { + id: string; + label: string; +}; + +/** + * Called on mount, and when the selected tab changes. + */ +export type OnChangeCallback = (tab: LabeledComponentType) => void; + +type Props = { + tabs: LabeledComponentType[]; + onChange?: OnChangeCallback; +}; + +/** + * The tabs at the top of the catalog list page, for component type filtering. + */ +export const CatalogTabs = ({ tabs, onChange }: Props) => { + const filterGroup = useMemo(() => { + return { + filters: Object.fromEntries( + tabs.map(t => [t.id, (entity: Entity) => entity.spec?.type === t.id]), + ), + }; + }, [tabs]); + + const { setSelectedFilters } = useEntityFilterGroup('type', filterGroup, [ + tabs[0].id, + ]); + + const [currentTabIndex, setCurrentTabIndex] = useState(0); + + // Hold a reference to the callback + const onChangeRef = useRef(); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + useEffect(() => { + onChangeRef.current?.(tabs[currentTabIndex]); + }, [tabs, currentTabIndex]); + + const switchTab = useCallback( + (index: number) => { + const tab = tabs[index]; + setSelectedFilters([tab.id]); + setCurrentTabIndex(index); + onChangeRef.current?.(tab); + }, + [tabs, setSelectedFilters], + ); + + return ; +}; diff --git a/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx new file mode 100644 index 0000000000..ab486081b1 --- /dev/null +++ b/plugins/catalog/src/components/CatalogPage/WelcomeBanner.tsx @@ -0,0 +1,55 @@ +/* + * 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 { DismissableBanner } from '@backstage/core'; +import { Link, makeStyles, Typography } from '@material-ui/core'; +import React from 'react'; + +const useStyles = makeStyles(theme => ({ + contentWrapper: { + display: 'grid', + gridTemplateAreas: "'filters' 'table'", + gridTemplateColumns: '250px 1fr', + gridColumnGap: theme.spacing(2), + }, + emoji: { + fontSize: '125%', + marginRight: theme.spacing(2), + }, +})); + +export const WelcomeBanner = () => { + const classes = useStyles(); + return ( + + + 👋🏼 + + Welcome to Backstage, we are happy to have you. Start by checking out + our{' '} + + getting started + {' '} + page. + + } + id="catalog_page_welcome_banner" + /> + ); +}; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index b50da60a50..707213f3bb 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -66,11 +66,9 @@ describe('CatalogTable component', () => { />, ), ); - expect( - await rendered.findByText(`Owned (${entites.length})`), - ).toBeInTheDocument(); - expect(await rendered.findByText('component1')).toBeInTheDocument(); - expect(await rendered.findByText('component2')).toBeInTheDocument(); - expect(await rendered.findByText('component3')).toBeInTheDocument(); + expect(rendered.getByText(/Owned \(3\)/)).toBeInTheDocument(); + expect(rendered.getByText(/component1/)).toBeInTheDocument(); + expect(rendered.getByText(/component2/)).toBeInTheDocument(); + expect(rendered.getByText(/component3/)).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index f62749ee17..b507b96f05 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,16 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import { Entity } from '@backstage/catalog-model'; -import { Table, TableColumn } from '@backstage/core'; +import { Entity, LocationSpec } from '@backstage/catalog-model'; +import { Table, TableColumn, TableProps } from '@backstage/core'; import { Link } from '@material-ui/core'; +import Edit from '@material-ui/icons/Edit'; +import GitHub from '@material-ui/icons/GitHub'; +import Star from '@material-ui/icons/Star'; +import StarOutline from '@material-ui/icons/StarBorder'; import { Alert } from '@material-ui/lab'; -import React, { FC } from 'react'; +import React from 'react'; import { generatePath, Link as RouterLink } from 'react-router-dom'; +import { findLocationForEntityMeta } from '../../data/utils'; +import { useStarredEntities } from '../../hooks/useStarredEntites'; import { entityRoute } from '../../routes'; -const columns: TableColumn[] = [ +const columns: TableColumn[] = [ { title: 'Name', field: 'metadata.name', @@ -63,16 +68,16 @@ type CatalogTableProps = { titlePreamble: string; loading: boolean; error?: any; - actions?: any; }; -export const CatalogTable: FC = ({ +export const CatalogTable = ({ entities, loading, error, titlePreamble, - actions, -}) => { +}: CatalogTableProps) => { + const { isStarredEntity, toggleStarredEntity } = useStarredEntities(); + if (error) { return (
@@ -83,8 +88,57 @@ export const CatalogTable: FC = ({ ); } + const actions: TableProps['actions'] = [ + (rowData: Entity) => { + const location = findLocationForEntityMeta(rowData.metadata); + return { + icon: () => , + tooltip: 'View on GitHub', + onClick: () => { + if (!location) return; + window.open(location.target, '_blank'); + }, + hidden: location?.type !== 'github', + }; + }, + (rowData: Entity) => { + const createEditLink = (location: LocationSpec): string => { + switch (location.type) { + case 'github': + return location.target.replace('/blob/', '/edit/'); + default: + return location.target; + } + }; + const location = findLocationForEntityMeta(rowData.metadata); + return { + icon: () => , + tooltip: 'Edit', + onClick: () => { + if (!location) return; + window.open(createEditLink(location), '_blank'); + }, + hidden: location?.type !== 'github', + }; + }, + (rowData: Entity) => { + const isStarred = isStarredEntity(rowData); + return { + cellStyle: { paddingLeft: '1em' }, + icon: () => + isStarred ? ( + + ) : ( + + ), + tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites', + onClick: () => toggleStarredEntity(rowData), + }; + }, + ]; + return ( -
isLoading={loading} columns={columns} options={{ diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx index e6064f8af1..1b966c0bde 100644 --- a/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx @@ -27,7 +27,7 @@ jest.mock('react-router-dom', () => { import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; import { Entity } from '@backstage/catalog-model'; import { wrapInTestApp } from '@backstage/test-utils'; -import { render, wait } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import * as React from 'react'; import { CatalogApi, catalogApiRef } from '../../api/types'; import { EntityPage, getPageTheme } from './EntityPage'; @@ -66,7 +66,7 @@ describe('EntityPage', () => { ), ); - await wait(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); + await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog')); }); }); diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts deleted file mode 100644 index 51ed5582f5..0000000000 --- a/plugins/catalog/src/data/filters.ts +++ /dev/null @@ -1,123 +0,0 @@ -/* - * 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 { Entity } from '@backstage/catalog-model'; -import SettingsIcon from '@material-ui/icons/Settings'; -import StarIcon from '@material-ui/icons/Star'; -import { - CatalogFilterGroup, - CatalogFilterItem, -} from '../components/CatalogFilter/CatalogFilter'; - -export enum EntityGroup { - ALL = 'ALL', - STARRED = 'STARRED', - OWNED = 'OWNED', -} - -export const filterGroups: CatalogFilterGroup[] = [ - { - name: 'Personal', - items: [ - { - id: EntityGroup.OWNED, - label: 'Owned', - icon: SettingsIcon, - }, - { - id: EntityGroup.STARRED, - label: 'Starred', - icon: StarIcon, - }, - ], - }, - { - // TODO: Replace with Company name, read from app config. - name: 'Company', - items: [ - { - id: EntityGroup.ALL, - label: 'All Services', - }, - ], - }, -]; - -export const getCatalogFilterItemByType = (filterType: EntityGroup) => { - for (const group of filterGroups) { - for (const filter of group.items) { - if (filter.id === filterType) { - return filter; - } - } - } - return null; -}; - -type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean; - -type EntityFilterOptions = Partial<{ - isStarred: boolean; - userId: string; -}>; - -type Owned = { - owner: string; -}; - -export const entityFilters: Record = { - [EntityGroup.OWNED]: (e, { userId }) => { - const owner = (e.spec! as Owned).owner; - return owner === userId; - }, - [EntityGroup.ALL]: () => true, - [EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred, -}; - -export const entityTypeFilter = (e: Entity, type: string) => - (e.spec as any)?.type === type; - -type EntityType = 'service' | 'website' | 'library' | 'documentation' | 'other'; - -type LabeledEntityType = { - id: EntityType; - label: string; -}; - -export const labeledEntityTypes: LabeledEntityType[] = [ - { - id: 'service', - label: 'Services', - }, - { - id: 'website', - label: 'Websites', - }, - { - id: 'library', - label: 'Libraries', - }, - { - id: 'documentation', - label: 'Documentation', - }, - { - id: 'other', - label: 'Other', - }, -]; - -export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx new file mode 100644 index 0000000000..dd557a4322 --- /dev/null +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -0,0 +1,207 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { useApi } from '@backstage/core'; +import React, { useCallback, useRef, useState } from 'react'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../api/types'; +import { filterGroupsContext, FilterGroupsContext } from './context'; +import { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, +} from './types'; + +/** + * Implementation of the shared filter groups state. + */ +export const EntityFilterGroupsProvider = ({ + children, +}: { + children?: React.ReactNode; +}) => { + const state = useProvideEntityFilters(); + return ( + + {children} + + ); +}; + +// The hook that implements the actual context building +function useProvideEntityFilters(): FilterGroupsContext { + const catalogApi = useApi(catalogApiRef); + const { value: entities, error } = useAsync(() => catalogApi.getEntities()); + + const filterGroups = useRef<{ + [filterGroupId: string]: FilterGroup; + }>({}); + const selectedFilterKeys = useRef<{ + [filterGroupId: string]: Set; + }>({}); + const [filterGroupStates, setFilterGroupStates] = useState<{ + [filterGroupId: string]: FilterGroupStates; + }>({}); + const [matchingEntities, setMatchingEntities] = useState([]); + + const rebuild = useCallback(() => { + setFilterGroupStates( + buildStates( + filterGroups.current, + selectedFilterKeys.current, + entities, + error, + ), + ); + setMatchingEntities( + buildMatchingEntities( + filterGroups.current, + selectedFilterKeys.current, + entities, + ), + ); + }, [entities, error]); + + const register = useCallback( + ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => { + filterGroups.current[filterGroupId] = filterGroup; + selectedFilterKeys.current[filterGroupId] = new Set( + initialSelectedFilterIds ?? [], + ); + rebuild(); + }, + [rebuild], + ); + + const unregister = useCallback( + (filterGroupId: string) => { + delete filterGroups.current[filterGroupId]; + delete selectedFilterKeys.current[filterGroupId]; + rebuild(); + }, + [rebuild], + ); + + const setGroupSelectedFilters = useCallback( + (filterGroupId: string, filters: string[]) => { + selectedFilterKeys.current[filterGroupId] = new Set(filters); + rebuild(); + }, + [rebuild], + ); + + return { + register, + unregister, + setGroupSelectedFilters, + loading: !error && !entities, + error, + filterGroupStates, + matchingEntities, + }; +} + +// Given all filter groups and what filters are actually selected, along with +// the loading state for entities, generate the state of each individual filter +function buildStates( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + entities?: Entity[], + error?: Error, +): { [filterGroupId: string]: FilterGroupStates } { + // On error - all entries are an error state + if (error) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'error', error }, + ]), + ); + } + + // On startup - all entries are a loading state + if (!entities) { + return Object.fromEntries( + Object.keys(filterGroups).map(filterGroupId => [ + filterGroupId, + { type: 'loading' }, + ]), + ); + } + + const result: { [filterGroupId: string]: FilterGroupStates } = {}; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + const otherMatchingEntities = buildMatchingEntities( + filterGroups, + selectedFilterKeys, + entities, + filterGroupId, + ); + const groupState: FilterGroupState = { filters: {} }; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + const isSelected = !!selectedFilterKeys[filterGroupId]?.has(filterId); + const matchCount = otherMatchingEntities.filter(entity => + filterFn(entity), + ).length; + groupState.filters[filterId] = { isSelected, matchCount }; + } + result[filterGroupId] = { type: 'ready', state: groupState }; + } + + return result; +} + +// Given all filter groups and what filters are actually selected, extract all +// entities that match all those filter groups. +function buildMatchingEntities( + filterGroups: { [filterGroupId: string]: FilterGroup }, + selectedFilterKeys: { [filterGroupId: string]: Set }, + entities?: Entity[], + excludeFilterGroupId?: string, +): Entity[] { + // Build one filter fn per filter group + const allFilters: EntityFilterFn[] = []; + for (const [filterGroupId, filterGroup] of Object.entries(filterGroups)) { + if (excludeFilterGroupId === filterGroupId) { + continue; + } + + // Pick out all of the filter functions in the group that are actually + // selected + const groupFilters: EntityFilterFn[] = []; + for (const [filterId, filterFn] of Object.entries(filterGroup.filters)) { + if (!!selectedFilterKeys[filterGroupId]?.has(filterId)) { + groupFilters.push(filterFn); + } + } + + // Need to match any of the selected filters in the group - if there is + // any at all + if (groupFilters.length) { + allFilters.push(entity => groupFilters.some(fn => fn(entity))); + } + } + + // All filter groups that had any checked filters need to match. Note that + // every() always returns true for an empty array. + return entities?.filter(entity => allFilters.every(fn => fn(entity))) ?? []; +} diff --git a/plugins/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts new file mode 100644 index 0000000000..31e9c91b97 --- /dev/null +++ b/plugins/catalog/src/filter/context.ts @@ -0,0 +1,40 @@ +/* + * 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 { Entity } from '@backstage/catalog-model'; +import { createContext } from 'react'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type FilterGroupsContext = { + register: ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilterIds?: string[], + ) => void; + unregister: (filterGroupId: string) => void; + setGroupSelectedFilters: (filterGroupId: string, filterIds: string[]) => void; + loading: boolean; + error?: Error; + filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; + matchingEntities: Entity[]; +}; + +/** + * The context that maintains shared state for all visible filter groups. + */ +export const filterGroupsContext = createContext< + FilterGroupsContext | undefined +>(undefined); diff --git a/plugins/catalog/src/filter/index.ts b/plugins/catalog/src/filter/index.ts new file mode 100644 index 0000000000..da73147ef9 --- /dev/null +++ b/plugins/catalog/src/filter/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +export type { + EntityFilterFn, + FilterGroup, + FilterGroupState, + FilterGroupStates, + FilterGroupStatesError, + FilterGroupStatesLoading, + FilterGroupStatesReady, +} from './types'; +export { useEntityFilterGroup } from './useEntityFilterGroup'; +export { useFilteredEntities } from './useFilteredEntities'; diff --git a/plugins/catalog/src/filter/types.ts b/plugins/catalog/src/filter/types.ts new file mode 100644 index 0000000000..ed08b131bf --- /dev/null +++ b/plugins/catalog/src/filter/types.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 { Entity } from '@backstage/catalog-model'; + +export type EntityFilterFn = (entity: Entity) => boolean; + +export type FilterGroup = { + filters: { + [filterId: string]: EntityFilterFn; + }; +}; + +export type FilterGroupState = { + filters: { + [filterId: string]: { + isSelected: boolean; + matchCount: number; + }; + }; +}; + +export type FilterGroupStatesReady = { + type: 'ready'; + state: FilterGroupState; +}; + +export type FilterGroupStatesError = { + type: 'error'; + error: Error; +}; + +export type FilterGroupStatesLoading = { + type: 'loading'; +}; + +export type FilterGroupStates = + | FilterGroupStatesReady + | FilterGroupStatesError + | FilterGroupStatesLoading; diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx new file mode 100644 index 0000000000..60e7d4f412 --- /dev/null +++ b/plugins/catalog/src/filter/useEntityFilterGroup.test.tsx @@ -0,0 +1,118 @@ +/* + * 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 { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core'; +import { act, renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { catalogApiRef } from '../api/types'; +import { EntityFilterGroupsProvider } from './EntityFilterGroupsProvider'; +import { FilterGroupStatesReady, FilterGroup } from './types'; +import { useEntityFilterGroup } from './useEntityFilterGroup'; +import { MockStorageApi } from '@backstage/test-utils'; + +describe('useEntityFilterGroup', () => { + let catalogApi: jest.Mocked; + let wrapper: ({ children }: { children?: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + catalogApi = { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + addLocation: jest.fn((_a, _b) => new Promise(() => {})), + getEntities: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + getEntityByName: jest.fn(), + }; + const apis = ApiRegistry.with(catalogApiRef, catalogApi).with( + storageApiRef, + MockStorageApi.create(), + ); + wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {children} + + ); + }); + + it('works for an empty set of filters', async () => { + catalogApi.getEntities.mockResolvedValue([]); + const group: FilterGroup = { filters: {} }; + const { result, wait } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await wait(() => expect(result.current.state.type).toBe('ready')); + }); + + it('works for a single group', async () => { + catalogApi.getEntities.mockResolvedValue([ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { name: 'n' }, + }, + ]); + const group: FilterGroup = { + filters: { + f1: e => e.metadata.name === 'n', + f2: e => e.metadata.name !== 'n', + }, + }; + const { result, wait } = renderHook( + () => useEntityFilterGroup('g1', group), + { wrapper }, + ); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + let state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f1'])); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: true, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: false, + matchCount: 0, + }); + + act(() => result.current.setSelectedFilters(['f2'])); + + await wait(() => expect(result.current.state.type).toEqual('ready')); + state = result.current.state as FilterGroupStatesReady; + expect(state.state.filters.f1).toEqual({ + isSelected: false, + matchCount: 1, + }); + expect(state.state.filters.f2).toEqual({ + isSelected: true, + matchCount: 0, + }); + }); +}); diff --git a/plugins/catalog/src/filter/useEntityFilterGroup.ts b/plugins/catalog/src/filter/useEntityFilterGroup.ts new file mode 100644 index 0000000000..242238e4f4 --- /dev/null +++ b/plugins/catalog/src/filter/useEntityFilterGroup.ts @@ -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 { useCallback, useContext, useEffect, useMemo } from 'react'; +import { filterGroupsContext } from './context'; +import { FilterGroup, FilterGroupStates } from './types'; + +export type EntityFilterGroupOutput = { + state: FilterGroupStates; + setSelectedFilters: (filterIds: string[]) => void; +}; + +/** + * Hook that exposes the relevant data and operations for a single filter + * group. + */ +export const useEntityFilterGroup = ( + filterGroupId: string, + filterGroup: FilterGroup, + initialSelectedFilters?: string[], +): EntityFilterGroupOutput => { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + const { + register, + unregister, + setGroupSelectedFilters, + filterGroupStates, + } = context; + + // Intentionally consider initial set only at mount time + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialMemo = useMemo(() => initialSelectedFilters?.slice(), []); + + // Register the group on mount, and unregister on unmount + useEffect(() => { + register(filterGroupId, filterGroup, initialMemo); + return () => unregister(filterGroupId); + }, [register, unregister, filterGroupId, filterGroup, initialMemo]); + + const setSelectedFilters = useCallback( + (filters: string[]) => { + setGroupSelectedFilters(filterGroupId, filters); + }, + [setGroupSelectedFilters, filterGroupId], + ); + + let state = filterGroupStates[filterGroupId]; + if (!state) { + state = { type: 'loading' }; + } + + return { state, setSelectedFilters }; +}; diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts new file mode 100644 index 0000000000..256f5247c7 --- /dev/null +++ b/plugins/catalog/src/filter/useFilteredEntities.ts @@ -0,0 +1,34 @@ +/* + * 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 { useContext } from 'react'; +import { filterGroupsContext } from './context'; + +/** + * Hook that exposes the result of applying a set of filter groups. + */ +export function useFilteredEntities() { + const context = useContext(filterGroupsContext); + if (!context) { + throw new Error(`Must be used inside an EntityFilterGroupsProvider`); + } + + return { + loading: context.loading, + error: context.error, + matchingEntities: context.matchingEntities, + }; +} diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts deleted file mode 100644 index 6509bd9b4b..0000000000 --- a/plugins/catalog/src/hooks/useEntities.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* - * 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 { useState, useMemo } from 'react'; -import { - EntityGroup, - entityFilters, - entityTypeFilter, - labeledEntityTypes, -} from '../data/filters'; -import { useApi, identityApiRef } from '@backstage/core'; -import { catalogApiRef } from '..'; -import { useStarredEntities } from './useStarredEntites'; -import { Entity } from '@backstage/catalog-model'; -import useStaleWhileRevalidate from 'swr'; - -export type EntitiesByFilter = Record; - -type UseEntities = { - selectedFilter: EntityGroup | undefined; - setSelectedFilter: (f: EntityGroup) => void; - error: Error | null; - toggleStarredEntity: any; - isStarredEntity: (e: Entity) => boolean; - entitiesByFilter: EntitiesByFilter; - loading: boolean; - selectedTypeFilter: string; - selectTypeFilter: (id: string) => void; -}; - -export const useEntities = (): UseEntities => { - const [selectedFilter, setSelectedFilter] = useState< - EntityGroup | undefined - >(); - const catalogApi = useApi(catalogApiRef); - const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const { data: entities, error } = useStaleWhileRevalidate( - ['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]], - async () => catalogApi.getEntities(), - ); - - const indentityApi = useApi(identityApiRef); - const userId = indentityApi.getUserId(); - - const [selectedTypeFilter, selectTypeFilter] = useState( - labeledEntityTypes[0].id, - ); - - const entitiesByFilter = useMemo(() => { - const filterEntities = ( - ents: Entity[] | undefined, - filterId: EntityGroup, - isStarred: (e: Entity) => boolean, - user: string, - ) => { - return ents - ?.filter((e: Entity) => - entityFilters[filterId](e, { - isStarred: isStarred(e), - userId: user, - }), - ) - .filter(e => entityTypeFilter(e, selectedTypeFilter)); - }; - const data = Object.keys(EntityGroup).reduce( - (res, key) => ({ - ...res, - [key]: filterEntities( - entities, - key as EntityGroup, - isStarredEntity, - userId, - ), - }), - {} as EntitiesByFilter, - ); - return data; - }, [entities, isStarredEntity, userId, selectedTypeFilter]); - - return { - selectedFilter, - setSelectedFilter, - error, - toggleStarredEntity, - isStarredEntity, - entitiesByFilter, - loading: entities === undefined, - selectedTypeFilter, - selectTypeFilter, - }; -}; diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json index bfb0328909..67a76ef872 100644 --- a/plugins/gitops-profiles/package.json +++ b/plugins/gitops-profiles/package.json @@ -28,7 +28,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", - "react-router-dom": "^5.2.0", + "react-router-dom": "6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts index 20e4dc4307..3a14dea837 100644 --- a/plugins/gitops-profiles/src/api.ts +++ b/plugins/gitops-profiles/src/api.ts @@ -79,6 +79,14 @@ export interface ListClusterRequest { gitHubToken: string; } +export interface GithubUserInfoRequest { + accessToken: string; +} + +export interface GithubUserInfoResponse { + login: string; +} + export class FetchError extends Error { get name(): string { return this.constructor.name; @@ -100,6 +108,7 @@ export type GitOpsApi = { cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; applyProfiles(req: ApplyProfileRequest): Promise; listClusters(req: ListClusterRequest): Promise; + fetchUserInfo(req: GithubUserInfoRequest): Promise; }; export const gitOpsApiRef = createApiRef({ @@ -116,6 +125,19 @@ export class GitOpsRestApi implements GitOpsApi { return await resp.json(); } + async fetchUserInfo( + req: GithubUserInfoRequest, + ): Promise { + const resp = await fetch(`https://api.github.com/user`, { + method: 'get', + headers: new Headers({ + Authorization: `token ${req.accessToken}`, + }), + }); + if (!resp.ok) throw await FetchError.forResponse(resp); + return await resp.json(); + } + async fetchLog(req: PollLogRequest): Promise { return await this.fetch(`/api/cluster/run-status`, { method: 'post', diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx index 8e5aa15aa9..44578560e6 100644 --- a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import { Content, ContentHeader, @@ -25,32 +25,30 @@ import { Progress, HeaderLabel, useApi, + githubAuthApiRef, } from '@backstage/core'; import ClusterTable from '../ClusterTable/ClusterTable'; import { Button } from '@material-ui/core'; -import { useAsync, useLocalStorage } from 'react-use'; +import { useAsync } from 'react-use'; import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api'; import { Alert } from '@material-ui/lab'; const ClusterList: FC<{}> = () => { - const [loginInfo] = useLocalStorage<{ - token: string; - username: string; - name: string; - }>('githubLoginDetails', { - token: '', - username: '', - name: 'Guest', - }); - const api = useApi(gitOpsApiRef); + const githubAuth = useApi(githubAuthApiRef); + const [githubUsername, setGithubUsername] = useState(String); const { loading, error, value } = useAsync( - () => { + async () => { + const accessToken = await githubAuth.getAccessToken(['repo', 'user']); + if (!githubUsername) { + const userInfo = await api.fetchUserInfo({ accessToken }); + setGithubUsername(userInfo.login); + } return api.listClusters({ - gitHubToken: loginInfo.token, - gitHubUser: loginInfo.username, + gitHubToken: accessToken, + gitHubUser: githubUsername, }); }, ); @@ -73,9 +71,6 @@ const ClusterList: FC<{}> = () => { Please make sure that you start GitOps-API backend on localhost port 3008 before using this plugin. - - If you're Guest, please login via GitHub first. - ); @@ -100,7 +95,7 @@ const ClusterList: FC<{}> = () => { return (
- +
{content}
diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx index d454278255..b2d62e8650 100644 --- a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -24,21 +24,16 @@ import { Progress, HeaderLabel, useApi, + githubAuthApiRef, } from '@backstage/core'; import { Link } from '@material-ui/core'; import { useParams } from 'react-router-dom'; -import { useLocalStorage } from 'react-use'; import { gitOpsApiRef, Status } from '../../api'; import { transformRunStatus } from '../ProfileCatalog'; const ClusterPage: FC<{}> = () => { const params = useParams() as { owner: string; repo: string }; - const [loginInfo] = useLocalStorage<{ - token: string; - username: string; - name: string; - }>('githubLoginDetails'); const [pollingLog, setPollingLog] = useState(true); const [runStatus, setRunStatus] = useState([]); @@ -46,6 +41,9 @@ const ClusterPage: FC<{}> = () => { const [showProgress, setShowProgress] = useState(true); const api = useApi(gitOpsApiRef); + const githubAuth = useApi(githubAuthApiRef); + const [githubAccessToken, setGithubAccessToken] = useState(String); + const [githubUsername, setGithubUsername] = useState(String); const columns = [ { field: 'status', title: 'Status' }, @@ -53,31 +51,43 @@ const ClusterPage: FC<{}> = () => { ]; useEffect(() => { - if (pollingLog) { - const interval = setInterval(async () => { - const resp = await api.fetchLog({ - gitHubToken: loginInfo.token, - gitHubUser: loginInfo.username, - targetOrg: params.owner, - targetRepo: params.repo, - }); + const fetchGithubUserInfo = async () => { + const accessToken = await githubAuth.getAccessToken(['repo', 'user']); + const userInfo = await api.fetchUserInfo({ accessToken }); + setGithubAccessToken(accessToken); + setGithubUsername(userInfo.login); + }; - setRunStatus(resp.result); - setRunLink(resp.link); - if (resp.status === 'completed') { - setPollingLog(false); - setShowProgress(false); - } - }, 10000); - return () => clearInterval(interval); + if (!githubAccessToken || !githubUsername) { + fetchGithubUserInfo(); + } else { + if (pollingLog) { + const interval = setInterval(async () => { + const resp = await api.fetchLog({ + gitHubToken: githubAccessToken, + gitHubUser: githubUsername, + targetOrg: params.owner, + targetRepo: params.repo, + }); + + setRunStatus(resp.result); + setRunLink(resp.link); + if (resp.status === 'completed') { + setPollingLog(false); + setShowProgress(false); + } + }, 10000); + return () => clearInterval(interval); + } } + return () => {}; - }, [pollingLog, api, loginInfo, params]); + }, [pollingLog, api, params, githubAuth, githubAccessToken, githubUsername]); return (
- +
diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index a288268670..0ea28e38a9 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -27,7 +27,6 @@ "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", - "@types/react": "^16.9", "color": "^3.1.2", "d3-force": "^2.0.1", "prop-types": "^15.7.2", @@ -43,6 +42,7 @@ "@testing-library/user-event": "^12.0.7", "@types/color": "^3.0.1", "@types/d3-force": "^1.2.1", + "@types/react": "^16.9", "@types/jest": "^25.2.2", "@types/node": "^12.0.0", "jest-fetch-mock": "^3.0.3" diff --git a/plugins/tech-radar/src/api.ts b/plugins/tech-radar/src/api.ts index 3dec964ddb..856cf4ff3c 100644 --- a/plugins/tech-radar/src/api.ts +++ b/plugins/tech-radar/src/api.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '@backstage/core'; +import { MovedState } from './utils/types'; /** * Types related to the Radar's visualization. @@ -34,7 +35,7 @@ export interface RadarQuadrant { export interface RadarEntry { key: string; // react key id: string; - moved: number; + moved: MovedState; quadrant: RadarQuadrant; ring: RadarRing; title: string; diff --git a/plugins/tech-radar/src/components/Radar/Radar.test.tsx b/plugins/tech-radar/src/components/Radar/Radar.test.tsx index 469f0d85cf..f0ad0d66ed 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.test.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.test.tsx @@ -20,9 +20,9 @@ import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; -import Radar from './Radar'; +import Radar, { Props } from './Radar'; -const minProps = { +const minProps: Props = { width: 500, height: 200, quadrants: [{ id: 'languages', name: 'Languages' }], diff --git a/plugins/tech-radar/src/components/Radar/Radar.tsx b/plugins/tech-radar/src/components/Radar/Radar.tsx index 1db655730d..124e8bb68a 100644 --- a/plugins/tech-radar/src/components/Radar/Radar.tsx +++ b/plugins/tech-radar/src/components/Radar/Radar.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import React, { FC, useState, useRef } from 'react'; +import React, { useState, useRef } from 'react'; import RadarPlot from '../RadarPlot'; -import { Ring, Quadrant, Entry } from '../../utils/types'; +import type { Ring, Quadrant, Entry } from '../../utils/types'; import { adjustQuadrants, adjustRings, adjustEntries } from './utils'; -type Props = { +export type Props = { width: number; height: number; quadrants: Quadrant[]; @@ -28,17 +28,17 @@ type Props = { svgProps?: object; }; -const Radar: FC = props => { +const Radar = (props: Props): JSX.Element => { const { width, height, quadrants, rings, entries } = props; const radius = Math.min(width, height) / 2; - const [activeEntry, setActiveEntry] = useState(); + const [activeEntry, setActiveEntry] = useState(); const node = useRef(null); // TODO(dflemstr): most of this can be heavily memoized if performance becomes a problem adjustQuadrants(quadrants, radius, width, height); adjustRings(rings, radius); - adjustEntries(entries, activeEntry, quadrants, rings, radius); + adjustEntries(entries, quadrants, rings, radius, activeEntry); return ( @@ -49,9 +49,9 @@ const Radar: FC = props => { entries={entries} quadrants={quadrants} rings={rings} - activeEntry={activeEntry || undefined} + activeEntry={activeEntry} onEntryMouseEnter={entry => setActiveEntry(entry)} - onEntryMouseLeave={() => setActiveEntry(null)} + onEntryMouseLeave={() => setActiveEntry(undefined)} /> ); diff --git a/plugins/tech-radar/src/components/Radar/utils.ts b/plugins/tech-radar/src/components/Radar/utils.ts index a8896ddf31..d7a826543c 100644 --- a/plugins/tech-radar/src/components/Radar/utils.ts +++ b/plugins/tech-radar/src/components/Radar/utils.ts @@ -17,7 +17,7 @@ import color from 'color'; import { forceCollide, forceSimulation } from 'd3-force'; import Segment from '../../utils/segment'; -import { Ring, Quadrant, Entry } from '../../utils/types'; +import type { Ring, Quadrant, Entry } from '../../utils/types'; export const adjustQuadrants = ( quadrants: Quadrant[], @@ -81,14 +81,14 @@ export const adjustQuadrants = ( }, ]; - quadrants.forEach((quadrant, idx) => { - const legendParam = legendParams[idx % 4]; + quadrants.forEach((quadrant, index) => { + const legendParam = legendParams[index % 4]; - quadrant.idx = idx; - quadrant.radialMin = (idx * Math.PI) / 2; - quadrant.radialMax = ((idx + 1) * Math.PI) / 2; - quadrant.offsetX = idx % 4 === 0 || idx % 4 === 3 ? 1 : -1; - quadrant.offsetY = idx % 4 === 0 || idx % 4 === 1 ? 1 : -1; + quadrant.index = index; + quadrant.radialMin = (index * Math.PI) / 2; + quadrant.radialMax = ((index + 1) * Math.PI) / 2; + quadrant.offsetX = index % 4 === 0 || index % 4 === 3 ? 1 : -1; + quadrant.offsetY = index % 4 === 0 || index % 4 === 1 ? 1 : -1; quadrant.legendX = legendParam.x; quadrant.legendY = legendParam.y; quadrant.legendWidth = legendParam.width; @@ -98,13 +98,13 @@ export const adjustQuadrants = ( export const adjustEntries = ( entries: Entry[], - activeEntry: Entry | null | undefined, quadrants: Quadrant[], rings: Ring[], radius: number, + activeEntry?: Entry, ) => { let seed = 42; - entries.forEach((entry, idx) => { + entries.forEach((entry, index) => { const quadrant = quadrants.find(q => { const match = typeof entry.quadrant === 'object' ? entry.quadrant.id : entry.quadrant; @@ -124,7 +124,7 @@ export const adjustEntries = ( throw new Error(`Unknown ring ${entry.ring} for entry ${entry.id}!`); } - entry.idx = idx; + entry.index = index; entry.quadrant = quadrant; entry.ring = ring; entry.segment = new Segment(quadrant, ring, radius, () => seed++); @@ -163,10 +163,10 @@ export const adjustEntries = ( }; export const adjustRings = (rings: Ring[], radius: number) => { - rings.forEach((ring, idx) => { - ring.idx = idx; - ring.outerRadius = ((idx + 2) / (rings.length + 1)) * radius; + rings.forEach((ring, index) => { + ring.index = index; + ring.outerRadius = ((index + 2) / (rings.length + 1)) * radius; ring.innerRadius = - ((idx === 0 ? 0 : idx + 1) / (rings.length + 1)) * radius; + ((index === 0 ? 0 : index + 1) / (rings.length + 1)) * radius; }); }; diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx new file mode 100644 index 0000000000..d56ca743c2 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarBubble, { Props } from './RadarBubble'; + +const minProps: Props = { + visible: true, + text: 'RadarBubble', + x: 2, + y: 2, +}; + +describe('RadarBubble', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByText(minProps.text)).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx index dcf9a37ed7..abaed92253 100644 --- a/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx +++ b/plugins/tech-radar/src/components/RadarBubble/RadarBubble.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC, useRef, useLayoutEffect } from 'react'; +import React, { useRef, useLayoutEffect } from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -type Props = { +export type Props = { visible: boolean; text: string; x: number; @@ -46,7 +46,7 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarBubble: FC = props => { +const RadarBubble = (props: Props): JSX.Element => { const classes = useStyles(props); const { visible, text } = props; @@ -98,6 +98,7 @@ const RadarBubble: FC = props => { x={0} y={0} className={visible ? classes.visibleBubble : classes.bubble} + data-testid="radar-bubble" > diff --git a/plugins/tech-radar/src/components/RadarComponent.tsx b/plugins/tech-radar/src/components/RadarComponent.tsx index 981d1f1369..99190202b9 100644 --- a/plugins/tech-radar/src/components/RadarComponent.tsx +++ b/plugins/tech-radar/src/components/RadarComponent.tsx @@ -14,50 +14,38 @@ * limitations under the License. */ -import React, { useEffect, useState, FC } from 'react'; +import React, { useEffect } from 'react'; import { Progress, useApi, errorApiRef, ErrorApi } from '@backstage/core'; +import { useAsync } from 'react-use'; import Radar from '../components/Radar'; import { TechRadarComponentProps, TechRadarLoaderResponse } from '../api'; import getSampleData from '../sampleData'; const useTechRadarLoader = (props: TechRadarComponentProps) => { const errorApi = useApi(errorApiRef); - const [state, setState] = useState<{ - loading: boolean; - error?: Error; - data?: TechRadarLoaderResponse; - }>({ - loading: true, - error: undefined, - data: undefined, - }); const { getData } = props; - useEffect(() => { - if (!getData) { - return; + const state = useAsync(async () => { + if (getData) { + const response: TechRadarLoaderResponse = await getData(); + return response; } - - getData() - .then((payload: TechRadarLoaderResponse) => { - setState({ loading: false, error: undefined, data: payload }); - }) - .catch((err: Error) => { - errorApi.post(err); - setState({ - loading: false, - error: err, - data: undefined, - }); - }); + return undefined; }, [getData, errorApi]); + useEffect(() => { + const { error } = state; + if (error) { + errorApi.post(error); + } + }, [errorApi, state]); + return state; }; -const RadarComponent: FC = props => { - const { loading, error, data } = useTechRadarLoader(props); +const RadarComponent = (props: TechRadarComponentProps): JSX.Element => { + const { loading, error, value: data } = useTechRadarLoader(props); return ( <> diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx new file mode 100644 index 0000000000..2d24f301d1 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarEntry, { Props } from './RadarEntry'; + +const minProps: Props = { + x: 2, + y: 2, + value: 2, + color: 'red', +}; + +describe('RadarEntry', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + const radarEntry = rendered.getByTestId('radar-entry'); + const { x, y } = minProps; + expect(radarEntry).toBeInTheDocument(); + expect(radarEntry.getAttribute('transform')).toBe(`translate(${x}, ${y})`); + expect(rendered.getByText(String(minProps.value))).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx index 6006979e67..29d35e01e2 100644 --- a/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx +++ b/plugins/tech-radar/src/components/RadarEntry/RadarEntry.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; +import { WithLink } from '../../utils/components'; -type Props = { +export type Props = { x: number; y: number; - number: number; + value: number; color: string; url?: string; moved?: number; @@ -43,14 +44,27 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarEntry: FC = props => { +const makeBlip = (color: string, moved?: number) => { + const style = { fill: color }; + + let blip = ; + if (moved && moved > 0) { + blip = ; // triangle pointing up + } else if (moved && moved < 0) { + blip = ; // triangle pointing down + } + + return blip; +}; + +const RadarEntry = (props: Props): JSX.Element => { const classes = useStyles(props); const { moved, color, url, - number, + value, x, y, onMouseEnter, @@ -58,24 +72,7 @@ const RadarEntry: FC = props => { onClick, } = props; - const style = { fill: color }; - - let blip; - if (moved && moved > 0) { - blip = ; // triangle pointing up - } else if (moved && moved < 0) { - blip = ; // triangle pointing down - } else { - blip = ; - } - - if (url) { - blip = ( - - {blip} - - ); - } + const blip = makeBlip(color, moved); return ( = props => { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick} + data-testid="radar-entry" > - {blip} + + {blip} + - {number} + {value} ); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx new file mode 100644 index 0000000000..77f21b6e63 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.test.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarFooter, { Props } from './RadarFooter'; + +const minProps: Props = { + x: 2, + y: 2, +}; + +describe('RadarFooter', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + const radarFooter = rendered.getByTestId('radar-footer'); + const { x, y } = minProps; + expect(radarFooter).toBeInTheDocument(); + expect(radarFooter.getAttribute('transform')).toBe(`translate(${x}, ${y})`); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx index 7d60a63f59..ced1ab8054 100644 --- a/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx +++ b/plugins/tech-radar/src/components/RadarFooter/RadarFooter.tsx @@ -14,10 +14,10 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -type Props = { +export type Props = { x: number; y: number; }; @@ -31,12 +31,16 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarFooter: FC = props => { +const RadarFooter = (props: Props): JSX.Element => { const { x, y } = props; const classes = useStyles(props); return ( - + {'▲ moved up\u00a0\u00a0\u00a0\u00a0\u00a0▼ moved down'} ); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx new file mode 100644 index 0000000000..3027cc4adb --- /dev/null +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarGrid, { Props } from './RadarGrid'; + +const minProps: Props = { + radius: 5, + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], +}; + +describe('RadarGrid', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByTestId('radar-grid-x-line')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-grid-y-line')).toBeInTheDocument(); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx index 514afbe958..eae85d4e64 100644 --- a/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx +++ b/plugins/tech-radar/src/components/RadarGrid/RadarGrid.tsx @@ -16,9 +16,9 @@ import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -import { Ring } from '../../utils/types'; +import type { Ring } from '../../utils/types'; -type Props = { +export type Props = { radius: number; rings: Ring[]; }; @@ -49,7 +49,7 @@ const RadarGrid = (props: Props) => { const { radius, rings } = props; const classes = useStyles(props); - const makeRingNode = (ringRadius: number | undefined, ringIndex: number) => [ + const makeRingNode = (ringIndex: number, ringRadius?: number) => [ { x2={0} y2={radius} className={classes.axis} + data-testid="radar-grid-x-line" />, // Y axis { x2={radius} y2={0} className={classes.axis} + data-testid="radar-grid-y-line" />, ]; - const ringNodes = rings.map(r => r.outerRadius).map(makeRingNode); + const ringNodes = rings + .map(r => r.outerRadius) + .map((ringRadius, ringIndex) => makeRingNode(ringIndex, ringRadius)); return <>{axisNodes.concat(...ringNodes)}; }; diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx new file mode 100644 index 0000000000..68bfe892ea --- /dev/null +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.test.tsx @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarLegend, { Props } from './RadarLegend'; + +const minProps: Props = { + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: { id: 'languages', name: 'Languages' }, + moved: 0, + ring: { id: 'use', name: 'USE', color: '#93c47d' }, + url: '#', + }, + ], +}; + +describe('RadarLegend', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-quadrant')).toHaveLength(1); + expect(rendered.getAllByTestId('radar-ring')).toHaveLength(1); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx index 03146a8071..e319846d78 100644 --- a/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx +++ b/plugins/tech-radar/src/components/RadarLegend/RadarLegend.tsx @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { makeStyles, Theme } from '@material-ui/core'; -import { Quadrant, Ring, Entry } from '../../utils/types'; +import type { Quadrant, Ring, Entry } from '../../utils/types'; +import { WithLink } from '../../utils/components'; type Segments = { [k: number]: { [k: number]: Entry[] }; }; -type Props = { +export type Props = { quadrants: Quadrant[]; rings: Ring[]; entries: Entry[]; @@ -29,7 +30,7 @@ type Props = { onEntryMouseLeave?: (entry: Entry) => void; }; -const useStyles = makeStyles(() => ({ +const useStyles = makeStyles(theme => ({ quadrant: { height: '100%', width: '100%', @@ -40,7 +41,7 @@ const useStyles = makeStyles(() => ({ pointerEvents: 'none', userSelect: 'none', marginTop: 0, - marginBottom: 'calc(18px * 0.375)', + marginBottom: theme.spacing(8 / (18 * 0.375)), fontSize: '18px', }, rings: { @@ -56,7 +57,7 @@ const useStyles = makeStyles(() => ({ pointerEvents: 'none', userSelect: 'none', marginTop: 0, - marginBottom: 'calc(12px * 0.375)', + marginBottom: theme.spacing(8 / (12 * 0.375)), fontSize: '12px', fontWeight: 800, }, @@ -79,73 +80,81 @@ const useStyles = makeStyles(() => ({ }, })); -const RadarLegend: FC = props => { +const RadarLegend = (props: Props): JSX.Element => { const classes = useStyles(props); - const _getSegment = ( + const getSegment = ( segmented: Segments, quadrant: Quadrant, ring: Ring, ringOffset = 0, ) => { - const qidx = quadrant.idx; - const ridx = ring.idx; - const segmentedData = qidx === undefined ? {} : segmented[qidx] || {}; - return ridx === undefined ? [] : segmentedData[ridx + ringOffset] || []; + const quadrantIndex = quadrant.index; + const ringIndex = ring.index; + const segmentedData = + quadrantIndex === undefined ? {} : segmented[quadrantIndex] || {}; + return ringIndex === undefined + ? [] + : segmentedData[ringIndex + ringOffset] || []; }; - const _renderRing = ( - ring: Ring, - entries: Entry[], - onEntryMouseEnter?: Props['onEntryMouseEnter'], - onEntryMouseLeave?: Props['onEntryMouseEnter'], - ) => { + type RadarLegendRingProps = { + ring: Ring; + entries: Entry[]; + onEntryMouseEnter?: Props['onEntryMouseEnter']; + onEntryMouseLeave?: Props['onEntryMouseEnter']; + }; + + const RadarLegendRing = ({ + ring, + entries, + onEntryMouseEnter, + onEntryMouseLeave, + }: RadarLegendRingProps) => { return ( -
+

{ring.name}

{entries.length === 0 ? (

(empty)

) : (
    - {entries.map(entry => { - let node = {entry.title}; - - if (entry.url) { - node = ( - - {node} - - ); - } - - return ( -
  1. onEntryMouseEnter(entry)) - } - onMouseLeave={ - onEntryMouseLeave && (() => onEntryMouseLeave(entry)) - } - > - {node} -
  2. - ); - })} + {entries.map(entry => ( +
  3. onEntryMouseEnter(entry)) + } + onMouseLeave={ + onEntryMouseLeave && (() => onEntryMouseLeave(entry)) + } + > + + {entry.title} + +
  4. + ))}
)}
); }; - const _renderQuadrant = ( - segments: Segments, - quadrant: Quadrant, - rings: Ring[], - onEntryMouseEnter: Props['onEntryMouseEnter'], - onEntryMouseLeave: Props['onEntryMouseLeave'], - ) => { + type RadarLegendQuadrantProps = { + segments: Segments; + quadrant: Quadrant; + rings: Ring[]; + onEntryMouseEnter: Props['onEntryMouseEnter']; + onEntryMouseLeave: Props['onEntryMouseLeave']; + }; + + const RadarLegendQuadrant = ({ + segments, + quadrant, + rings, + onEntryMouseEnter, + onEntryMouseLeave, + }: RadarLegendQuadrantProps) => { return ( = props => { y={quadrant.legendY} width={quadrant.legendWidth} height={quadrant.legendHeight} + data-testid="radar-quadrant" >

{quadrant.name}

- {rings.map(ring => - _renderRing( - ring, - _getSegment(segments, quadrant, ring), - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + {rings.map(ring => ( + + ))}
); }; - const _setupSegments = (entries: Entry[]) => { + const setupSegments = (entries: Entry[]) => { const segments: Segments = {}; for (const entry of entries) { - const qidx = entry.quadrant.idx; - const ridx = entry.ring.idx; + const quadrantIndex = entry.quadrant.index; + const ringIndex = entry.ring.index; let quadrantData: { [k: number]: Entry[] } = {}; - if (qidx !== undefined) { - if (segments[qidx] === undefined) { - segments[qidx] = {}; + if (quadrantIndex !== undefined) { + if (segments[quadrantIndex] === undefined) { + segments[quadrantIndex] = {}; } - quadrantData = segments[qidx]; + quadrantData = segments[quadrantIndex]; } let ringData = []; - if (ridx !== undefined) { - if (quadrantData[ridx] === undefined) { - quadrantData[ridx] = []; + if (ringIndex !== undefined) { + if (quadrantData[ringIndex] === undefined) { + quadrantData[ringIndex] = []; } - ringData = quadrantData[ridx]; + ringData = quadrantData[ringIndex]; } ringData.push(entry); @@ -209,19 +220,20 @@ const RadarLegend: FC = props => { onEntryMouseLeave, } = props; - const segments: Segments = _setupSegments(entries); + const segments: Segments = setupSegments(entries); return ( - - {quadrants.map(quadrant => - _renderQuadrant( - segments, - quadrant, - rings, - onEntryMouseEnter, - onEntryMouseLeave, - ), - )} + + {quadrants.map(quadrant => ( + + ))} ); }; diff --git a/plugins/tech-radar/src/components/RadarPage.tsx b/plugins/tech-radar/src/components/RadarPage.tsx index 4c7236e01f..1aefb72eca 100644 --- a/plugins/tech-radar/src/components/RadarPage.tsx +++ b/plugins/tech-radar/src/components/RadarPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React from 'react'; import { Grid } from '@material-ui/core'; import { Content, @@ -29,7 +29,7 @@ import { import RadarComponent from '../components/RadarComponent'; import { techRadarApiRef, TechRadarApi } from '../api'; -const RadarPage: FC<{}> = () => { +const RadarPage = (): JSX.Element => { const techRadarApi = useApi(techRadarApiRef); return ( diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx new file mode 100644 index 0000000000..5b17578478 --- /dev/null +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.test.tsx @@ -0,0 +1,67 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { ThemeProvider } from '@material-ui/core'; +import { lightTheme } from '@backstage/theme'; +import GetBBoxPolyfill from '../../utils/polyfills/getBBox'; + +import RadarPlot, { Props } from './RadarPlot'; + +const minProps: Props = { + width: 500, + height: 200, + radius: 50, + quadrants: [{ id: 'languages', name: 'Languages' }], + rings: [{ id: 'use', name: 'USE', color: '#93c47d' }], + entries: [ + { + id: 'typescript', + title: 'TypeScript', + quadrant: { id: 'languages', name: 'Languages' }, + moved: 0, + ring: { id: 'use', name: 'USE', color: '#93c47d' }, + url: '#', + }, + ], +}; + +describe('RadarPlot', () => { + beforeAll(() => { + GetBBoxPolyfill.create(0, 0, 1000, 500); + }); + + afterAll(() => { + GetBBoxPolyfill.remove(); + }); + + it('should render', () => { + const rendered = render( + + + + + , + ); + + expect(rendered.getByTestId('radar-plot')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-legend')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-footer')).toBeInTheDocument(); + expect(rendered.getByTestId('radar-bubble')).toBeInTheDocument(); + expect(rendered.getAllByTestId('radar-entry')).toHaveLength(1); + }); +}); diff --git a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx index 48ad660dde..7f68ffe021 100644 --- a/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx +++ b/plugins/tech-radar/src/components/RadarPlot/RadarPlot.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import React, { FC } from 'react'; -import { Quadrant, Ring, Entry } from '../../utils/types'; +import React from 'react'; +import type { Quadrant, Ring, Entry } from '../../utils/types'; import RadarGrid from '../RadarGrid'; import RadarEntry from '../RadarEntry'; @@ -23,7 +23,7 @@ import RadarBubble from '../RadarBubble'; import RadarFooter from '../RadarFooter'; import RadarLegend from '../RadarLegend'; -type Props = { +export type Props = { width: number; height: number; radius: number; @@ -36,7 +36,7 @@ type Props = { }; // A component that draws the radar circle. -const RadarPlot: FC = props => { +const RadarPlot = (props: Props): JSX.Element => { const { width, height, @@ -50,7 +50,7 @@ const RadarPlot: FC = props => { } = props; return ( - + = props => { x={entry.x || 0} y={entry.y || 0} color={entry.color || ''} - number={((entry && entry.idx) || 0) + 1} + value={(entry?.index || 0) + 1} url={entry.url} moved={entry.moved} onMouseEnter={onEntryMouseEnter && (() => onEntryMouseEnter(entry))} @@ -80,9 +80,9 @@ const RadarPlot: FC = props => { ))} diff --git a/plugins/tech-radar/src/utils/components.tsx b/plugins/tech-radar/src/utils/components.tsx new file mode 100644 index 0000000000..af5a7b05bd --- /dev/null +++ b/plugins/tech-radar/src/utils/components.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; + +type WithLinkProps = { + url?: string; + className: string; + children: React.ReactNode; +}; + +export const WithLink = ({ + url, + className, + children, +}: WithLinkProps): JSX.Element => + url ? ( + + {children} + + ) : ( + <>{children} + ); diff --git a/plugins/tech-radar/src/utils/types.ts b/plugins/tech-radar/src/utils/types.ts index 14a0554a1f..f3933ee584 100644 --- a/plugins/tech-radar/src/utils/types.ts +++ b/plugins/tech-radar/src/utils/types.ts @@ -17,7 +17,7 @@ // Parameters for a ring; its index in an array determines how close to the center this ring is. export type Ring = { id: string; - idx?: number; + index?: number; name: string; color: string; outerRadius?: number; @@ -27,7 +27,7 @@ export type Ring = { // Parameters for a quadrant (there should be exactly 4 of course) export type Quadrant = { id: string; - idx?: number; + index?: number; name: string; legendX?: number; legendY?: number; @@ -45,9 +45,15 @@ export type Segment = { random: Function; }; +export enum MovedState { + Down = -1, + NoChange = 0, + Up = 1, +} + export type Entry = { id: string; - idx?: number; + index?: number; x?: number; y?: number; color?: string; @@ -61,7 +67,7 @@ export type Entry = { // An URL to a longer description as to why this entry is where it is url?: string; // How this entry has recently moved; -1 for "down", +1 for "up", 0 for not moved - moved?: number; + moved?: MovedState; active?: boolean; }; diff --git a/plugins/techdocs/README.md b/plugins/techdocs/README.md index 10ddcf9411..36d00fc970 100644 --- a/plugins/techdocs/README.md +++ b/plugins/techdocs/README.md @@ -10,7 +10,7 @@ Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directl ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/techdocs](http://localhost:3000/techdocs). +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/docs](http://localhost:3000/docs). You can also serve the plugin in isolation by running `yarn start` in the plugin directory. This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json index 9ef290fa40..ca8e957a46 100644 --- a/plugins/techdocs/package.json +++ b/plugins/techdocs/package.json @@ -29,6 +29,8 @@ "@material-ui/lab": "4.0.0-alpha.45", "react": "^16.13.1", "react-dom": "^16.13.1", + "react-router": "^6.0.0-alpha.5", + "react-router-dom": "^6.0.0-alpha.5", "react-use": "^14.2.0" }, "devDependencies": { diff --git a/plugins/techdocs/src/config.js b/plugins/techdocs/src/config.js new file mode 100644 index 0000000000..296b9fc14c --- /dev/null +++ b/plugins/techdocs/src/config.js @@ -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 const docStorageURL = + 'https://techdocs-mock-sites.storage.googleapis.com'; diff --git a/plugins/techdocs/src/plugin.ts b/plugins/techdocs/src/plugin.ts index 23bfa3b714..89a5ddeb73 100644 --- a/plugins/techdocs/src/plugin.ts +++ b/plugins/techdocs/src/plugin.ts @@ -34,6 +34,11 @@ import { Reader } from './reader/components/Reader'; export const rootRouteRef = createRouteRef({ path: '/docs', + title: 'TechDocs Landing Page', +}); + +export const rootDocsRouteRef = createRouteRef({ + path: '/docs/:componentId/*', title: 'Docs', }); @@ -41,5 +46,6 @@ export const plugin = createPlugin({ id: 'techdocs', register({ router }) { router.addRoute(rootRouteRef, Reader); + router.addRoute(rootDocsRouteRef, Reader); }, }); diff --git a/plugins/techdocs/src/reader/components/Reader.tsx b/plugins/techdocs/src/reader/components/Reader.tsx index e306a72424..12cec30c80 100644 --- a/plugins/techdocs/src/reader/components/Reader.tsx +++ b/plugins/techdocs/src/reader/components/Reader.tsx @@ -16,801 +16,123 @@ import React from 'react'; import { useShadowDom } from '..'; - -const mockHtml: string = ` - - - - - - - - - - - - - - - - - - - - - - - - - - Download boilerplate - MkDocs Material Boilerplate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- -
- -
- -
- - - - - - - - - - - - -
-
- - -
-
-
- -
-
-
- - -
-
-
- - -
-
-
- - -
-
- - - - - - - - - - -

Download boilerplate

-

Git clone

-
git clone https://github.com/peaceiris/mkdocs-material-boilerplate.git
-cd mkdocs-material-boilerplate
-
- - -

Download zip

-
wget 'https://github.com/peaceiris/mkdocs-material-boilerplate/archive/master.zip'
-unzip master.zip
-cd mkdocs-material-boilerplate-master
-
- - -

👉 Click me to download zip

- - - - - - - -
-
-
-
- - - - -
- - - - - - - - - - - -`; +import { useAsync } from 'react-use'; +import { useLocation, useParams, useNavigate } from 'react-router-dom'; + +import { Grid } from '@material-ui/core'; +import { Header, Content, ItemCard } from '@backstage/core'; + +import transformer, { + addBaseUrl, + rewriteDocLinks, + addEventListener, + removeMkdocsHeader, + modifyCss, +} from '../transformers'; +import { docStorageURL } from '../../config'; +import URLParser from '../urlParser'; + +const useFetch = (url: string) => { + const state = useAsync(async () => { + const response = await fetch(url); + const raw = await response.text(); + return raw; + }, [url]); + + return state; +}; + +const useEnforcedTrailingSlash = (): void => { + React.useEffect(() => { + const actualUrl = window.location.href; + const expectedUrl = new URLParser(window.location.href, '.').parse(); + + if (actualUrl !== expectedUrl) { + window.history.replaceState({}, document.title, expectedUrl); + } + }, []); +}; export const Reader = () => { + const location = useLocation(); + const { componentId, '*': path } = useParams(); const shadowDomRef = useShadowDom(); + const navigate = useNavigate(); + const normalizedUrl = new URLParser( + `${docStorageURL}${location.pathname.replace('/docs', '')}`, + '.', + ).parse(); + const state = useFetch(`${normalizedUrl}index.html`); + + useEnforcedTrailingSlash(); React.useEffect(() => { const divElement = shadowDomRef.current; - if (!divElement?.shadowRoot) { - return; + if (divElement?.shadowRoot && state.value) { + const transformedElement = transformer(state.value, [ + addBaseUrl({ + docStorageURL, + componentId, + path, + }), + rewriteDocLinks(), + modifyCss({ + cssTransforms: { + '.md-main__inner': [{ 'margin-top': '0' }], + '.md-sidebar': [{ top: '0' }, { width: '20rem' }], + '.md-typeset': [{ 'font-size': '1rem' }], + '.md-nav': [{ 'font-size': '1rem' }], + '.md-grid': [{ 'max-width': '80vw' }], + }, + }), + removeMkdocsHeader(), + ]); + + divElement.shadowRoot.innerHTML = ''; + if (transformedElement) { + divElement.shadowRoot.appendChild(transformedElement); + transformer(divElement.shadowRoot.children[0], [ + addEventListener({ + onClick: navigate, + }), + ]); + } } - divElement.shadowRoot.innerHTML = mockHtml; - }, [shadowDomRef]); + }, [shadowDomRef, state, componentId, path, navigate]); return ( <> -

Shadow DOM should be underneath

-
+
+ + + {componentId ? ( +
+ ) : ( + + + navigate('/docs/mkdocs')} + tags={['Developer Tool']} + title="MkDocs" + label="Read Docs" + description="MkDocs is a fast, simple and downright gorgeous static site generator that's geared towards building project documentation. " + /> + + + navigate('/docs/backstage-microsite')} + tags={['Service']} + title="Backstage" + label="Read Docs" + description="Getting started guides, API Overview, documentation around how to Create a Plugin and more. " + /> + + + )} + ); }; diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts new file mode 100644 index 0000000000..b9cf9745ee --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { createTestShadowDom, FIXTURES, getSample } from '../../test-utils'; +import { addBaseUrl } from '../transformers'; + +const DOC_STORAGE_URL = 'https://example-host.storage.googleapis.com'; + +describe('addBaseUrl', () => { + it('contains relative paths', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE); + + expect(getSample(shadowDom, 'img', 'src')).toEqual([ + 'img/win-py-install.png', + 'img/initial-layout.png', + ]); + expect(getSample(shadowDom, 'link', 'href')).toEqual([ + 'https://www.mkdocs.org/', + 'assets/images/favicon.png', + ]); + expect(getSample(shadowDom, 'script', 'src')).toEqual([ + 'https://www.google-analytics.com/analytics.js', + 'assets/javascripts/vendor.d710d30a.min.js', + ]); + }); + + it('contains transformed absolute paths', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [ + addBaseUrl({ + docStorageURL: DOC_STORAGE_URL, + componentId: 'example-docs', + path: '', + }), + ], + }); + + expect(getSample(shadowDom, 'img', 'src')).toEqual([ + 'https://example-host.storage.googleapis.com/example-docs/img/win-py-install.png', + 'https://example-host.storage.googleapis.com/example-docs/img/initial-layout.png', + ]); + expect(getSample(shadowDom, 'link', 'href')).toEqual([ + 'https://www.mkdocs.org/', + 'https://example-host.storage.googleapis.com/example-docs/assets/images/favicon.png', + ]); + expect(getSample(shadowDom, 'script', 'src')).toEqual([ + 'https://www.google-analytics.com/analytics.js', + 'https://example-host.storage.googleapis.com/example-docs/assets/javascripts/vendor.d710d30a.min.js', + ]); + }); + + it('includes path option', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [ + addBaseUrl({ + docStorageURL: DOC_STORAGE_URL, + componentId: 'example-docs', + path: 'examplepath', + }), + ], + }); + + expect(getSample(shadowDom, 'img', 'src')).toEqual([ + 'https://example-host.storage.googleapis.com/example-docs/examplepath/img/win-py-install.png', + 'https://example-host.storage.googleapis.com/example-docs/examplepath/img/initial-layout.png', + ]); + expect(getSample(shadowDom, 'link', 'href')).toEqual([ + 'https://www.mkdocs.org/', + 'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/images/favicon.png', + ]); + expect(getSample(shadowDom, 'script', 'src')).toEqual([ + 'https://www.google-analytics.com/analytics.js', + 'https://example-host.storage.googleapis.com/example-docs/examplepath/assets/javascripts/vendor.d710d30a.min.js', + ]); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/addBaseUrl.ts b/plugins/techdocs/src/reader/transformers/addBaseUrl.ts new file mode 100644 index 0000000000..653f0b2f71 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addBaseUrl.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 URLParser from '../urlParser'; +import type { Transformer } from './index'; + +type AddBaseUrlOptions = { + docStorageURL: string; + componentId: string; + path: string; +}; + +export const addBaseUrl = ({ + docStorageURL, + componentId, + path, +}: AddBaseUrlOptions): Transformer => { + return dom => { + const updateDom = ( + list: HTMLCollectionOf | NodeListOf, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => !!elem.getAttribute(attributeName)) + .forEach((elem: T) => { + const newUrl = new URLParser( + `${docStorageURL}/${componentId}/${path}`, + elem.getAttribute(attributeName)!, + ).parse(); + elem.setAttribute(attributeName, newUrl); + }); + }; + + updateDom(dom.querySelectorAll('img'), 'src'); + updateDom(dom.querySelectorAll('script'), 'src'); + updateDom(dom.querySelectorAll('link'), 'href'); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.test.ts b/plugins/techdocs/src/reader/transformers/addEventListener.test.ts new file mode 100644 index 0000000000..e87ab81ab5 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addEventListener.test.ts @@ -0,0 +1,35 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTestShadowDom, FIXTURES } from '../../test-utils'; +import { addEventListener } from '../transformers'; + +describe('addEventListener', () => { + it('calls onClick when a link has been clicked', () => { + const fn = jest.fn(); + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [ + addEventListener({ + onClick: fn, + }), + ], + }); + + shadowDom.querySelector('a')?.click(); + + expect(fn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/addEventListener.ts b/plugins/techdocs/src/reader/transformers/addEventListener.ts new file mode 100644 index 0000000000..32b9d6b0b9 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/addEventListener.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Transformer } from './index'; + +type AddEventListenerOptions = { + onClick: (newUrl: string) => void; +}; + +export const addEventListener = ({ + onClick, +}: AddEventListenerOptions): Transformer => { + return dom => { + Array.from(dom.getElementsByTagName('a')).forEach(elem => { + elem.addEventListener('click', (e: MouseEvent) => { + e.preventDefault(); + const target = e.target as HTMLAnchorElement; + if (target?.getAttribute('href')) { + onClick( + target.getAttribute('href')!.replace(window.location.origin, ''), + ); + } + }); + }); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/index.test.ts b/plugins/techdocs/src/reader/transformers/index.test.ts new file mode 100644 index 0000000000..05ba13397f --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/index.test.ts @@ -0,0 +1,32 @@ +/* + * 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 transform, { Transformer } from '.'; + +describe('transform', () => { + it('calls the transformers', () => { + const fn = jest.fn(); + const mockTransformer = (): Transformer => (dom: Element) => { + fn(dom); + return dom; + }; + + transform('', [mockTransformer()]); + + expect(fn).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledWith(expect.any(Element)); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/index.ts b/plugins/techdocs/src/reader/transformers/index.ts new file mode 100644 index 0000000000..f49f496277 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/index.ts @@ -0,0 +1,44 @@ +/* + * 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 './addBaseUrl'; +export * from './rewriteDocLinks'; +export * from './addEventListener'; +export * from './removeMkdocsHeader'; +export * from './modifyCss'; + +export type Transformer = (dom: Element) => Element; + +function transform( + html: string | Element, + transformers: Transformer[], +): Element { + let dom: Element; + + if (typeof html === 'string') { + dom = new DOMParser().parseFromString(html, 'text/html').documentElement; + } else if (html instanceof Element) { + dom = html; + } else { + throw new Error('dom is not a recognized type'); + } + + transformers.forEach(transformer => transformer(dom)); + + return dom; +} + +export default transform; diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx b/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx new file mode 100644 index 0000000000..fa808103dc --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/modifyCss.test.tsx @@ -0,0 +1,56 @@ +/* + * 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 { createTestShadowDom } from '../../test-utils'; +import { modifyCss } from '../transformers'; + +describe('modifyCss', () => { + it('does not modify css', () => { + const shadowDom = createTestShadowDom( + `
`, + { + transformers: [], + }, + ); + + const { fontSize } = getComputedStyle( + shadowDom.querySelector('.md-typeset')!, + ); + + expect(fontSize).toBe('0.8em'); + }); + + it('does modify css', () => { + const shadowDom = createTestShadowDom( + `
`, + { + transformers: [ + modifyCss({ + cssTransforms: { + '.md-typeset': [{ 'font-size': '1em' }], + }, + }), + ], + }, + ); + + const { fontSize } = getComputedStyle( + shadowDom.querySelector('.md-typeset')!, + ); + + expect(fontSize).toBe('1em'); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/modifyCss.ts b/plugins/techdocs/src/reader/transformers/modifyCss.ts new file mode 100644 index 0000000000..5116baac45 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/modifyCss.ts @@ -0,0 +1,43 @@ +/* + * 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 type { Transformer } from './index'; + +type ModifyCssOptions = { + // Example: { '.md-container': { 'marginTop': '10px' }} + cssTransforms: { [key: string]: { [key: string]: string }[] }; +}; + +export const modifyCss = ({ cssTransforms }: ModifyCssOptions): Transformer => { + return dom => { + Object.entries(cssTransforms).forEach(([cssSelector, cssChanges]) => { + const elementsToChange = Array.from( + dom.querySelectorAll(cssSelector), + ); + if (elementsToChange.length < 1) return; + + cssChanges.forEach(changes => { + elementsToChange.forEach((element: HTMLElement) => { + Object.entries(changes).forEach(([cssProperty, cssValue]) => { + element.style.setProperty(cssProperty, cssValue); + }); + }); + }); + }); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts new file mode 100644 index 0000000000..7e155ea317 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.test.ts @@ -0,0 +1,36 @@ +/* + * 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 { createTestShadowDom, FIXTURES } from '../../test-utils'; +import { removeMkdocsHeader } from '../transformers'; + +describe('removeMkdocsHeader', () => { + it('does not remove mkdocs header', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [], + }); + + expect(shadowDom.querySelector('.md-header')).toBeTruthy(); + }); + + it('does remove mkdocs header', () => { + const shadowDom = createTestShadowDom(FIXTURES.FIXTURE_STANDARD_PAGE, { + transformers: [removeMkdocsHeader()], + }); + + expect(shadowDom.querySelector('.md-header')).toBeFalsy(); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts new file mode 100644 index 0000000000..087574b2de --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/removeMkdocsHeader.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { Transformer } from './index'; + +export const removeMkdocsHeader = (): Transformer => { + return dom => { + // Remove the header + dom.querySelector('.md-header')?.remove(); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts new file mode 100644 index 0000000000..83ca846142 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { createTestShadowDom, getSample } from '../../test-utils'; +import { rewriteDocLinks } from '../transformers'; + +describe('rewriteDocLinks', () => { + it('should not do anything', () => { + const shadowDom = createTestShadowDom(` + Test + Test + Test + Test Sub Page + `); + + expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([ + 'http://example.org/', + '../example', + 'example-docs', + 'example-docs/example-page', + ]); + }); + + it('should transform a href with licalhost as baseUrl', () => { + const shadowDom = createTestShadowDom( + ` + Test + Test + Test + Test Sub Page + `, + { + transformers: [rewriteDocLinks()], + }, + ); + + expect(getSample(shadowDom, 'a', 'href', 6)).toEqual([ + 'http://example.org/', + 'http://localhost/example', + 'http://localhost/example-docs', + 'http://localhost/example-docs/example-page', + ]); + }); +}); diff --git a/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts new file mode 100644 index 0000000000..7b8495b6b7 --- /dev/null +++ b/plugins/techdocs/src/reader/transformers/rewriteDocLinks.ts @@ -0,0 +1,43 @@ +/* + * 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 URLParser from '../urlParser'; +import type { Transformer } from './index'; + +export const rewriteDocLinks = (): Transformer => { + return dom => { + const updateDom = ( + list: Array, + attributeName: string, + ): void => { + Array.from(list) + .filter(elem => elem.hasAttribute(attributeName)) + .forEach((elem: T) => { + elem.setAttribute( + attributeName, + new URLParser( + window.location.href, + elem.getAttribute(attributeName)!, + ).parse(), + ); + }); + }; + + updateDom(Array.from(dom.getElementsByTagName('a')), 'href'); + + return dom; + }; +}; diff --git a/plugins/techdocs/src/reader/urlParser.test.ts b/plugins/techdocs/src/reader/urlParser.test.ts new file mode 100644 index 0000000000..4322fc42e1 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.test.ts @@ -0,0 +1,61 @@ +/* + * 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 URLParser from './urlParser'; + +describe('URLParser', () => { + it('should not change an absolute url', () => { + const urlParser = new URLParser( + 'https://www.google.com/', + 'https://www.mkdocs.org/', + ); + + expect(urlParser.parse()).toEqual('https://www.mkdocs.org/'); + }); + + it('should convert a relative url to an absolute url', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started/', + '../../support/installing/', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/support/installing/', + ); + }); + + it('should add a trailing slash', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started', + '.', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); + + it('should not add a trailing slash', () => { + const urlParser = new URLParser( + 'https://www.mkdocs.org/user-guide/getting-started/', + '.', + ); + + expect(urlParser.parse()).toEqual( + 'https://www.mkdocs.org/user-guide/getting-started/', + ); + }); +}); diff --git a/plugins/techdocs/src/reader/urlParser.ts b/plugins/techdocs/src/reader/urlParser.ts new file mode 100644 index 0000000000..b1429b7635 --- /dev/null +++ b/plugins/techdocs/src/reader/urlParser.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const normalizeBaseURL = (baseURL: string): string => { + const url = new URL(baseURL); + url.pathname = url.pathname.replace(/([^/])$/, '$1/'); + return url.toString(); +}; + +export default class URLParser { + constructor(public baseURL: string, public pathname: string) { + this.baseURL = normalizeBaseURL(baseURL); + } + + parse(): string { + return new URL(this.pathname, this.baseURL).toString(); + } +} diff --git a/plugins/techdocs/src/test-utils/fixtures/mkdocs-index.ts b/plugins/techdocs/src/test-utils/fixtures/mkdocs-index.ts new file mode 100644 index 0000000000..4fc0f6e8c4 --- /dev/null +++ b/plugins/techdocs/src/test-utils/fixtures/mkdocs-index.ts @@ -0,0 +1,1576 @@ +/* + * 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 ` + + + + + + + + + + + + + + + MkDocs + + + + + + + + + + + + + + + + +
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+
+ + + +
+
+

+ MkDocs +

+

Project documentation with Markdown.

+
+

+ Overview +

+

+ MkDocs is a fast, simple and + downright gorgeous static site generator that's + geared towards building project documentation. Documentation + source files are written in Markdown, and configured with a + single YAML configuration file. Start by reading the + introduction below, then check the User Guide for more info. +

+

+ Host anywhere +

+

+ MkDocs builds completely static HTML sites that you can host on + GitHub pages, Amazon S3, or + anywhere else you + choose. +

+

+ Great themes available +

+

+ There's a stack of good looking + themes available for + MkDocs. Choose between the built in themes: + mkdocs and + readthedocs, select one of the 3rd party themes listed on the + MkDocs Themes + wiki page, or + build your own. +

+

+ Preview your site as you work +

+

+ The built-in dev-server allows you to preview your documentation + as you're writing it. It will even auto-reload and refresh your + browser whenever you save your changes. +

+

+ Easy to customize +

+

+ Get your project documentation looking just the way you want it + by customizing the + theme and/or + installing some plugins. +

+
+

+ Installation +

+

+ Install with a Package Manager +

+

+ If you have and use a package manager (such as + apt-get, + dnf, homebrew, + yum, + chocolatey, etc.) to + install packages on your system, then you may want to search for + a "MkDocs" package and, if a recent version is available, + install it with your package manager (check your system's + documentation for details). That's it, you're done! Skip down to + Getting Started. +

+

+ If your package manager does not have a recent "MkDocs" package, + you can still use your package manager to install "Python" and + "pip". Then you can use pip to + install MkDocs. +

+

+ Manual Installation +

+

+ In order to manually install MkDocs you'll need + Python installed on your + system, as well as the Python package manager, + pip. You can check if you have these already installed from the + command line: +

+
+
$ python --version
+Python 3.8.2
+$ pip --version
+pip 20.0.2 from /usr/local/lib/python3.8/site-packages/pip (python 3.8)
+
+
+ +

+ MkDocs supports Python versions 3.5, 3.6, 3.7, 3.8, and pypy3. +

+

+ Installing Python +

+

+ Install Python by + downloading an installer appropriate for your system from + python.org and + running it. +

+
+

Note

+

+ If you are installing Python on Windows, be sure to check the + box to have Python added to your PATH if the installer offers + such an option (it's normally off by default). +

+

+ Add Python to PATH +

+
+

+ Installing pip +

+

+ If you're using a recent version of Python, the Python package + manager, + pip, is most likely installed by default. However, you may need to + upgrade pip to the lasted version: +

+
+
pip install --upgrade pip
+
+
+ +

+ If you need to install + pip + for the first time, download + get-pip.py. + Then run the following command to install it: +

+
+
python get-pip.py
+
+
+ +

+ Installing MkDocs +

+

Install the mkdocs package using pip:

+
+
pip install mkdocs
+
+
+ +

+ You should now have the mkdocs command installed on + your system. Run mkdocs --version to check that + everything worked okay. +

+
+
$ mkdocs --version
+mkdocs, version 0.15.3
+
+
+ +
+

Note

+

+ If you would like manpages installed for MkDocs, the + click-man + tool can generate and install them for you. Simply run the + following two commands: +

+
+ + + + +
+
+
+1
+2
+
+
+
+
pip install click-man
+click-man --target path/to/man/pages mkdocs
+
+
+
+ +

+ See the + click-man documentation + for an explanation of why manpages are not automatically + generated and installed by pip. +

+
+
+

Note

+

+ If you are using Windows, some of the above commands may not + work out-of-the-box. +

+

+ A quick solution may be to preface every Python command with + python -m like this: +

+ + + + + +
+
+
+1
+2
+
+
+
+
python -m pip install mkdocs
+python -m mkdocs
+
+
+
+ +

+ For a more permanent solution, you may need to edit your + PATH environment variable to include the + Scripts directory of your Python installation. + Recent versions of Python include a script to do this for you. + Navigate to your Python installation directory (for example + C:\Python38\), open the Tools, then + Scripts folder, and run the + win_add2path.py file by double clicking on it. + Alternatively, you can + download + the script and run it (python win_add2path.py). +

+
+
+

+ Getting Started +

+

Getting started is super easy.

+
+
mkdocs new my-project
+cd my-project
+
+
+ +

+ Take a moment to review the initial project that has been + created for you. +

+

+ The initial MkDocs layout +

+

+ There's a single configuration file named + mkdocs.yml, and a folder named + docs that will contain your documentation source + files. Right now the docs folder just contains a + single documentation page, named index.md. +

+

+ MkDocs comes with a built-in dev-server that lets you preview + your documentation as you work on it. Make sure you're in the + same directory as the mkdocs.yml configuration + file, and then start the server by running the + mkdocs serve command: +

+
+
$ mkdocs serve
+INFO    -  Building documentation...
+INFO    -  Cleaning site directory
+[I 160402 15:50:43 server:271] Serving on http://127.0.0.1:8000
+[I 160402 15:50:43 handlers:58] Start watching changes
+[I 160402 15:50:43 handlers:60] Start detecting changes
+
+
+ +

+ Open up http://127.0.0.1:8000/ in your browser, and + you'll see the default home page being displayed: +

+

+ The MkDocs live server +

+

+ The dev-server also supports auto-reloading, and will rebuild + your documentation whenever anything in the configuration file, + documentation directory, or theme directory changes. +

+

+ Open the docs/index.md document in your text editor + of choice, change the initial heading to MkLorum, + and save your changes. Your browser will auto-reload and you + should see your updated documentation immediately. +

+

+ Now try editing the configuration file: mkdocs.yml. + Change the + site_name + setting to MkLorum and save the file. +

+
+
site_name: MkLorum
+
+
+ +

+ Your browser should immediately reload, and you'll see your new + site name take effect. +

+

The site_name setting

+

+ Adding pages +

+

Now add a second page to your documentation:

+
+
curl 'https://jaspervdj.be/lorem-markdownum/markdown.txt' > docs/about.md
+
+
+ +

+ As our documentation site will include some navigation headers, + you may want to edit the configuration file and add some + information about the order, title, and nesting of each page in + the navigation header by adding a + nav + setting: +

+
+
site_name: MkLorum
+nav:
+    - Home: index.md
+    - About: about.md
+
+
+ +

+ Save your changes and you'll now see a navigation bar with + Home and About items on the left as + well as Search, Previous, and + Next items on the right. +

+

Screenshot

+

+ Try the menu items and navigate back and forth between pages. + Then click on Search. A search dialog will appear, + allowing you to search for any text on any page. Notice that the + search results include every occurrence of the search term on + the site and links directly to the section of the page in which + the search term appears. You get all of that with no effort or + configuration on your part! +

+

Screenshot

+

+ Theming our documentation +

+

+ Now change the configuration file to alter how the documentation + is displayed by changing the theme. Edit the + mkdocs.yml file and add a + theme + setting: +

+
+
site_name: MkLorum
+nav:
+    - Home: index.md
+    - About: about.md
+theme: readthedocs
+
+
+ +

+ Save your changes, and you'll see the ReadTheDocs theme being + used. +

+

Screenshot

+

+ Changing the Favicon Icon +

+

+ By default, MkDocs uses the + MkDocs favicon icon. To use a + different icon, create an img subdirectory in your + docs_dir and copy your custom + favicon.ico file to that directory. MkDocs will + automatically detect and use that file as your favicon icon. +

+

+ Building the site +

+

+ That's looking good. You're ready to deploy the first pass of + your MkLorum documentation. First build the + documentation: +

+
+
mkdocs build
+
+
+ +

+ This will create a new directory, named site. Take + a look inside the directory: +

+
+
$ ls site
+about  fonts  index.html  license  search.html
+css    img    js          mkdocs   sitemap.xml
+
+
+ +

+ Notice that your source documentation has been output as two + HTML files named index.html and + about/index.html. You also have various other media + that's been copied into the site directory as part + of the documentation theme. You even have a + sitemap.xml file and + mkdocs/search_index.json. +

+

+ If you're using source code control such as git you + probably don't want to check your documentation builds into the + repository. Add a line containing site/ to your + .gitignore file. +

+
+
echo "site/" >> .gitignore
+
+
+ +

+ If you're using another source code control tool you'll want to + check its documentation on how to ignore specific directories. +

+

+ After some time, files may be removed from the documentation but + they will still reside in the site directory. To + remove those stale files, just run mkdocs with the + --clean switch. +

+
+
mkdocs build --clean
+
+
+ +

+ Other Commands and Options +

+

+ There are various other commands and options available. For a + complete list of commands, use the --help flag: +

+
+
mkdocs --help
+
+
+ +

+ To view a list of options available on a given command, use the + --help flag with that command. For example, to get + a list of all options available for the + build command run the following: +

+
+
mkdocs build --help
+
+
+ +

+ Deploying +

+

+ The documentation site that you just built only uses static + files so you'll be able to host it from pretty much anywhere. + GitHub project pages + and + Amazon S3 + may be good hosting options, depending upon your needs. Upload + the contents of the entire site directory to + wherever you're hosting your website from and you're done. For + specific instructions on a number of common hosts, see the + Deploying your Docs + page. +

+

+ Getting help +

+

+ To get help with MkDocs, please use the + discussion group, + GitHub issues + or the MkDocs IRC channel #mkdocs on freenode. +

+ + + + + + + + + + + + + + + +`; diff --git a/plugins/techdocs/src/test-utils/index.ts b/plugins/techdocs/src/test-utils/index.ts new file mode 100644 index 0000000000..f9ced5f930 --- /dev/null +++ b/plugins/techdocs/src/test-utils/index.ts @@ -0,0 +1,63 @@ +/* + * 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 FIXTURE_STANDARD_PAGE from './fixtures/mkdocs-index'; +import transformer from '../reader/transformers'; +import type { Transformer } from '../reader/transformers'; + +export const FIXTURES = { + FIXTURE_STANDARD_PAGE, +}; + +export type CreateTestShadowDomOptions = { + transformers: Transformer[]; +}; + +export const createTestShadowDom = ( + fixture: string, + opts: CreateTestShadowDomOptions = { transformers: [] }, +): ShadowRoot => { + const divElement = document.createElement('div'); + divElement.attachShadow({ mode: 'open' }); + document.body.appendChild(divElement); + + const domParser = new DOMParser().parseFromString(fixture, 'text/html'); + divElement.shadowRoot?.appendChild(domParser.documentElement); + + if (opts.transformers) { + transformer(divElement.shadowRoot!.children[0], opts.transformers); + } + + return divElement.shadowRoot!; +}; + +export const getSample = ( + shadowDom: ShadowRoot, + elementName: string, + elementAttribute: string, + sampleSize = 2, +) => { + const rootElement = shadowDom.children[0]; + + return Array.from(rootElement.getElementsByTagName(elementName)) + .filter(elem => { + return elem.hasAttribute(elementAttribute); + }) + .slice(0, sampleSize) + .map(elem => { + return elem.getAttribute(elementAttribute); + }); +}; diff --git a/yarn.lock b/yarn.lock index 18be8c3246..0e1d3d6bc3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -868,14 +868,6 @@ "@babel/plugin-transform-react-jsx-self" "^7.9.0" "@babel/plugin-transform-react-jsx-source" "^7.9.0" -"@babel/runtime-corejs2@^7.4.4": - version "7.9.2" - resolved "https://registry.npmjs.org/@babel/runtime-corejs2/-/runtime-corejs2-7.9.2.tgz#f11d074ff99b9b4319b5ecf0501f12202bf2bf4d" - integrity sha512-ayjSOxuK2GaSDJFCtLgHnYjuMyIpViNujWrZo8GUpN60/n7juzJKK5yOo6RFVb0zdU9ACJFK+MsZrUnj3OmXMw== - dependencies: - core-js "^2.6.5" - regenerator-runtime "^0.13.4" - "@babel/runtime-corejs3@^7.10.2": version "7.10.3" resolved "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.10.3.tgz#931ed6941d3954924a7aa967ee440e60c507b91a" @@ -1184,71 +1176,6 @@ unique-filename "^1.1.1" which "^1.3.1" -"@graphql-toolkit/common@0.10.7", "@graphql-toolkit/common@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/common/-/common-0.10.7.tgz#e5d4ddeae080c4f7357bc7d6a8d02e75578e9bd1" - integrity sha512-epcJvmIAo+vSEY76F0Dj1Ef6oeewT5pdMe1obHj7LHXN9V22O86aQzwdEEm1iG91qROqSw/apcDnSCMjuVeQVA== - dependencies: - aggregate-error "3.0.1" - camel-case "4.1.1" - graphql-tools "5.0.0" - lodash "4.17.15" - -"@graphql-toolkit/core@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/core/-/core-0.10.7.tgz#e4d86d9e439fbb05b634054b0865c16d488fad97" - integrity sha512-LXcFLG7XcRJrPz/xD+0cExzLx/ptVynDM20650/FbmHbKOU50d9mSbcsrzAOq/3f4q3HrRDssvn0f6pPm0EHMg== - dependencies: - "@graphql-toolkit/common" "0.10.7" - "@graphql-toolkit/schema-merging" "0.10.7" - aggregate-error "3.0.1" - globby "11.0.0" - import-from "^3.0.0" - is-glob "4.0.1" - lodash "4.17.15" - p-limit "2.3.0" - resolve-from "5.0.0" - tslib "1.11.2" - unixify "1.0.0" - valid-url "1.0.9" - -"@graphql-toolkit/graphql-file-loader@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/graphql-file-loader/-/graphql-file-loader-0.10.7.tgz#496e49712def12449b339739d3583ed1bda7a24f" - integrity sha512-6tUIuw/YBlm0VyVgXgMrOXsEQ+WpXVgr2NQwHNzmZo82kPGqImveq7A2D3gBWLyVTcinDScRcKJMxM4kCF5T0A== - dependencies: - "@graphql-toolkit/common" "0.10.7" - tslib "1.11.2" - -"@graphql-toolkit/json-file-loader@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/json-file-loader/-/json-file-loader-0.10.7.tgz#6ac38258bc3da8540b38232b71bb5820bf02cff6" - integrity sha512-nVISrODqvn5LiQ4nKL5pz1Let/W1tuj2viEwrNyTS+9mcjaCE2nhV5MOK/7ZY0cR+XeA4N2u65EH1lQd63U3Cw== - dependencies: - "@graphql-toolkit/common" "0.10.7" - tslib "1.11.2" - -"@graphql-toolkit/schema-merging@0.10.7", "@graphql-toolkit/schema-merging@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/schema-merging/-/schema-merging-0.10.7.tgz#7b33becd48629bc656d602405c0b5c17d33ebd85" - integrity sha512-VngxJbVdRfXYhdMLhL90pqN+hD/2XTZwhHPGvpWqmGQhT6roc98yN3xyDyrWFYYsuiY4gTexdmrHQ3d7mzitwA== - dependencies: - "@graphql-toolkit/common" "0.10.7" - deepmerge "4.2.2" - graphql-tools "5.0.0" - tslib "1.11.2" - -"@graphql-toolkit/url-loader@~0.10.2": - version "0.10.7" - resolved "https://registry.npmjs.org/@graphql-toolkit/url-loader/-/url-loader-0.10.7.tgz#0de998eeeaebcbf918526a26cb5f30f8250211f0" - integrity sha512-Ec3T4Zuo63LwG+RfK2ryz8ChPfncBf8fiSJ1xr68FtLDVznDNulvlNKFbfREE5koWejwsnJrjLCv6IX5IbhExg== - dependencies: - "@graphql-toolkit/common" "0.10.7" - cross-fetch "3.0.4" - graphql-tools "5.0.0" - tslib "1.11.2" - valid-url "1.0.9" - "@hapi/address@^2.1.2": version "2.1.4" resolved "https://registry.npmjs.org/@hapi/address/-/address-2.1.4.tgz#5d67ed43f3fd41a69d4b9ff7b56e7c0d1d0a81e5" @@ -4416,13 +4343,6 @@ "@webassemblyjs/wast-parser" "1.9.0" "@xtuc/long" "4.2.2" -"@wry/equality@^0.1.2": - version "0.1.11" - resolved "https://registry.npmjs.org/@wry/equality/-/equality-0.1.11.tgz#35cb156e4a96695aa81a9ecc4d03787bc17f1790" - integrity sha512-mwEVBDUVODlsQQ5dfuLUS5/Tf7jqUKyhKYHmVi4fPB6bDMOfWvUPJmKgS1Z7Za/sOI3vzWt4+O7yCiL/70MogA== - dependencies: - tslib "^1.9.3" - "@xobotyi/scrollbar-width@1.9.5": version "1.9.5" resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" @@ -4550,7 +4470,7 @@ agentkeepalive@^3.4.1: dependencies: humanize-ms "^1.2.1" -aggregate-error@3.0.1, aggregate-error@^3.0.0: +aggregate-error@^3.0.0: version "3.0.1" resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.0.1.tgz#db2fe7246e536f40d9b5442a39e117d7dd6a24e0" integrity sha512-quoaXsZ9/BLNae5yiNoUz+Nhkwz83GhWwtYFglcjEQB2NDHCIpApbqXxIFnm4Pq/Nvhrsq5sYJFyohrrxnTGAA== @@ -4708,45 +4628,6 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" -apollo-link-http-common@^0.2.14: - version "0.2.16" - resolved "https://registry.npmjs.org/apollo-link-http-common/-/apollo-link-http-common-0.2.16.tgz#756749dafc732792c8ca0923f9a40564b7c59ecc" - integrity sha512-2tIhOIrnaF4UbQHf7kjeQA/EmSorB7+HyJIIrUjJOKBgnXwuexi8aMecRlqTIDWcyVXCeqLhUnztMa6bOH/jTg== - dependencies: - apollo-link "^1.2.14" - ts-invariant "^0.4.0" - tslib "^1.9.3" - -apollo-link@^1.2.12, apollo-link@^1.2.14: - version "1.2.14" - resolved "https://registry.npmjs.org/apollo-link/-/apollo-link-1.2.14.tgz#3feda4b47f9ebba7f4160bef8b977ba725b684d9" - integrity sha512-p67CMEFP7kOG1JZ0ZkYZwRDa369w5PIjtMjvrQd/HnIV8FRsHRqLqK+oAZQnFa1DDdZtOtHTi+aMIW6EatC2jg== - dependencies: - apollo-utilities "^1.3.0" - ts-invariant "^0.4.0" - tslib "^1.9.3" - zen-observable-ts "^0.8.21" - -apollo-upload-client@^13.0.0: - version "13.0.0" - resolved "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-13.0.0.tgz#146d1ddd85d711fcac8ca97a72d3ca6787f2b71b" - integrity sha512-lJ9/bk1BH1lD15WhWRha2J3+LrXrPIX5LP5EwiOUHv8PCORp4EUrcujrA3rI5hZeZygrTX8bshcuMdpqpSrvtA== - dependencies: - "@babel/runtime" "^7.9.2" - apollo-link "^1.2.12" - apollo-link-http-common "^0.2.14" - extract-files "^8.0.0" - -apollo-utilities@^1.3.0: - version "1.3.4" - resolved "https://registry.npmjs.org/apollo-utilities/-/apollo-utilities-1.3.4.tgz#6129e438e8be201b6c55b0f13ce49d2c7175c9cf" - integrity sha512-pk2hiWrCXMAy2fRPwEyhvka+mqwzeP60Jr1tRYi5xru+3ko94HI9o6lK0CT33/w4RDlxWchmdhDCrvdr+pHCig== - dependencies: - "@wry/equality" "^0.1.2" - fast-json-stable-stringify "^2.0.0" - ts-invariant "^0.4.0" - tslib "^1.10.0" - app-root-dir@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/app-root-dir/-/app-root-dir-1.0.2.tgz#38187ec2dea7577fff033ffcb12172692ff6e118" @@ -4762,10 +4643,10 @@ aproba@^2.0.0: resolved "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== -arch@2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" - integrity sha512-BLM56aPo9vLLFVa8+/+pJLnrZ7QGGTVHWsCwieAWT9o9K8UeGaQbzZbGoabWLOo2ksBCztoXdqBZBplqLDDCSg== +arch@2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf" + integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ== are-we-there-yet@~1.1.2: version "1.1.5" @@ -5002,7 +4883,7 @@ async@^2.6.1, async@^2.6.2: dependencies: lodash "^4.17.14" -async@^3.1.0: +async@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720" integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw== @@ -5905,7 +5786,7 @@ callsites@^3.0.0: resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camel-case@4.1.1, camel-case@^4.1.1: +camel-case@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/camel-case/-/camel-case-4.1.1.tgz#1fc41c854f00e2f7d0139dfeba1542d6896fe547" integrity sha512-7fa2WcG4fYFkclIvEmxBbTvmibwF2/agfEBc6q3lOpVu0A13ltLsA+Hr/8Hp6kp5f+G7hKi6t8lys6XxP+1K6Q== @@ -6306,13 +6187,13 @@ code-point-at@^1.0.0: resolved "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c= -codemirror-graphql@^0.12.0-alpha.8: - version "0.12.0-alpha.8" - resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-0.12.0-alpha.8.tgz#cd7653299baefcdff2349bc9c3def9549c78775c" - integrity sha512-leCFSjrwtdKQE70ROWIUoc1U6F8fy6i+cd9/0D1dRbEecoleCt5+zODdANETw9map50kxNPf3EcGW4BPYj14hw== +codemirror-graphql@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-0.12.0.tgz#55f782ffaaea7ff8dbda037e6b97b584dff2402a" + integrity sha512-C/0vKzQT5Uw+DLz6/MwKer29FQM52sh9Bem+VdDW42094j8nES1sdnuqj4k5ahNdQpW4FmVeoj/ngn2g3AWmgg== dependencies: - graphql-language-service-interface "^2.4.0-alpha.8" - graphql-language-service-parser "^1.6.0-alpha.4" + graphql-language-service-interface "^2.4.0" + graphql-language-service-parser "^1.6.0" codemirror@^5.52.2: version "5.53.2" @@ -6428,21 +6309,16 @@ comma-separated-tokens@^1.0.0: resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== -commander@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/commander/-/commander-4.1.0.tgz#545983a0603fe425bc672d66c9e3c89c42121a83" - integrity sha512-NIQrwvv9V39FHgGFm36+U9SMQzbiHvU79k+iADraJTpmrFFfx7Ds0IvDoAdZsDrknlkRk14OYoWXb57uTh7/sw== +commander@4.1.1, commander@^4.0.0, commander@^4.0.1, commander@^4.1.1: + version "4.1.1" + resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" + integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== commander@^2.11.0, commander@^2.19.0, commander@^2.20.0, commander@~2.20.3: version "2.20.3" resolved "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== -commander@^4.0.0, commander@^4.0.1, commander@^4.1.1: - version "4.1.1" - resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" - integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== - commander@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" @@ -6745,7 +6621,7 @@ core-js-pure@^3.0.0, core-js-pure@^3.0.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.6.4.tgz#4bf1ba866e25814f149d4e9aaa08c36173506e3a" integrity sha512-epIhRLkXdgv32xIUFaaAry2wdxZYBi6bgM7cB136dzzXXa+dFyRLTZeLUJxnd8ShrmyVXBub63n2NHo2JAt8Cw== -core-js@^2.4.0, core-js@^2.6.5: +core-js@^2.4.0: version "2.6.11" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c" integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg== @@ -6776,17 +6652,6 @@ cors@^2.8.5: object-assign "^4" vary "^1" -cosmiconfig@6.0.0, cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.7.2" - cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a" @@ -6797,6 +6662,17 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1: js-yaml "^3.13.1" parse-json "^4.0.0" +cosmiconfig@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" + integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== + dependencies: + "@types/parse-json" "^4.0.0" + import-fresh "^3.1.0" + parse-json "^5.0.0" + path-type "^4.0.0" + yaml "^1.7.2" + create-ecdh@^4.0.0: version "4.0.3" resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff" @@ -6843,7 +6719,7 @@ cross-env@^7.0.0: dependencies: cross-spawn "^7.0.1" -cross-fetch@3.0.4, cross-fetch@^3.0.4: +cross-fetch@^3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.0.4.tgz#7bef7020207e684a7638ef5f2f698e24d9eb283c" integrity sha512-MSHgpjQqgbT/94D4CyADeNoYh52zMkCX4pcJvPP5WqPsLFMKjr2TCMg381ox5qI0ii2dPwaLx/00477knXqXVw== @@ -6902,10 +6778,10 @@ crypto-random-string@^2.0.0: resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== -css-box-model@^1.1.2: - version "1.2.0" - resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.0.tgz#3a26377b4162b3200d2ede4b064ec5b6a75186d0" - integrity sha512-lri0br+jSNV0kkkiGEp9y9y3Njq2PmpqbeGWRFQJuZteZzY9iC9GZhQ8Y4WpPwM/2YocjHePxy14igJY7YKzkA== +css-box-model@^1.2.0: + version "1.2.1" + resolved "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz#59951d3b81fd6b2074a62d49444415b0d2b4d7c1" + integrity sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw== dependencies: tiny-invariant "^1.0.6" @@ -7184,38 +7060,38 @@ cyclist@^1.0.1: integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= cypress@*, cypress@^4.2.0: - version "4.7.0" - resolved "https://registry.npmjs.org/cypress/-/cypress-4.7.0.tgz#3ea29bddaf9a1faeaa5b8d54b60a84ed1cafa83d" - integrity sha512-Vav6wUFhPRlImIND/2lOQlUnAWzgCC/iXyJlJjX9nJOJul5LC1vUpf/m8Oiae870PFPyT0ZLLwPHKTXZNdXmHw== + version "4.9.0" + resolved "https://registry.npmjs.org/cypress/-/cypress-4.9.0.tgz#c188a3864ddf841c0fdc81a9e4eff5cf539cd1c1" + integrity sha512-qGxT5E0j21FPryzhb0OBjCdhoR/n1jXtumpFFSBPYWsaZZhNaBvc3XlBUDEZKkkXPsqUFYiyhWdHN/zo0t5FcA== dependencies: "@cypress/listr-verbose-renderer" "0.4.1" "@cypress/request" "2.88.5" "@cypress/xvfb" "1.2.4" "@types/sinonjs__fake-timers" "6.0.1" "@types/sizzle" "2.3.2" - arch "2.1.1" + arch "2.1.2" bluebird "3.7.2" cachedir "2.3.0" chalk "2.4.2" check-more-types "2.24.0" cli-table3 "0.5.1" - commander "4.1.0" + commander "4.1.1" common-tags "1.8.0" debug "4.1.1" - eventemitter2 "4.1.2" + eventemitter2 "6.4.2" execa "1.0.0" executable "4.1.1" extract-zip "1.7.0" fs-extra "8.1.0" - getos "3.1.4" + getos "3.2.1" is-ci "2.0.0" - is-installed-globally "0.1.0" + is-installed-globally "0.3.2" lazy-ass "1.6.0" listr "0.14.3" lodash "4.17.15" log-symbols "3.0.0" minimist "1.2.5" - moment "2.24.0" + moment "2.26.0" ospath "1.2.2" pretty-bytes "5.3.0" ramda "0.26.1" @@ -7417,7 +7293,7 @@ deep-object-diff@^1.1.0: resolved "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.0.tgz#d6fabf476c2ed1751fc94d5ca693d2ed8c18bc5a" integrity sha512-b+QLs5vHgS+IoSNcUE4n9HP2NwcHj7aqnJWsjPtuG75Rh5TOaGt0OjAYInh77d5T16V5cRDC+Pw/6ZZZiETBGw== -deepmerge@4.2.2, deepmerge@^4.2.2: +deepmerge@^4.2.2: version "4.2.2" resolved "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== @@ -7523,11 +7399,6 @@ depd@~1.1.2: resolved "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= -deprecated-decorator@^0.1.6: - version "0.1.6" - resolved "https://registry.npmjs.org/deprecated-decorator/-/deprecated-decorator-0.1.6.tgz#00966317b7a12fe92f3cc831f7583af329b86c37" - integrity sha1-AJZjF7ehL+kvPMgx91g68ym4bDc= - deprecation@^2.0.0, deprecation@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" @@ -7663,11 +7534,6 @@ dns-packet@^1.3.1: ip "^1.1.0" safe-buffer "^5.0.1" -dns-prefetch-control@0.2.0: - version "0.2.0" - resolved "https://registry.npmjs.org/dns-prefetch-control/-/dns-prefetch-control-0.2.0.tgz#73988161841f3dcc81f47686d539a2c702c88624" - integrity sha512-hvSnros73+qyZXhHFjx2CMLwoj3Fe7eR9EJsFsqmcI1bB2OBWL/+0YzaEaKssCHnj/6crawNnUyw74Gm2EKe+Q== - dns-txt@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz#b91d806f5d27188e4ab3e7d107d881a1cc4642b6" @@ -8450,10 +8316,10 @@ event-stream@=3.3.4: stream-combiner "~0.0.4" through "~2.3.1" -eventemitter2@4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz#0e1a8477af821a6ef3995b311bf74c23a5247f15" - integrity sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU= +eventemitter2@6.4.2: + version "6.4.2" + resolved "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.2.tgz#f31f8b99d45245f0edbc5b00797830ff3b388970" + integrity sha512-r/Pwupa5RIzxIHbEKCkNXqpEQIIT4uQDxmP4G/Lug/NokVUWj0joz/WzWl3OxRpC5kDrH/WdiUJoR+IrwvXJEw== eventemitter3@^3.1.0: version "3.1.2" @@ -8676,11 +8542,6 @@ extglob@^2.0.4: snapdragon "^0.8.1" to-regex "^3.0.1" -extract-files@^8.0.0: - version "8.1.0" - resolved "https://registry.npmjs.org/extract-files/-/extract-files-8.1.0.tgz#46a0690d0fe77411a2e3804852adeaa65cd59288" - integrity sha512-PTGtfthZK79WUMk+avLmwx3NGdU8+iVFXC2NMGxKsn0MnihOG2lvumj+AZo8CTwTrwjXDgZ5tztbRlEdRjBonQ== - extract-zip@1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927" @@ -9416,12 +9277,12 @@ getopts@2.2.5: resolved "https://registry.npmjs.org/getopts/-/getopts-2.2.5.tgz#67a0fe471cacb9c687d817cab6450b96dde8313b" integrity sha512-9jb7AW5p3in+IiJWhQiZmmwkpLaR/ccTWdWQCtZM66HJcHHLegowh4q4tSD7gouUyeNvFWRavfK9GXosQHDpFA== -getos@3.1.4: - version "3.1.4" - resolved "https://registry.npmjs.org/getos/-/getos-3.1.4.tgz#29cdf240ed10a70c049add7b6f8cb08c81876faf" - integrity sha512-UORPzguEB/7UG5hqiZai8f0vQ7hzynMQyJLxStoQ8dPGAcmgsfXOPA4iE/fGtweHYkK+z4zc9V0g+CIFRf5HYw== +getos@3.2.1: + version "3.2.1" + resolved "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5" + integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q== dependencies: - async "^3.1.0" + async "^3.2.0" getpass@^0.1.1: version "0.1.7" @@ -9526,13 +9387,6 @@ glob@7.1.6, glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glo once "^1.3.0" path-is-absolute "^1.0.0" -global-dirs@^0.1.0: - version "0.1.1" - resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" - integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU= - dependencies: - ini "^1.3.4" - global-dirs@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201" @@ -9603,18 +9457,6 @@ globalthis@^1.0.0: dependencies: define-properties "^1.1.3" -globby@11.0.0: - version "11.0.0" - resolved "https://registry.npmjs.org/globby/-/globby-11.0.0.tgz#56fd0e9f0d4f8fb0c456f1ab0dee96e1380bc154" - integrity sha512-iuehFnR3xu5wBBtm4xi0dMe92Ob87ufyu/dHwpDYfbcpYpIbrO5OnS8M1vWvrBhSGEJ3/Ecj7gnX76P8YxpPEg== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.1.1" - ignore "^5.1.4" - merge2 "^1.3.0" - slash "^3.0.0" - globby@8.0.2: version "8.0.2" resolved "https://registry.npmjs.org/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d" @@ -9726,86 +9568,55 @@ graceful-fs@^4.2.4: integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== graphiql@^1.0.0-alpha.10: - version "1.0.0-alpha.10" - resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.0.0-alpha.10.tgz#9b6b5e554c6586e9bc09ebc863df8bcfd844110d" - integrity sha512-fKyYXQQRjsxOQw0rZMvICA2+P7ieUjj8AyfMaYlQeXGsGkZo0FUvBXVsN6LTraAPRBvVFo2jH9MSH68ujDXoeQ== + version "1.0.3" + resolved "https://registry.npmjs.org/graphiql/-/graphiql-1.0.3.tgz#f6b5d5c417d8f1a28786d3228a69883426ba74ad" + integrity sha512-6YW76m2exdpGiVS75I0QsbM4NanvenKFR9FtW/IFbui0mWv89iVa1FbdjhUt+kxa+LX/WlqTshiqiZRz7E5Bgg== dependencies: "@emotion/core" "^10.0.28" "@mdx-js/react" "^1.5.2" codemirror "^5.52.2" - codemirror-graphql "^0.12.0-alpha.8" + codemirror-graphql "^0.12.0" copy-to-clipboard "^3.2.0" entities "^2.0.0" markdown-it "^10.0.0" - monaco-graphql "^0.1.1" + monaco-graphql "^0.2.0" regenerator-runtime "^0.13.5" theme-ui "^0.3.1" -graphql-config@3.0.0-rc.2: - version "3.0.0-rc.2" - resolved "https://registry.npmjs.org/graphql-config/-/graphql-config-3.0.0-rc.2.tgz#760a1d7bcf8b114b7c1675c5a6ac624976d62e09" - integrity sha512-fb14mSDRatKsz2XXWgQjupbRsur/5vfQqHFFKo+N7hZNzRdT4hm+52HG8gn57mHQNZsN18q4aROwRQQRICMqZA== +graphql-language-service-interface@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.4.0.tgz#4e2e63242c76197c4f56c61122db68187ea566b3" + integrity sha512-r7DQPyhCFY5TlpEukdh9tekJ9hAc7MD9TdOsb5CfAPlsIb1/faVVo2Ty19PxGSYDxygXjwpKLOQD0LqqFuw63A== dependencies: - "@graphql-toolkit/common" "~0.10.2" - "@graphql-toolkit/core" "~0.10.2" - "@graphql-toolkit/graphql-file-loader" "~0.10.2" - "@graphql-toolkit/json-file-loader" "~0.10.2" - "@graphql-toolkit/schema-merging" "~0.10.2" - "@graphql-toolkit/url-loader" "~0.10.2" - cosmiconfig "6.0.0" - globby "11.0.0" - minimatch "3.0.4" - tslib "^1.11.1" - -graphql-language-service-interface@^2.4.0-alpha.8: - version "2.4.0-alpha.8" - resolved "https://registry.npmjs.org/graphql-language-service-interface/-/graphql-language-service-interface-2.4.0-alpha.8.tgz#2f50b107c72be01796417bcfec2669d42f3b57c7" - integrity sha512-HT77H0GQk2jtpE9NAE+f8aNKvCkv5e7cZ32RZd+lCL23qNmuLJYpQcy+pbuRxxFKOWpiZLa5lc17T010gd64rQ== - dependencies: - graphql-language-service-parser "^1.6.0-alpha.4" - graphql-language-service-types "^1.6.0-alpha.6" - graphql-language-service-utils "^2.4.0-alpha.7" + graphql-language-service-parser "^1.6.0" + graphql-language-service-types "^1.6.0" + graphql-language-service-utils "^2.4.0" vscode-languageserver-types "^3.15.1" -graphql-language-service-parser@^1.6.0-alpha.4: - version "1.6.0-alpha.4" - resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.6.0-alpha.4.tgz#5d80046b1a3848a655d8f514ef65f40261b74897" - integrity sha512-l76uie88SNsa37Noc0/Bpw3HT1UxLdIrrs5A3NDj6Fm4FHmri+0Mvgjygpa3y4Y0W3AGje2MILtuJUmUSjiriA== +graphql-language-service-parser@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/graphql-language-service-parser/-/graphql-language-service-parser-1.6.0.tgz#53a97619461ed41ae237b9070e7e20cfaca56118" + integrity sha512-tkfYXl6WBECWNcsyw7O09c0oCMrxqr3JGyDNvdISDSDhvk8EwEk+AOweBEJkycJwoiv1lVlM+EdLfj7dzw4/6w== -graphql-language-service-types@^1.6.0-alpha.6: - version "1.6.0-alpha.6" - resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.6.0-alpha.6.tgz#a7a50abbdfaf41074019d107754fc9c00ed824e2" - integrity sha512-cQfLC4CYDBVKcr3C53apqqd9imT+el3oEvIPz5/MocClWAZa4EsoEr2Z4WP9X5L4QrLk3hhFOJHor1aPzQRsLw== +graphql-language-service-types@^1.6.0: + version "1.6.0" + resolved "https://registry.npmjs.org/graphql-language-service-types/-/graphql-language-service-types-1.6.0.tgz#32529bb9d2e3b8ae9dd6d9646e15481ac5dfe9ad" + integrity sha512-7y4pkhTL+MtarC8CeHpvRYVc6pkT6ITdQXs+Gr7OClnk4Lz5nQEK0eO29kUdd0jEnYfHyh5iqhnL5Owfy+wT0A== -graphql-language-service-utils@^2.4.0-alpha.7: - version "2.4.0-alpha.7" - resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.4.0-alpha.7.tgz#a78150eb57a079af52eaffadf9f21a9b3c82cea9" - integrity sha512-iPw9aFUWAJJx+aWY9DUf4rvIKnJVB1Qoaf7NzHegEC7LMIg7052i8ealMFKiSqk0q/zP6PCmiQblCMnGCjnEwg== +graphql-language-service-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/graphql-language-service-utils/-/graphql-language-service-utils-2.4.0.tgz#d4992a1968860abc43a118de498fec84361a4b87" + integrity sha512-aLa+8iqb7AJYgdcawsKH8/KLc14DHcRsnveshOm8hN6bBVT0YiQP6mEfJoci741O74uaDF2zj1J3c02vorichw== dependencies: - graphql-language-service-types "^1.6.0-alpha.6" + graphql-language-service-types "^1.6.0" -graphql-language-service@^3.0.0-alpha.1: - version "3.0.0-alpha.1" - resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.0.0-alpha.1.tgz#dc04c0ca3f35ce322dc38548aaa023cde867ca1b" - integrity sha512-JkkONXR7+yYcm8Q4bohBJtRl6hLodgogt01lS0ROX8lcwkaihKaU+dyoSVgrRaNAibahjFrDLo0j7iAOarJ5ow== +graphql-language-service@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-3.0.0.tgz#953c3bbf4cc35ff9a7bd54e428bca17832367490" + integrity sha512-ySPrbecxtjQtzIFdCIOoSJzaco9dQvZK+wZN/tTjcSEEWh3fgMYXmctAG+WddJiZg3AB7I4Xsb2AsO5yFBbLHg== dependencies: - graphql-config "3.0.0-rc.2" - graphql-language-service-interface "^2.4.0-alpha.8" - graphql-language-service-types "^1.6.0-alpha.6" - -graphql-tools@5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/graphql-tools/-/graphql-tools-5.0.0.tgz#67281c834a0e29f458adba8018f424816fa627e9" - integrity sha512-5zn3vtn//382b7G3Wzz3d5q/sh+f7tVrnxeuhTMTJ7pWJijNqLxH7VEzv8VwXCq19zAzHYEosFHfXiK7qzvk7w== - dependencies: - apollo-link "^1.2.14" - apollo-upload-client "^13.0.0" - deprecated-decorator "^0.1.6" - form-data "^3.0.0" - iterall "^1.3.0" - node-fetch "^2.6.0" - tslib "^1.11.1" - uuid "^7.0.3" + graphql-language-service-interface "^2.4.0" + graphql-language-service-types "^1.6.0" graphql@15.1.0, graphql@^15.0.0: version "15.1.0" @@ -9997,12 +9808,11 @@ helmet-csp@2.10.0: dasherize "2.0.0" helmet@^3.22.0: - version "3.22.0" - resolved "https://registry.npmjs.org/helmet/-/helmet-3.22.0.tgz#3a6f11d931799145f0aff15dbc563cff9e13131f" - integrity sha512-Xrqicn2nm1ZIUxP3YGuTBmbDL04neKsIT583Sjh0FkiwKDXYCMUqGqC88w3NUvVXtA75JyR2Jn6jw6ZEMOD+ZA== + version "3.23.2" + resolved "https://registry.npmjs.org/helmet/-/helmet-3.23.2.tgz#390bb6f3f5e593f90f441bdd91000912c543a898" + integrity sha512-pe0UiHw3aHbP8Lon9McCq4AN2XLUMSbhwxJnUY6U2t8wTda7F1SsYg0/pBa1BPugaRqAtx9e1/FyF6E9PsUU5A== dependencies: depd "2.0.0" - dns-prefetch-control "0.2.0" dont-sniff-mimetype "1.1.0" expect-ct "0.2.0" feature-policy "0.3.0" @@ -10012,7 +9822,6 @@ helmet@^3.22.0: hide-powered-by "1.1.0" hpkp "2.0.0" hsts "2.2.0" - ienoopen "1.1.0" nocache "2.1.0" referrer-policy "1.2.0" x-xss-protection "1.3.0" @@ -10044,18 +9853,6 @@ history@5.0.0-beta.9: dependencies: "@babel/runtime" "^7.7.6" -history@^4.9.0: - version "4.10.1" - resolved "https://registry.npmjs.org/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - hmac-drbg@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -10065,7 +9862,7 @@ hmac-drbg@^1.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.1" -hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: +hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2: version "3.3.2" resolved "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== @@ -10375,11 +10172,6 @@ ieee754@^1.1.4: resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84" integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg== -ienoopen@1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/ienoopen/-/ienoopen-1.1.0.tgz#411e5d530c982287dbdc3bb31e7a9c9e32630974" - integrity sha512-MFs36e/ca6ohEKtinTJ5VvAJ6oDRAYFdYXweUnGY9L9vcoqFOU4n2ZhmJ0C4z/cwGZ3YIQRSB3XZ1+ghZkY5NQ== - iferr@^0.1.5: version "0.1.5" resolved "https://registry.npmjs.org/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" @@ -10627,9 +10419,9 @@ inquirer@^6.2.0: through "^2.3.6" inquirer@^7.0.0, inquirer@^7.0.4: - version "7.1.0" - resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" - integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg== + version "7.2.0" + resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.2.0.tgz#63ce99d823090de7eb420e4bb05e6f3449aa389a" + integrity sha512-E0c4rPwr9ByePfNlTIB8z51kK1s2n6jrHuJeEHENl/sbq2G/S1auvibgEwNR4uSyiU+PiYHqSwsgGiXjG8p5ZQ== dependencies: ansi-escapes "^4.2.1" chalk "^3.0.0" @@ -10911,13 +10703,6 @@ is-generator-fn@^2.0.0: resolved "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== -is-glob@4.0.1, is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: - version "4.0.1" - resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - is-glob@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863" @@ -10932,6 +10717,13 @@ is-glob@^3.1.0: dependencies: is-extglob "^2.1.0" +is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.1" + resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" + integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== + dependencies: + is-extglob "^2.1.1" + is-hexadecimal@^1.0.0: version "1.0.4" resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" @@ -10942,15 +10734,7 @@ is-in-browser@^1.0.2, is-in-browser@^1.1.3: resolved "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz#56ff4db683a078c6082eb95dad7dc62e1d04f835" integrity sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU= -is-installed-globally@0.1.0: - version "0.1.0" - resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80" - integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA= - dependencies: - global-dirs "^0.1.0" - is-path-inside "^1.0.0" - -is-installed-globally@^0.3.1: +is-installed-globally@0.3.2, is-installed-globally@^0.3.1: version "0.3.2" resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141" integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g== @@ -11024,13 +10808,6 @@ is-path-in-cwd@^2.0.0: dependencies: is-path-inside "^2.1.0" -is-path-inside@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036" - integrity sha1-jvW33lBDej/cprToZe96pVy0gDY= - dependencies: - path-is-inside "^1.0.1" - is-path-inside@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-2.1.0.tgz#7c9810587d659a40d27bcdb4d5616eab059494b2" @@ -11208,11 +10985,6 @@ is-yarn-global@^0.3.0: resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8= - isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -11294,11 +11066,6 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -iterall@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz#afcb08492e2915cbd8a0884eb93a8c94d0d72fea" - integrity sha512-QZ9qOMdF+QLHxy1QIpUHUU1D5pS2CG2P69LF6L6CPjPYA/XMOmKV3PZpawHoAjHNyB0swdVTRxdYT4tbBbxqwg== - iterate-iterator@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/iterate-iterator/-/iterate-iterator-1.0.1.tgz#1693a768c1ddd79c969051459453f082fe82e9f6" @@ -12539,7 +12306,7 @@ loglevel@^1.6.8: resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171" integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.3.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -12720,10 +12487,10 @@ markdown-to-jsx@^6.11.4: prop-types "^15.6.2" unquote "^1.1.0" -material-table@^1.58.0: - version "1.58.2" - resolved "https://registry.npmjs.org/material-table/-/material-table-1.58.2.tgz#dc0d19652848e6bb92f747d122bd7d4681cca6dc" - integrity sha512-s/m6ebyXFXmg07zxv1Fl6qPySKaiQhASXaOB3ubRKUFA1DkryUy3PGSEVWTjUYnRyi63kGq+N6b5nsokLR6m5A== +material-table@1.62.x: + version "1.62.0" + resolved "https://registry.npmjs.org/material-table/-/material-table-1.62.0.tgz#117793ebf16ab0fccbb6f8a670d849a7be0b5995" + integrity sha512-+3tnk32lXtkXeKM7k/hZ82jpSzlXU5CsWXqJHq4Tl0Un7ycjK2Kef6EMPqeE3i58vKNqbIvbrFf/ESH0D/Qwig== dependencies: "@date-io/date-fns" "^1.1.0" "@material-ui/pickers" "^3.2.2" @@ -12733,7 +12500,7 @@ material-table@^1.58.0: fast-deep-equal "2.0.1" filefy "0.1.10" prop-types "^15.6.2" - react-beautiful-dnd "11.0.3" + react-beautiful-dnd "^13.0.0" react-double-scrollbar "0.0.15" md5.js@^1.3.4: @@ -12772,7 +12539,7 @@ media-typer@0.3.0: resolved "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= -memoize-one@^5.0.4: +memoize-one@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== @@ -12981,14 +12748,6 @@ min-indent@^1.0.0: resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.0.tgz#cfc45c37e9ec0d8f0a0ec3dd4ef7f7c3abe39256" integrity sha1-z8RcN+nsDY8KDsPdTvf3w6vjklY= -mini-create-react-context@^0.4.0: - version "0.4.0" - resolved "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.0.tgz#df60501c83151db69e28eac0ef08b4002efab040" - integrity sha512-b0TytUgFSbgFJGzJqXPKCFCBWigAjpjo+Fl7Vf7ZbKRDptszpppKxXH6DRXEABZ/gcEQczeb0iZ7JvL8e8jjCA== - dependencies: - "@babel/runtime" "^7.5.5" - tiny-warning "^1.0.3" - mini-css-extract-plugin@^0.7.0: version "0.7.0" resolved "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0" @@ -13156,12 +12915,7 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@2.24.0: - version "2.24.0" - resolved "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" - integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== - -moment@^2.25.3, moment@^2.26.0: +moment@2.26.0, moment@^2.25.3, moment@^2.26.0: version "2.26.0" resolved "https://registry.npmjs.org/moment/-/moment-2.26.0.tgz#5e1f82c6bafca6e83e808b30c8705eed0dcbd39a" integrity sha512-oIixUO+OamkUkwjhAVE18rAMfRJNsNe/Stid/gwHSOfHrOtw9EhAY2AHvdKZ/k/MggcYELFCJz/Sn2pL8b8JMw== @@ -13171,13 +12925,13 @@ monaco-editor@^0.20.0: resolved "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz#5d5009343a550124426cb4d965a4d27a348b4dea" integrity sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ== -monaco-graphql@^0.1.1: - version "0.1.1" - resolved "https://registry.npmjs.org/monaco-graphql/-/monaco-graphql-0.1.1.tgz#d74b03a14d60805841157efe99c3ddec6e8c1407" - integrity sha512-P6K03iP2hme9geA9TR2zYkSrdhVR0/YcMBQjAaTALGvI4dIILTieGxuBeElZAZFEhsj38CNZZKUF8pKXapA86Q== +monaco-graphql@^0.2.0: + version "0.2.0" + resolved "https://registry.npmjs.org/monaco-graphql/-/monaco-graphql-0.2.0.tgz#be6bd165b6cbdc624fde6c108fe9ee32f50f2e80" + integrity sha512-IwpbJvrNlFxq6xjnQQ8eC72ep+q2vfiUaHBNkfqe314CXYnUCcXYfoMBfR5EuLAXRBOTf4CKl6BCN1/9eHrq7A== dependencies: - graphql-language-service "^3.0.0-alpha.1" - graphql-language-service-utils "^2.4.0-alpha.7" + graphql-language-service "^3.0.0" + graphql-language-service-utils "^2.4.0" monaco-editor "^0.20.0" morgan@^1.10.0: @@ -14035,13 +13789,6 @@ p-finally@^2.0.0: resolved "https://registry.npmjs.org/p-finally/-/p-finally-2.0.1.tgz#bd6fcaa9c559a096b680806f4d657b3f0f240561" integrity sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw== -p-limit@2.3.0: - version "2.3.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - p-limit@^1.1.0: version "1.3.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" @@ -14324,6 +14071,13 @@ passport-github2@^0.1.12: dependencies: passport-oauth2 "1.x.x" +passport-gitlab2@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz#ea37e5285321c026a02671e87469cac28cce9b69" + integrity sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg== + dependencies: + passport-oauth2 "^1.4.0" + passport-google-oauth20@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef" @@ -14331,7 +14085,16 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" -passport-oauth2@1.x.x: +passport-oauth1@1.x.x: + version "1.1.0" + resolved "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918" + integrity sha1-p96YiiEfnPRoc3cTDqdN8ycwyRg= + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + utils-merge "1.x.x" + +passport-oauth2@1.x.x, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108" integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ== @@ -14342,6 +14105,23 @@ passport-oauth2@1.x.x: uid2 "0.0.x" utils-merge "1.x.x" +passport-oauth@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df" + integrity sha1-kK/2M4dUDwIImvKM2tOep/gNd98= + dependencies: + passport-oauth1 "1.x.x" + passport-oauth2 "1.x.x" + +passport-okta-oauth@^0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/passport-okta-oauth/-/passport-okta-oauth-0.0.1.tgz#c8bcee02af3d56ca79d3cca776f2df7cf15a5748" + integrity sha1-yLzuAq89Vsp508yndvLffPFaV0g= + dependencies: + passport-oauth "1.0.0" + pkginfo "0.2.x" + uid2 "0.0.3" + passport-saml@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" @@ -14401,7 +14181,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.1, path-is-inside@^1.0.2: +path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -14438,13 +14218,6 @@ path-to-regexp@0.1.7: resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - path-type@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441" @@ -14590,6 +14363,11 @@ pkg-up@3.1.0, pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +pkginfo@0.2.x: + version "0.2.3" + resolved "https://registry.npmjs.org/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" + integrity sha1-cjnEKl72wwuPMoQ52bn/cQQkkPg= + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -15402,7 +15180,7 @@ quick-lru@^1.0.0: resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz#4360b17c61136ad38078397ff11416e186dcfbb8" integrity sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g= -raf-schd@^4.0.0: +raf-schd@^4.0.2: version "4.0.2" resolved "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0" integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ== @@ -15485,19 +15263,18 @@ rc@^1.2.7, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-beautiful-dnd@11.0.3: - version "11.0.3" - resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-11.0.3.tgz#5678bb3e725d8b56cb7cf57f56e952105fc4f2af" - integrity sha512-2FX2SnOlKMmfn90xUHCav7cxRWXwY7FeRa6TzdxWeX7DdP5JTvVQcsWgiOkdbJSj+J+1q1nA9QO4/HQ52D0DAA== +react-beautiful-dnd@^13.0.0: + version "13.0.0" + resolved "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.0.0.tgz#f70cc8ff82b84bc718f8af157c9f95757a6c3b40" + integrity sha512-87It8sN0ineoC3nBW0SbQuTFXM6bUqM62uJGY4BtTf0yzPl8/3+bHMWkgIe0Z6m8e+gJgjWxefGRVfpE3VcdEg== dependencies: - "@babel/runtime-corejs2" "^7.4.4" - css-box-model "^1.1.2" - memoize-one "^5.0.4" - raf-schd "^4.0.0" - react-redux "^7.0.3" - redux "^4.0.1" - tiny-invariant "^1.0.4" - use-memo-one "^1.1.0" + "@babel/runtime" "^7.8.4" + css-box-model "^1.2.0" + memoize-one "^5.1.1" + raf-schd "^4.0.2" + react-redux "^7.1.1" + redux "^4.0.4" + use-memo-one "^1.1.1" react-clientside-effect@^1.2.2: version "1.2.2" @@ -15687,7 +15464,7 @@ react-inspector@^4.0.0: is-dom "^1.0.9" prop-types "^15.6.1" -react-is@^16.12.0, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: +react-is@^16.12.0, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -15747,7 +15524,7 @@ react-popper@^1.3.6: typed-styles "^0.0.7" warning "^4.0.2" -react-redux@^7.0.3: +react-redux@^7.1.1: version "7.2.0" resolved "https://registry.npmjs.org/react-redux/-/react-redux-7.2.0.tgz#f970f62192b3981642fec46fd0db18a074fe879d" integrity sha512-EvCAZYGfOLqwV7gh849xy9/pt55rJXPwmYvI4lilPM5rUT/1NxuuN59ipdBksRVSvz0KInbPnp4IfoXJXCqiDA== @@ -15766,35 +15543,6 @@ react-router-dom@6.0.0-alpha.5, react-router-dom@^6.0.0-alpha.5: history "5.0.0-beta.9" prop-types "^15.7.2" -react-router-dom@^5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.2.0.tgz#9e65a4d0c45e13289e66c7b17c7e175d0ea15662" - integrity sha512-gxAmfylo2QUjcwxI63RhQ5G85Qqt4voZpUXSEqCwykV0baaOTQDR1f0PmY8AELqIyVc0NEZUj0Gov5lNGcXgsA== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.2.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.2.0: - version "5.2.0" - resolved "https://registry.npmjs.org/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293" - integrity sha512-smz1DUuFHRKdcJC0jobGo8cVbhO3x50tCL4icacOlcwDOEQPq4TMqwx3sY1TP+DvtTgz4nm3thuo7A+BK2U0Dw== - dependencies: - "@babel/runtime" "^7.1.2" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - mini-create-react-context "^0.4.0" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - react-router@6.0.0-alpha.5, react-router@^6.0.0-alpha.5: version "6.0.0-alpha.5" resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-alpha.5.tgz#c98805e50dc0e64787aa8aa4fa6753b435f2496b" @@ -16123,7 +15871,7 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux@^4.0.1: +redux@^4.0.4: version "4.0.5" resolved "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== @@ -16398,11 +16146,6 @@ resolve-dir@^1.0.0, resolve-dir@^1.0.1: expand-tilde "^2.0.0" global-modules "^1.0.0" -resolve-from@5.0.0, resolve-from@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" - integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== - resolve-from@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" @@ -16413,10 +16156,10 @@ resolve-from@^4.0.0: resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== resolve-url@^0.2.1: version "0.2.1" @@ -16662,20 +16405,13 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^6.3.3, rxjs@^6.5.5: +rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.5.4, rxjs@^6.5.5: version "6.5.5" resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec" integrity sha512-WfQI+1gohdf0Dai/Bbmk5L5ItH5tYqm3ki2c5GdWhKjalzjg93N3avFjVStyZZz+A2Em+ZxKH5bNghw9UeylGQ== dependencies: tslib "^1.9.0" -rxjs@^6.4.0, rxjs@^6.5.3, rxjs@^6.5.4: - version "6.5.4" - resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c" - integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q== - dependencies: - tslib "^1.9.0" - safe-buffer@5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853" @@ -18229,12 +17965,12 @@ tiny-emitter@^2.0.0: resolved "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== -tiny-invariant@^1.0.2, tiny-invariant@^1.0.4, tiny-invariant@^1.0.6: +tiny-invariant@^1.0.6: version "1.1.0" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz#634c5f8efdc27714b7f386c35e6760991d230875" integrity sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw== -tiny-warning@^1.0.0, tiny-warning@^1.0.2, tiny-warning@^1.0.3: +tiny-warning@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== @@ -18418,13 +18154,6 @@ ts-interface-checker@^0.1.9: resolved "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.10.tgz#b68a49e37e90a05797e590f08494dd528bf383cf" integrity sha512-UJYuKET7ez7ry0CnvfY6fPIUIZDw+UI3qvTUQeS2MyI4TgEeWAUBqy185LeaHcdJ9zG2dgFpPJU/AecXU0Afug== -ts-invariant@^0.4.0: - version "0.4.4" - resolved "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" - integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== - dependencies: - tslib "^1.9.3" - ts-jest@^26.0.0: version "26.1.0" resolved "https://registry.npmjs.org/ts-jest/-/ts-jest-26.1.0.tgz#e9070fc97b3ea5557a48b67c631c74eb35e15417" @@ -18483,21 +18212,11 @@ tslib@1.10.0: resolved "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== -tslib@1.11.2: - version "1.11.2" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.2.tgz#9c79d83272c9a7aaf166f73915c9667ecdde3cc9" - integrity sha512-tTSkux6IGPnUGUd1XAZHcpu85MOkIl5zX49pO+jfsie3eP0B6pyhOlLXm3cAC6T7s+euSDDUUV+Acop5WmtkVg== - tslib@^1.10.0, tslib@^1.8.1, tslib@^1.9.0, tslib@^1.9.3: version "1.11.1" resolved "https://registry.npmjs.org/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35" integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA== -tslib@^1.11.1: - version "1.13.0" - resolved "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043" - integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q== - tsutils@^3.17.1: version "3.17.1" resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.17.1.tgz#ed719917f11ca0dee586272b2ac49e015a2dd759" @@ -18609,7 +18328,7 @@ uid-number@0.0.6: resolved "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= -uid2@0.0.x: +uid2@0.0.3, uid2@0.0.x: version "0.0.3" resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" integrity sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I= @@ -18780,13 +18499,6 @@ universalify@^1.0.0: resolved "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz#b61a1da173e8435b2fe3c67d29b9adf8594bd16d" integrity sha512-rb6X1W158d7pRQBg5gkR8uPaSfiids68LTJQYOtEUhoJUWBdaQHsuT/EUduxXYxcrt4r5PJ4fuHW1MHT6p0qug== -unixify@1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/unixify/-/unixify-1.0.0.tgz#3a641c8c2ffbce4da683a5c70f03a462940c2090" - integrity sha1-OmQcjC/7zk2mg6XHDwOkYpQMIJA= - dependencies: - normalize-path "^2.1.1" - unpipe@1.0.0, unpipe@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" @@ -18892,7 +18604,7 @@ use-callback-ref@^1.2.1: resolved "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.2.1.tgz#898759ccb9e14be6c7a860abafa3ffbd826c89bb" integrity sha512-C3nvxh0ZpaOxs9RCnWwAJ+7bJPwQI8LHF71LzbQ3BvzH5XkdtlkMadqElGevg5bYBDFip4sAnD4m06zAKebg1w== -use-memo-one@^1.1.0: +use-memo-one@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c" integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ== @@ -19005,11 +18717,6 @@ v8flags@^3.1.3: dependencies: homedir-polyfill "^1.0.1" -valid-url@1.0.9: - version "1.0.9" - resolved "https://registry.npmjs.org/valid-url/-/valid-url-1.0.9.tgz#1c14479b40f1397a75782f115e4086447433a200" - integrity sha1-HBRHm0DxOXp1eC8RXkCGRHQzogA= - validate-npm-package-license@^3.0.1, validate-npm-package-license@^3.0.3: version "3.0.4" resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" @@ -19025,11 +18732,6 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - vary@^1, vary@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" @@ -19783,15 +19485,7 @@ yup@^0.29.1: synchronous-promise "^2.0.10" toposort "^2.0.2" -zen-observable-ts@^0.8.21: - version "0.8.21" - resolved "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-0.8.21.tgz#85d0031fbbde1eba3cd07d3ba90da241215f421d" - integrity sha512-Yj3yXweRc8LdRMrCC8nIc4kkjWecPAUVh0TI0OUrWXx6aX790vLcDlWca6I4vsyCGH3LpWxq0dJRcMOFoVqmeg== - dependencies: - tslib "^1.9.3" - zen-observable "^0.8.0" - -zen-observable@^0.8.0, zen-observable@^0.8.15: +zen-observable@^0.8.15: version "0.8.15" resolved "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15" integrity sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==