From 64a83bcf6273394125373a5416e206791bad3bc4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 9 Mar 2021 18:08:47 +0100 Subject: [PATCH 01/37] packages: add plugin-api Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- packages/plugin-api/.eslintrc.js | 8 + packages/plugin-api/CHANGELOG.md | 247 +++++++++++++ packages/plugin-api/README.md | 22 ++ packages/plugin-api/package.json | 59 +++ .../src/apis/definitions/AlertApi.ts | 43 +++ .../src/apis/definitions/AppThemeApi.ts | 81 +++++ .../src/apis/definitions/ConfigApi.ts | 27 ++ .../src/apis/definitions/DiscoveryApi.ts | 46 +++ .../src/apis/definitions/ErrorApi.ts | 67 ++++ .../src/apis/definitions/FeatureFlagsApi.ts | 85 +++++ .../src/apis/definitions/IdentityApi.ts | 55 +++ .../src/apis/definitions/OAuthRequestApi.ts | 132 +++++++ .../src/apis/definitions/StorageApi.ts | 70 ++++ .../plugin-api/src/apis/definitions/auth.ts | 337 ++++++++++++++++++ .../plugin-api/src/apis/definitions/index.ts | 33 ++ packages/plugin-api/src/apis/index.ts | 18 + .../plugin-api/src/apis/system/ApiRef.test.ts | 50 +++ packages/plugin-api/src/apis/system/ApiRef.ts | 50 +++ .../plugin-api/src/apis/system/helpers.ts | 49 +++ packages/plugin-api/src/apis/system/index.ts | 20 ++ packages/plugin-api/src/apis/system/types.ts | 50 +++ .../src/apis/system/useApi.test.tsx | 40 +++ .../plugin-api/src/apis/system/useApi.tsx | 38 ++ packages/plugin-api/src/app/index.ts | 18 + packages/plugin-api/src/app/types.ts | 80 +++++ packages/plugin-api/src/app/useApp.test.tsx | 34 ++ packages/plugin-api/src/app/useApp.tsx | 29 ++ .../src/extensions/componentData.test.tsx | 118 ++++++ .../src/extensions/componentData.tsx | 84 +++++ .../src/extensions/extensions.test.tsx | 76 ++++ .../plugin-api/src/extensions/extensions.tsx | 109 ++++++ packages/plugin-api/src/extensions/index.ts | 22 ++ packages/plugin-api/src/icons/index.ts | 17 + packages/plugin-api/src/icons/types.ts | 20 ++ packages/plugin-api/src/index.test.ts | 71 ++++ packages/plugin-api/src/index.ts | 22 ++ .../plugin-api/src/lib/globalObject.test.ts | 67 ++++ packages/plugin-api/src/lib/globalObject.ts | 73 ++++ .../plugin-api/src/lib/versionedValues.ts | 66 ++++ packages/plugin-api/src/plugin/Plugin.tsx | 89 +++++ packages/plugin-api/src/plugin/index.ts | 28 ++ packages/plugin-api/src/plugin/types.ts | 72 ++++ .../src/routing/ExternalRouteRef.test.ts | 119 +++++++ .../src/routing/ExternalRouteRef.ts | 84 +++++ .../plugin-api/src/routing/RouteRef.test.ts | 81 +++++ packages/plugin-api/src/routing/RouteRef.ts | 71 ++++ .../src/routing/SubRouteRef.test.ts | 134 +++++++ .../plugin-api/src/routing/SubRouteRef.ts | 128 +++++++ packages/plugin-api/src/routing/index.ts | 21 ++ packages/plugin-api/src/routing/types.ts | 80 +++++ .../src/routing/useRouteRef.test.tsx | 52 +++ .../plugin-api/src/routing/useRouteRef.tsx | 73 ++++ packages/plugin-api/src/setupTests.ts | 18 + packages/plugin-api/src/types.ts | 63 ++++ 54 files changed, 3646 insertions(+) create mode 100644 packages/plugin-api/.eslintrc.js create mode 100644 packages/plugin-api/CHANGELOG.md create mode 100644 packages/plugin-api/README.md create mode 100644 packages/plugin-api/package.json create mode 100644 packages/plugin-api/src/apis/definitions/AlertApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/AppThemeApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/ConfigApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/DiscoveryApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/ErrorApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/FeatureFlagsApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/IdentityApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/OAuthRequestApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/StorageApi.ts create mode 100644 packages/plugin-api/src/apis/definitions/auth.ts create mode 100644 packages/plugin-api/src/apis/definitions/index.ts create mode 100644 packages/plugin-api/src/apis/index.ts create mode 100644 packages/plugin-api/src/apis/system/ApiRef.test.ts create mode 100644 packages/plugin-api/src/apis/system/ApiRef.ts create mode 100644 packages/plugin-api/src/apis/system/helpers.ts create mode 100644 packages/plugin-api/src/apis/system/index.ts create mode 100644 packages/plugin-api/src/apis/system/types.ts create mode 100644 packages/plugin-api/src/apis/system/useApi.test.tsx create mode 100644 packages/plugin-api/src/apis/system/useApi.tsx create mode 100644 packages/plugin-api/src/app/index.ts create mode 100644 packages/plugin-api/src/app/types.ts create mode 100644 packages/plugin-api/src/app/useApp.test.tsx create mode 100644 packages/plugin-api/src/app/useApp.tsx create mode 100644 packages/plugin-api/src/extensions/componentData.test.tsx create mode 100644 packages/plugin-api/src/extensions/componentData.tsx create mode 100644 packages/plugin-api/src/extensions/extensions.test.tsx create mode 100644 packages/plugin-api/src/extensions/extensions.tsx create mode 100644 packages/plugin-api/src/extensions/index.ts create mode 100644 packages/plugin-api/src/icons/index.ts create mode 100644 packages/plugin-api/src/icons/types.ts create mode 100644 packages/plugin-api/src/index.test.ts create mode 100644 packages/plugin-api/src/index.ts create mode 100644 packages/plugin-api/src/lib/globalObject.test.ts create mode 100644 packages/plugin-api/src/lib/globalObject.ts create mode 100644 packages/plugin-api/src/lib/versionedValues.ts create mode 100644 packages/plugin-api/src/plugin/Plugin.tsx create mode 100644 packages/plugin-api/src/plugin/index.ts create mode 100644 packages/plugin-api/src/plugin/types.ts create mode 100644 packages/plugin-api/src/routing/ExternalRouteRef.test.ts create mode 100644 packages/plugin-api/src/routing/ExternalRouteRef.ts create mode 100644 packages/plugin-api/src/routing/RouteRef.test.ts create mode 100644 packages/plugin-api/src/routing/RouteRef.ts create mode 100644 packages/plugin-api/src/routing/SubRouteRef.test.ts create mode 100644 packages/plugin-api/src/routing/SubRouteRef.ts create mode 100644 packages/plugin-api/src/routing/index.ts create mode 100644 packages/plugin-api/src/routing/types.ts create mode 100644 packages/plugin-api/src/routing/useRouteRef.test.tsx create mode 100644 packages/plugin-api/src/routing/useRouteRef.tsx create mode 100644 packages/plugin-api/src/setupTests.ts create mode 100644 packages/plugin-api/src/types.ts diff --git a/packages/plugin-api/.eslintrc.js b/packages/plugin-api/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/plugin-api/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + // TODO: add prop types to JS and remove + 'react/prop-types': 0, + 'jest/expect-expect': 0, + }, +}; diff --git a/packages/plugin-api/CHANGELOG.md b/packages/plugin-api/CHANGELOG.md new file mode 100644 index 0000000000..0d1f8b3e11 --- /dev/null +++ b/packages/plugin-api/CHANGELOG.md @@ -0,0 +1,247 @@ +# @backstage/core-api + +## 0.2.12 + +### Patch Changes + +- 40c0fdbaa: Added support for optional external route references. By setting `optional: true` when creating an `ExternalRouteRef` it is no longer a requirement to bind the route in the app. If the app isn't bound `useRouteRef` will return `undefined`. +- 2a271d89e: Internal refactor of how component data is access to avoid polluting components and make it possible to bridge across versions. + +## 0.2.11 + +### Patch Changes + +- 3a58084b6: The `FlatRoutes` components now renders the not found page of the app if no routes are matched. +- 1407b34c6: More informative error message for missing ApiContext. +- b6c4f485d: Fix error when querying Backstage Identity with SAML authentication +- 3a58084b6: Created separate `AppContext` type to be returned from `useApp` rather than the `BackstageApp` itself. The `AppContext` type includes but deprecates `getPlugins`, `getProvider`, `getRouter`, and `getRoutes`. In addition, the `AppContext` adds a new `getComponents` method which providers access to the app components. +- Updated dependencies [a1f5e6545] + - @backstage/config@0.1.3 + +## 0.2.10 + +### Patch Changes + +- f10950bd2: Minor refactoring of BackstageApp.getSystemIcons to support custom registered + icons. Custom Icons can be added using: + + ```tsx + import AlarmIcon from '@material-ui/icons/Alarm'; + import MyPersonIcon from './MyPerson'; + + const app = createApp({ + icons: { + user: MyPersonIcon // override system icon + alert: AlarmIcon, // Custom icon + }, + }); + ``` + +- fd3f2a8c0: Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`. + +## 0.2.9 + +### Patch Changes + +- ab0892358: Remove test dependencies from production package list + +## 0.2.8 + +### Patch Changes + +- a08c32ced: Add `FlatRoutes` component to replace the top-level `Routes` component from `react-router` within apps, removing the need for manually appending `/*` to paths or sorting routes. +- 86c3c652a: Deprecate `RouteRef` path parameter and member, and remove deprecated `routeRef.createSubRouteRef`. +- 27f2af935: Delay auth loginPopup close to avoid race condition with callers of authFlowHelpers. + +## 0.2.7 + +### Patch Changes + +- d681db2b5: Fix for GitHub and SAML auth not properly updating session state when already logged in. +- 1dc445e89: Introduce new plugin extension API +- Updated dependencies [1dc445e89] + - @backstage/test-utils@0.1.6 + +## 0.2.6 + +### Patch Changes + +- 7dd2ef7d1: Use auth provider ID to create unique session storage keys for GitHub and SAML Auth. + +## 0.2.5 + +### Patch Changes + +- b6557c098: Update ApiFactory type to correctly infer API type and disallow mismatched implementations. + + This fixes for example the following code: + + ```ts + interface MyApi { + myMethod(): void + } + + const myApiRef = createApiRef({...}); + + createApiFactory({ + api: myApiRef, + deps: {}, + // This should've caused an error, since the empty object does not fully implement MyApi + factory: () => ({}), + }) + ``` + +- d8d5a17da: Deprecated the `ConcreteRoute`, `MutableRouteRef`, `AbsoluteRouteRef` types and added a new `RouteRef` type as replacement. + + Deprecated and disabled the `createSubRoute` method of `AbsoluteRouteRef`. + + Add an as of yet unused `params` option to `createRouteRef`. + +- Updated dependencies [e3bd9fc2f] +- Updated dependencies [e1f4e24ef] +- Updated dependencies [1665ae8bb] +- Updated dependencies [e3bd9fc2f] + - @backstage/config@0.1.2 + - @backstage/test-utils@0.1.5 + - @backstage/theme@0.2.2 + +## 0.2.4 + +### Patch Changes + +- b4488ddb0: Added a type alias for PositionError = GeolocationPositionError + - @backstage/test-utils@0.1.4 + +## 0.2.3 + +### Patch Changes + +- 700a212b4: bug fix: issue 3223 - detect mismatching origin and indicate it in the message at auth failure + +## 0.2.2 + +### Patch Changes + +- 9b9e86f8a: export oidc provider + +## 0.2.1 + +### Patch Changes + +- c5bab94ab: Updated the AuthApi `.create` methods to configure the default scope of the corresponding Auth Api. As a result the + default scope is configurable when overwriting the Core Api in the app. + + ``` + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user', 'repo'], + }), + ``` + + Replaced redundant CreateOptions of each Auth Api with the OAuthApiCreateOptions type. + + ``` + export type OAuthApiCreateOptions = AuthApiCreateOptions & { + oauthRequestApi: OAuthRequestApi; + defaultScopes?: string[]; + }; + + export type AuthApiCreateOptions = { + discoveryApi: DiscoveryApi; + environment?: string; + provider?: AuthProvider & { id: string }; + }; + ``` + +- Updated dependencies [4577e377b] + - @backstage/theme@0.2.1 + +## 0.2.0 + +### Minor Changes + +- 819a70229: Add SAML login to backstage + + ![](https://user-images.githubusercontent.com/872486/92251660-bb9e3400-eeff-11ea-86fe-1f2a0262cd31.png) + + ![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png) + +- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the + default scope is configurable when overwriting the Core Api in the app. + + ``` + GithubAuth.create({ + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user', 'repo'], + }), + ``` + +- cbab5bbf8: Refactored the FeatureFlagsApi to make it easier to re-implement. Existing usage of particularly getUserFlags can be replaced with isActive() or save(). + +### Patch Changes + +- cbbd271c4: Add initial RouteRefRegistry + + Starting out some work to bring routing back and working as part of the work towards finalizing #1536 + + This is some of the groundwork of an experiment we're working on to enable routing via RouteRefs, while letting the app itself look something like this: + + ```jsx + const App = () => ( + + + + {' '} + // catalogRouteRef + + + + + + + // statusRouteRef + + + + + + + + + + // sentryRouteRef + + + + + + + + + + + + + + + + ); + ``` + + As part of inverting the composition of the app, route refs and routing in general was somewhat broken, intentionally. Right now it's not really possible to easily route to different parts of the app from a plugin, or even different parts of the plugin that are not within the same router. + + The core part of the experiment is to construct a map of ApiRef[] -> path overrides. Each key in the map is the list of route refs to traversed to reach a leaf in the routing tree, and the value is the path override at that point. For example, the above tree would add entries like [techDocsRouteRef] -> '/docs', and [entityRouteRef, apiDocsRouteRef] -> '/api'. By mapping out the entire app in this structure, the idea is that we can navigate to any point in the app using RouteRefs. + + The RouteRefRegistry is an implementation of such a map, and the idea is to add it in master to make it a bit easier to experiment and iterate. This is not an exposed API at this point. + + We've explored a couple of alternatives for how to enable routing, but it's boiled down to either a solution centred around the route map mentioned above, or treating all routes as static and globally unique, with no room for flexibility, customization or conflicts between different plugins. We're starting out pursuing this options 😁. We also expect that a the app-wide routing table will make things like dynamic loading a lot cleaner, as there would be a much more clear handoff between the main chunk and dynamic chunks. + +- 26e69ab1a: Remove cost insights example client from demo app and export from plugin + Create cost insights dev plugin using example client + Make PluginConfig and dependent types public +- Updated dependencies [ae5983387] +- Updated dependencies [0d4459c08] + - @backstage/theme@0.2.0 + - @backstage/test-utils@0.1.2 diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md new file mode 100644 index 0000000000..f7b8b6c337 --- /dev/null +++ b/packages/plugin-api/README.md @@ -0,0 +1,22 @@ +# @backstage/core-api + +This package provides the core API used by Backstage plugins and apps. + +## Installation + +Do not install this package directly, it is reexported by `@backstage/core`. Install that instead: + +```sh +$ npm install --save @backstage/core +``` + +or + +```sh +$ yarn add @backstage/core +``` + +## Documentation + +- [Backstage Readme](https://github.com/backstage/backstage/blob/master/README.md) +- [Backstage Documentation](https://github.com/backstage/backstage/blob/master/docs/README.md) diff --git a/packages/plugin-api/package.json b/packages/plugin-api/package.json new file mode 100644 index 0000000000..51e4d7374d --- /dev/null +++ b/packages/plugin-api/package.json @@ -0,0 +1,59 @@ +{ + "name": "@backstage/plugin-api", + "description": "Core API used by Backstage plugins", + "version": "0.1.0", + "private": false, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/plugin-api" + }, + "keywords": [ + "backstage" + ], + "license": "Apache-2.0", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "backstage-cli build --outputs types,esm", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/config": "^0.1.3", + "@backstage/theme": "^0.2.3", + "@types/react": "^16.9", + "@types/prop-types": "^15.7.3", + "prop-types": "^15.7.2", + "react": "^16.12.0", + "react-router-dom": "6.0.0-beta.0", + "react-use": "^15.3.3", + "zen-observable": "^0.8.15" + }, + "devDependencies": { + "@backstage/cli": "^0.6.3", + "@backstage/test-utils": "^0.1.8", + "@backstage/test-utils-core": "^0.1.1", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^11.2.5", + "@testing-library/react-hooks": "^3.4.2", + "@testing-library/user-event": "^12.0.7", + "@types/jest": "^26.0.7", + "@types/node": "^12.0.0", + "@types/zen-observable": "^0.8.0", + "cross-fetch": "^3.0.6", + "msw": "^0.21.3" + }, + "files": [ + "dist" + ] +} diff --git a/packages/plugin-api/src/apis/definitions/AlertApi.ts b/packages/plugin-api/src/apis/definitions/AlertApi.ts new file mode 100644 index 0000000000..36bc1b5596 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/AlertApi.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 { createApiRef, ApiRef } from '../system'; +import { Observable } from '../../types'; + +export type AlertMessage = { + message: string; + // Severity will default to success since that is what material ui defaults the value to. + severity?: 'success' | 'info' | 'warning' | 'error'; +}; + +/** + * The alert API is used to report alerts to the app, and display them to the user. + */ + +export type AlertApi = { + /** + * Post an alert for handling by the application. + */ + post(alert: AlertMessage): void; + + /** + * Observe alerts posted by other parts of the application. + */ + alert$(): Observable; +}; + +export const alertApiRef: ApiRef = createApiRef({ + id: 'core.alert', +}); diff --git a/packages/plugin-api/src/apis/definitions/AppThemeApi.ts b/packages/plugin-api/src/apis/definitions/AppThemeApi.ts new file mode 100644 index 0000000000..df58c3977e --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/AppThemeApi.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { BackstageTheme } from '@backstage/theme'; +import { Observable } from '../../types'; + +/** + * Describes a theme provided by the app. + */ +export type AppTheme = { + /** + * ID used to remember theme selections. + */ + id: string; + + /** + * Title of the theme + */ + title: string; + + /** + * Theme variant + */ + variant: 'light' | 'dark'; + + /** + * The specialized MaterialUI theme instance. + */ + theme: BackstageTheme; + + /** + * An Icon for the theme mode setting. + */ + icon?: React.ReactElement; +}; + +/** + * The AppThemeApi gives access to the current app theme, and allows switching + * to other options that have been registered as a part of the App. + */ +export type AppThemeApi = { + /** + * Get a list of available themes. + */ + getInstalledThemes(): AppTheme[]; + + /** + * Observe the currently selected theme. A value of undefined means no specific theme has been selected. + */ + activeThemeId$(): Observable; + + /** + * Get the current theme ID. Returns undefined if no specific theme is selected. + */ + getActiveThemeId(): string | undefined; + + /** + * Set a specific theme to use in the app, overriding the default theme selection. + * + * Clear the selection by passing in undefined. + */ + setActiveThemeId(themeId?: string): void; +}; + +export const appThemeApiRef: ApiRef = createApiRef({ + id: 'core.apptheme', +}); diff --git a/packages/plugin-api/src/apis/definitions/ConfigApi.ts b/packages/plugin-api/src/apis/definitions/ConfigApi.ts new file mode 100644 index 0000000000..6fdd180a6f --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/ConfigApi.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 { ApiRef, createApiRef } from '../system'; +import { Config } from '@backstage/config'; + +/** + * The Config API is used to provide a mechanism to access the + * runtime configuration of the system. + */ +export type ConfigApi = Config; + +export const configApiRef: ApiRef = createApiRef({ + id: 'core.config', +}); diff --git a/packages/plugin-api/src/apis/definitions/DiscoveryApi.ts b/packages/plugin-api/src/apis/definitions/DiscoveryApi.ts new file mode 100644 index 0000000000..6e11c06ce5 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/DiscoveryApi.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 { ApiRef, createApiRef } from '../system'; + +/** + * The discovery API is used to provide a mechanism for plugins to + * discover the endpoint to use to talk to their backend counterpart. + * + * The purpose of the discovery API is to allow for many different deployment + * setups and routing methods through a central configuration, instead + * of letting each individual plugin manage that configuration. + * + * Implementations of the discovery API can be a simple as a URL pattern + * using the pluginId, but could also have overrides for individual plugins, + * or query a separate discovery service. + */ +export type DiscoveryApi = { + /** + * Returns the HTTP base backend URL for a given plugin, without a trailing slash. + * + * This method must always be called just before making a request, as opposed to + * fetching the URL when constructing an API client. That is to ensure that more + * flexible routing patterns can be supported. + * + * For example, asking for the URL for `auth` may return something + * like `https://backstage.example.com/api/auth` + */ + getBaseUrl(pluginId: string): Promise; +}; + +export const discoveryApiRef: ApiRef = createApiRef({ + id: 'core.discovery', +}); diff --git a/packages/plugin-api/src/apis/definitions/ErrorApi.ts b/packages/plugin-api/src/apis/definitions/ErrorApi.ts new file mode 100644 index 0000000000..ffbfa67924 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/ErrorApi.ts @@ -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 { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; + +/** + * Mirrors the JavaScript Error class, for the purpose of + * providing documentation and optional fields. + */ +type Error = { + name: string; + message: string; + stack?: string; +}; + +/** + * Provides additional information about an error that was posted to the application. + */ +export type ErrorContext = { + // If set to true, this error should not be displayed to the user. Defaults to false. + hidden?: boolean; +}; + +/** + * The error API is used to report errors to the app, and display them to the user. + * + * Plugins can use this API as a method of displaying errors to the user, but also + * to report errors for collection by error reporting services. + * + * If an error can be displayed inline, e.g. as feedback in a form, that should be + * preferred over relying on this API to display the error. The main use of this API + * for displaying errors should be for asynchronous errors, such as a failing background process. + * + * Even if an error is displayed inline, it should still be reported through this API + * if it would be useful to collect or log it for debugging purposes, but with + * the hidden flag set. For example, an error arising from form field validation + * should probably not be reported, while a failed REST call would be useful to report. + */ +export type ErrorApi = { + /** + * Post an error for handling by the application. + */ + post(error: Error, context?: ErrorContext): void; + + /** + * Observe errors posted by other parts of the application. + */ + error$(): Observable<{ error: Error; context?: ErrorContext }>; +}; + +export const errorApiRef: ApiRef = createApiRef({ + id: 'core.error', +}); diff --git a/packages/plugin-api/src/apis/definitions/FeatureFlagsApi.ts b/packages/plugin-api/src/apis/definitions/FeatureFlagsApi.ts new file mode 100644 index 0000000000..66a39aa1b1 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/FeatureFlagsApi.ts @@ -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 { ApiRef, createApiRef } from '../system'; + +/** + * The feature flags API is used to toggle functionality to users across plugins and Backstage. + * + * Plugins can use this API to register feature flags that they have available + * for users to enable/disable, and this API will centralize the current user's + * state of which feature flags they would like to enable. + * + * This is ideal for Backstage plugins, as well as your own App, to trial incomplete + * or unstable upcoming features. Although there will be a common interface for users + * to enable and disable feature flags, this API acts as another way to enable/disable. + */ + +export type FeatureFlag = { + name: string; + pluginId: string; +}; + +export enum FeatureFlagState { + None = 0, + Active = 1, +} + +/** + * Options to use when saving feature flags. + */ +export type FeatureFlagsSaveOptions = { + /** + * The new feature flag states to save. + */ + states: Record; + + /** + * Whether the saves states should be merged into the existing ones, or replace them. + * + * Defaults to false. + */ + merge?: boolean; +}; + +export type UserFlags = {}; + +export interface FeatureFlagsApi { + /** + * Registers a new feature flag. Once a feature flag has been registered it + * can be toggled by users, and read back to enable or disable features. + */ + registerFlag(flag: FeatureFlag): void; + + /** + * Get a list of all registered flags. + */ + getRegisteredFlags(): FeatureFlag[]; + + /** + * Whether the feature flag with the given name is currently activated for the user. + */ + isActive(name: string): boolean; + + /** + * Save the user's choice of feature flag states. + */ + save(options: FeatureFlagsSaveOptions): void; +} + +export const featureFlagsApiRef: ApiRef = createApiRef({ + id: 'core.featureflags', +}); diff --git a/packages/plugin-api/src/apis/definitions/IdentityApi.ts b/packages/plugin-api/src/apis/definitions/IdentityApi.ts new file mode 100644 index 0000000000..14afe65fad --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/IdentityApi.ts @@ -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 { ApiRef, createApiRef } from '../system'; +import { ProfileInfo } from './auth'; + +/** + * The Identity API used to identify and get information about the signed in user. + */ +export type IdentityApi = { + /** + * The ID of the signed in user. This ID is not meant to be presented to the user, but used + * as an opaque string to pass on to backends or use in frontend logic. + * + * TODO: The intention of the user ID is to be able to tie the user to an identity + * that is known by the catalog and/or identity backend. It should for example + * be possible to fetch all owned components using this ID. + */ + getUserId(): string; + + // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. + /** + * The profile of the signed in user. + */ + getProfile(): ProfileInfo; + + /** + * An OpenID Connect ID Token which proves the identity of the signed in user. + * + * The ID token will be undefined if the signed in user does not have a verified + * identity, such as a demo user or mocked user for e2e tests. + */ + getIdToken(): Promise; + + /** + * Sign out the current user + */ + signOut(): Promise; +}; + +export const identityApiRef: ApiRef = createApiRef({ + id: 'core.identity', +}); diff --git a/packages/plugin-api/src/apis/definitions/OAuthRequestApi.ts b/packages/plugin-api/src/apis/definitions/OAuthRequestApi.ts new file mode 100644 index 0000000000..5d79fc0759 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/OAuthRequestApi.ts @@ -0,0 +1,132 @@ +/* + * 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 { IconComponent } from '../../icons'; +import { Observable } from '../../types'; +import { ApiRef, createApiRef } from '../system'; + +/** + * Information about the auth provider that we're requesting a login towards. + * + * This should be shown to the user so that they can be informed about what login is being requested + * before a popup is shown. + */ +export type AuthProvider = { + /** + * Title for the auth provider, for example "GitHub" + */ + title: string; + + /** + * Icon for the auth provider. + */ + icon: IconComponent; +}; + +/** + * Describes how to handle auth requests. Both how to show them to the user, and what to do when + * the user accesses the auth request. + */ +export type AuthRequesterOptions = { + /** + * Information about the auth provider, which will be forwarded to auth requests. + */ + provider: AuthProvider; + + /** + * Implementation of the auth flow, which will be called synchronously when + * trigger() is called on an auth requests. + */ + onAuthRequest(scopes: Set): Promise; +}; + +/** + * Function used to trigger new auth requests for a set of scopes. + * + * The returned promise will resolve to the same value returned by the onAuthRequest in the + * AuthRequesterOptions. Or rejected, if the request is rejected. + * + * This function can be called multiple times before the promise resolves. All calls + * will be merged into one request, and the scopes forwarded to the onAuthRequest will be the + * union of all requested scopes. + */ +export type AuthRequester = ( + scopes: Set, +) => Promise; + +/** + * An pending auth request for a single auth provider. The request will remain in this pending + * state until either reject() or trigger() is called. + * + * Any new requests for the same provider are merged into the existing pending request, meaning + * there will only ever be a single pending request for a given provider. + */ +export type PendingAuthRequest = { + /** + * Information about the auth provider, as given in the AuthRequesterOptions + */ + provider: AuthProvider; + + /** + * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError". + */ + reject: () => void; + + /** + * Trigger the auth request to continue the auth flow, by for example showing a popup. + * + * Synchronously calls onAuthRequest with all scope currently in the request. + */ + trigger(): Promise; +}; + +/** + * Provides helpers for implemented OAuth login flows within Backstage. + */ +export type OAuthRequestApi = { + /** + * A utility for showing login popups or similar things, and merging together multiple requests for + * different scopes into one request that includes all scopes. + * + * The passed in options provide information about the login provider, and how to handle auth requests. + * + * The returned AuthRequester function is used to request login with new scopes. These requests + * are merged together and forwarded to the auth handler, as soon as a consumer of auth requests + * triggers an auth flow. + * + * See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info. + */ + createAuthRequester( + options: AuthRequesterOptions, + ): AuthRequester; + + /** + * Observers pending auth requests. The returned observable will emit all + * current active auth request, at most one for each created auth requester. + * + * Each request has its own info about the login provider, forwarded from the auth requester options. + * + * Depending on user interaction, the request should either be rejected, or used to trigger the auth handler. + * If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError". + * If a auth is triggered, and the auth handler resolves successfully, then all currently pending + * AuthRequester calls will resolve to the value returned by the onAuthRequest call. + */ + authRequest$(): Observable; +}; + +export const oauthRequestApiRef: ApiRef = createApiRef({ + id: 'core.oauthrequest', +}); diff --git a/packages/plugin-api/src/apis/definitions/StorageApi.ts b/packages/plugin-api/src/apis/definitions/StorageApi.ts new file mode 100644 index 0000000000..374efa5a1d --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/StorageApi.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; +import { ErrorApi } from './ErrorApi'; + +export type StorageValueChange = { + key: string; + newValue?: T; +}; + +export type CreateStorageApiOptions = { + errorApi: ErrorApi; + namespace?: string; +}; + +export interface StorageApi { + /** + * Create a bucket to store data in. + * @param {String} name Namespace for the storage to be stored under, + * will inherit previous namespaces too + */ + forBucket(name: string): StorageApi; + + /** + * Get the current value for persistent data, use observe$ to be notified of updates. + * + * @param {String} key Unique key associated with the data. + * @return {Object} data The data that should is stored. + */ + get(key: string): T | undefined; + + /** + * Remove persistent data. + * + * @param {String} key Unique key associated with the data. + */ + remove(key: string): Promise; + + /** + * Save persistent data, and emit messages to anyone that is using observe$ for this key + * + * @param {String} key Unique key associated with the data. + */ + set(key: string, data: any): Promise; + + /** + * Observe changes on a particular key in the bucket + * @param {String} key Unique key associated with the data + */ + observe$(key: string): Observable>; +} + +export const storageApiRef: ApiRef = createApiRef({ + id: 'core.storage', +}); diff --git a/packages/plugin-api/src/apis/definitions/auth.ts b/packages/plugin-api/src/apis/definitions/auth.ts new file mode 100644 index 0000000000..14e402a3c0 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/auth.ts @@ -0,0 +1,337 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, createApiRef } from '../system'; +import { Observable } from '../../types'; + +/** + * This file contains declarations for common interfaces of auth-related APIs. + * The declarations should be used to signal which type of authentication and + * authorization methods each separate auth provider supports. + * + * For example, a Google OAuth provider that supports OAuth 2 and OpenID Connect, + * would be declared as follows: + * + * const googleAuthApiRef = createApiRef({ ... }) + */ + +/** + * An array of scopes, or a scope string formatted according to the + * auth provider, which is typically a space separated list. + * + * See the documentation for each auth provider for the list of scopes + * supported by each provider. + */ +export type OAuthScope = string | string[]; + +export type AuthRequestOptions = { + /** + * If this is set to true, the user will not be prompted to log in, + * and an empty response will be returned if there is no existing session. + * + * This can be used to perform a check whether the user is logged in, or if you don't + * want to force a user to be logged in, but provide functionality if they already are. + * + * @default false + */ + optional?: boolean; + + /** + * If this is set to true, the request will bypass the regular oauth login modal + * and open the login popup directly. + * + * The method must be called synchronously from a user action for this to work in all browsers. + * + * @default false + */ + instantPopup?: boolean; +}; + +/** + * This API provides access to OAuth 2 credentials. It lets you request access tokens, + * which can be used to act on behalf of the user when talking to APIs. + */ +export type OAuthApi = { + /** + * Requests an OAuth 2 Access Token, optionally with a set of scopes. The access token allows + * you to make requests on behalf of the user, and the copes may grant you broader access, depending + * on the auth provider. + * + * Each auth provider has separate handling of scope, so you need to look at the documentation + * for each one to know what scope you need to request. + * + * This method is cheap and should be called each time an access token is used. Do not for example + * store the access token in React component state, as that could cause the token to expire. Instead + * fetch a new access token for each request. + * + * Be sure to include all required scopes when requesting an access token. When testing your implementation + * it is best to log out the Backstage session and then visit your plugin page directly, as + * you might already have some required scopes in your existing session. Not requesting the correct + * scopes can lead to 403 or other authorization errors, which can be tricky to debug. + * + * If the user has not yet granted access to the provider and the set of requested scopes, the user + * will be prompted to log in. The returned promise will not resolve until the user has + * successfully logged in. The returned promise can be rejected, but only if the user rejects the login request. + */ + getAccessToken( + scope?: OAuthScope, + options?: AuthRequestOptions, + ): Promise; +}; + +/** + * This API provides access to OpenID Connect credentials. It lets you request ID tokens, + * which can be passed to backend services to prove the user's identity. + */ +export type OpenIdConnectApi = { + /** + * Requests an OpenID Connect ID Token. + * + * This method is cheap and should be called each time an ID token is used. Do not for example + * store the id token in React component state, as that could cause the token to expire. Instead + * fetch a new id token for each request. + * + * If the user has not yet logged in to Google inside Backstage, the user will be prompted + * to log in. The returned promise will not resolve until the user has successfully logged in. + * The returned promise can be rejected, but only if the user rejects the login request. + */ + getIdToken(options?: AuthRequestOptions): Promise; +}; + +/** + * This API provides access to profile information of the user from an auth provider. + */ +export type ProfileInfoApi = { + /** + * Get profile information for the user as supplied by this auth provider. + * + * If the optional flag is not set, a session is guaranteed to be returned, while if + * the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details. + */ + getProfile(options?: AuthRequestOptions): Promise; +}; + +/** + * This API provides access to the user's identity within Backstage. + * + * An auth provider that implements this interface can be used to sign-in to backstage. It is + * not intended to be used directly from a plugin, but instead serves as a connection between + * this authentication method and the app's @IdentityApi + */ +export type BackstageIdentityApi = { + /** + * Get the user's identity within Backstage. This should normally not be called directly, + * use the @IdentityApi instead. + * + * If the optional flag is not set, a session is guaranteed to be returned, while if + * the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details. + */ + getBackstageIdentity( + options?: AuthRequestOptions, + ): Promise; +}; + +export type BackstageIdentity = { + /** + * The backstage user ID. + */ + id: string; + + /** + * An ID token that can be used to authenticate the user within Backstage. + */ + idToken: string; +}; + +/** + * Profile information of the user. + */ +export type ProfileInfo = { + /** + * Email ID. + */ + email?: string; + + /** + * Display name that can be presented to the user. + */ + displayName?: string; + + /** + * URL to an avatar image of the user. + */ + picture?: string; +}; + +/** + * Session state values passed to subscribers of the SessionApi. + */ +export enum SessionState { + SignedIn = 'SignedIn', + SignedOut = 'SignedOut', +} + +/** + * The SessionApi provides basic controls for any auth provider that is tied to a persistent session. + */ +export type SessionApi = { + /** + * Sign in with a minimum set of permissions. + */ + signIn(): Promise; + + /** + * Sign out from the current session. This will reload the page. + */ + signOut(): Promise; + + /** + * Observe the current state of the auth session. Emits the current state on subscription. + */ + sessionState$(): Observable; +}; + +/** + * Provides authentication towards Google APIs and identities. + * + * See https://developers.google.com/identity/protocols/googlescopes for a full list of supported scopes. + * + * Note that the ID token payload is only guaranteed to contain the user's numerical Google ID, + * email and expiration information. Do not rely on any other fields, as they might not be present. + */ +export const googleAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.google', +}); + +/** + * Provides authentication towards GitHub APIs. + * + * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ + * for a full list of supported scopes. + */ +export const githubAuthApiRef: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.github', +}); + +/** + * 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: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.okta', +}); + +/** + * 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: ApiRef< + OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.gitlab', +}); + +/** + * Provides authentication towards Auth0 APIs. + * + * See https://auth0.com/docs/scopes/current/oidc-scopes + * for a full list of supported scopes. + */ +export const auth0AuthApiRef: ApiRef< + OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.auth0', +}); + +/** + * Provides authentication towards Microsoft APIs and identities. + * + * For more info and a full list of supported scopes, see: + * - https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-permissions-and-consent + * - https://docs.microsoft.com/en-us/graph/permissions-reference + */ +export const microsoftAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.microsoft', +}); + +/** + * Provides authentication for custom identity providers. + */ +export const oauth2ApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.oauth2', +}); + +/** + * Provides authentication for custom OpenID Connect identity providers. + */ +export const oidcAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.oidc', +}); + +/** + * Provides authentication for saml based identity providers + */ +export const samlAuthApiRef: ApiRef< + ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.saml', +}); + +export const oneloginAuthApiRef: ApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi +> = createApiRef({ + id: 'core.auth.onelogin', +}); diff --git a/packages/plugin-api/src/apis/definitions/index.ts b/packages/plugin-api/src/apis/definitions/index.ts new file mode 100644 index 0000000000..e29d1022c4 --- /dev/null +++ b/packages/plugin-api/src/apis/definitions/index.ts @@ -0,0 +1,33 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This folder contains definitions for all core APIs. +// +// Plugins should rely on these APIs for functionality as much as possible. +// +// If you think some API definition is missing, please open an Issue or send a PR! + +export * from './auth'; + +export * from './AlertApi'; +export * from './AppThemeApi'; +export * from './ConfigApi'; +export * from './DiscoveryApi'; +export * from './ErrorApi'; +export * from './FeatureFlagsApi'; +export * from './IdentityApi'; +export * from './OAuthRequestApi'; +export * from './StorageApi'; diff --git a/packages/plugin-api/src/apis/index.ts b/packages/plugin-api/src/apis/index.ts new file mode 100644 index 0000000000..3cce4b982a --- /dev/null +++ b/packages/plugin-api/src/apis/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 './system'; +export * from './definitions'; diff --git a/packages/plugin-api/src/apis/system/ApiRef.test.ts b/packages/plugin-api/src/apis/system/ApiRef.test.ts new file mode 100644 index 0000000000..b0535e954c --- /dev/null +++ b/packages/plugin-api/src/apis/system/ApiRef.test.ts @@ -0,0 +1,50 @@ +/* + * 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 { createApiRef } from './ApiRef'; + +describe('ApiRef', () => { + it('should be created', () => { + const ref = createApiRef({ id: 'abc' }); + expect(ref.id).toBe('abc'); + expect(String(ref)).toBe('apiRef{abc}'); + expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}'); + }); + + it('should reject invalid ids', () => { + for (const id of ['a', 'abc', 'ab-c', 'a.b.c', 'a-b.c', 'abc.a-b-c.abc3']) { + expect(createApiRef({ id }).id).toBe(id); + } + + for (const id of [ + '123', + 'ab-3', + 'ab_c', + '.', + '2ac', + 'ab.3a', + '.abc', + 'abc.', + 'ab..s', + '', + '_', + ]) { + expect(() => createApiRef({ id }).id).toThrow( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${id}'`, + ); + } + }); +}); diff --git a/packages/plugin-api/src/apis/system/ApiRef.ts b/packages/plugin-api/src/apis/system/ApiRef.ts new file mode 100644 index 0000000000..470f4cec39 --- /dev/null +++ b/packages/plugin-api/src/apis/system/ApiRef.ts @@ -0,0 +1,50 @@ +/* + * 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 { ApiRef } from './types'; + +export type ApiRefConfig = { id: string }; + +class ApiRefImpl implements ApiRef { + constructor(private readonly config: ApiRefConfig) { + const valid = config.id + .split('.') + .flatMap(part => part.split('-')) + .every(part => part.match(/^[a-z][a-z0-9]*$/)); + if (!valid) { + throw new Error( + `API id must only contain period separated lowercase alphanum tokens with dashes, got '${config.id}'`, + ); + } + } + + get id(): string { + return this.config.id; + } + + // Utility for getting type of an api, using `typeof apiRef.T` + get T(): T { + throw new Error(`tried to read ApiRef.T of ${this}`); + } + + toString() { + return `apiRef{${this.config.id}}`; + } +} + +export function createApiRef(config: ApiRefConfig): ApiRef { + return new ApiRefImpl(config); +} diff --git a/packages/plugin-api/src/apis/system/helpers.ts b/packages/plugin-api/src/apis/system/helpers.ts new file mode 100644 index 0000000000..cabff73060 --- /dev/null +++ b/packages/plugin-api/src/apis/system/helpers.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiFactory, TypesToApiRefs } from './types'; + +/** + * Used to infer types for a standalone ApiFactory that isn't immediately passed + * to another function. + * This function doesn't actually do anything, it's only used to infer types. + */ +export function createApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +>(factory: ApiFactory): ApiFactory; +export function createApiFactory( + api: ApiRef, + instance: Impl, +): ApiFactory; +export function createApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +>( + factory: ApiFactory | ApiRef, + instance?: Impl, +): ApiFactory { + if ('id' in factory) { + return { + api: factory, + deps: {} as TypesToApiRefs, + factory: () => instance!, + }; + } + return factory; +} diff --git a/packages/plugin-api/src/apis/system/index.ts b/packages/plugin-api/src/apis/system/index.ts new file mode 100644 index 0000000000..5adf0054d4 --- /dev/null +++ b/packages/plugin-api/src/apis/system/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { useApi, useApiHolder } from './useApi'; +export { createApiRef } from './ApiRef'; +export * from './types'; +export * from './helpers'; diff --git a/packages/plugin-api/src/apis/system/types.ts b/packages/plugin-api/src/apis/system/types.ts new file mode 100644 index 0000000000..17f3fe276b --- /dev/null +++ b/packages/plugin-api/src/apis/system/types.ts @@ -0,0 +1,50 @@ +/* + * 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 type ApiRef = { + id: string; + T: T; +}; + +export type AnyApiRef = ApiRef; + +export type ApiRefType = T extends ApiRef ? U : never; + +export type TypesToApiRefs = { [key in keyof T]: ApiRef }; + +export type ApiRefsToTypes }> = { + [key in keyof T]: ApiRefType; +}; + +export type ApiHolder = { + get(api: ApiRef): T | undefined; +}; + +export type ApiFactory< + Api, + Impl extends Api, + Deps extends { [name in string]: unknown } +> = { + api: ApiRef; + deps: TypesToApiRefs; + factory(deps: Deps): Impl; +}; + +export type AnyApiFactory = ApiFactory< + unknown, + unknown, + { [key in string]: unknown } +>; diff --git a/packages/plugin-api/src/apis/system/useApi.test.tsx b/packages/plugin-api/src/apis/system/useApi.test.tsx new file mode 100644 index 0000000000..563ae8bc29 --- /dev/null +++ b/packages/plugin-api/src/apis/system/useApi.test.tsx @@ -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 { renderHook } from '@testing-library/react-hooks'; +import { createVersionedContextForTesting } from '../../lib/versionedValues'; +import { createApiRef } from './ApiRef'; +import { useApi } from './useApi'; + +describe('useApi', () => { + const context = createVersionedContextForTesting('api-context'); + + afterEach(() => { + context.reset(); + }); + + it('should resolve routes', () => { + const get = jest.fn(() => 'my-api-impl'); + context.set({ 1: { get } }); + + const apiRef = createApiRef({ id: 'x' }); + const renderedHook = renderHook(() => useApi(apiRef)); + + const value = renderedHook.result.current; + expect(value).toBe('my-api-impl'); + expect(get).toHaveBeenCalledWith(apiRef); + }); +}); diff --git a/packages/plugin-api/src/apis/system/useApi.tsx b/packages/plugin-api/src/apis/system/useApi.tsx new file mode 100644 index 0000000000..b55fb36c17 --- /dev/null +++ b/packages/plugin-api/src/apis/system/useApi.tsx @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ApiRef, ApiHolder } from './types'; +import { useVersionedContext } from '../../lib/versionedValues'; + +export function useApiHolder(): ApiHolder { + const versionedHolder = useVersionedContext<{ 1: ApiHolder }>('api-context'); + + const apiHolder = versionedHolder.atVersion(1); + if (!apiHolder) { + throw new Error('ApiContext v1 not available'); + } + return apiHolder; +} + +export function useApi(apiRef: ApiRef): T { + const apiHolder = useApiHolder(); + + const api = apiHolder.get(apiRef); + if (!api) { + throw new Error(`No implementation available for ${apiRef}`); + } + return api; +} diff --git a/packages/plugin-api/src/app/index.ts b/packages/plugin-api/src/app/index.ts new file mode 100644 index 0000000000..3079aef415 --- /dev/null +++ b/packages/plugin-api/src/app/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 { useApp } from './useApp'; +export * from './types'; diff --git a/packages/plugin-api/src/app/types.ts b/packages/plugin-api/src/app/types.ts new file mode 100644 index 0000000000..7ed251e490 --- /dev/null +++ b/packages/plugin-api/src/app/types.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType } from 'react'; +import { ProfileInfo } from '../apis/definitions'; +import { IconComponent } from '../icons'; + +export type BootErrorPageProps = { + step: 'load-config'; + error: Error; +}; + +export type SignInResult = { + /** + * User ID that will be returned by the IdentityApi + */ + userId: string; + + profile: ProfileInfo; + + /** + * Function used to retrieve an ID token for the signed in user. + */ + getIdToken?: () => Promise; + + /** + * Sign out handler that will be called if the user requests to sign out. + */ + signOut?: () => Promise; +}; + +export type SignInPageProps = { + /** + * Set the sign-in result for the app. This should only be called once. + */ + onResult(result: SignInResult): void; +}; + +export type AppComponents = { + NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; + Progress: ComponentType<{}>; + Router: ComponentType<{}>; + + /** + * An optional sign-in page that will be rendered instead of the AppRouter at startup. + * + * If a sign-in page is set, it will always be shown before the app, and it is up + * to the sign-in page to handle e.g. saving of login methods for subsequent visits. + * + * The sign-in page will be displayed until it has passed up a result to the parent, + * and which point the AppRouter and all of its children will be rendered instead. + */ + SignInPage?: ComponentType; +}; + +export type AppContext = { + /** + * Get a common or custom icon for this app. + */ + getSystemIcon(key: string): IconComponent | undefined; + + /** + * Get the components registered for various purposes in the app. + */ + getComponents(): AppComponents; +}; diff --git a/packages/plugin-api/src/app/useApp.test.tsx b/packages/plugin-api/src/app/useApp.test.tsx new file mode 100644 index 0000000000..037963670c --- /dev/null +++ b/packages/plugin-api/src/app/useApp.test.tsx @@ -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 { renderHook } from '@testing-library/react-hooks'; +import { createVersionedContextForTesting } from '../lib/versionedValues'; +import { useApp } from './useApp'; + +describe('v1 consumer', () => { + const context = createVersionedContextForTesting('app-context'); + + afterEach(() => { + context.reset(); + }); + + it('should provide an app context', () => { + context.set({ 1: 'context-value' }); + + const renderedHook = renderHook(() => useApp()); + expect(renderedHook.result.current).toBe('context-value'); + }); +}); diff --git a/packages/plugin-api/src/app/useApp.tsx b/packages/plugin-api/src/app/useApp.tsx new file mode 100644 index 0000000000..8979155366 --- /dev/null +++ b/packages/plugin-api/src/app/useApp.tsx @@ -0,0 +1,29 @@ +/* + * 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 { useVersionedContext } from '../lib/versionedValues'; +import { AppContext as AppContextV1 } from './types'; + +export const useApp = (): AppContextV1 => { + const versionedContext = useVersionedContext<{ 1: AppContextV1 }>( + 'app-context', + ); + const appContext = versionedContext.atVersion(1); + if (!appContext) { + throw new Error('AppContext v1 not available'); + } + return appContext; +}; diff --git a/packages/plugin-api/src/extensions/componentData.test.tsx b/packages/plugin-api/src/extensions/componentData.test.tsx new file mode 100644 index 0000000000..417fe07414 --- /dev/null +++ b/packages/plugin-api/src/extensions/componentData.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 React from 'react'; +import { attachComponentData, getComponentData } from './componentData'; + +describe('elementData', () => { + it('should attach a single piece of data', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should attach several distinct pieces of data', () => { + const data1 = { foo: 'bar' }; + const data2 = { test: 'value' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data1); + attachComponentData(Component, 'second', data2); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data1); + expect(getComponentData(element, 'second')).toBe(data2); + }); + + it('returns undefined for missing data', () => { + const data = { foo: 'bar' }; + const Component1 = () => null; + const Component2 = () => null; + attachComponentData(Component2, 'my-data', data); + + const element1 = ; + const element2 = ; + expect(getComponentData(element1, 'missing')).toBeUndefined(); + expect(getComponentData(element2, 'missing')).toBeUndefined(); + }); + + it('should throw when attempting to overwrite data', () => { + const data = { foo: 'bar' }; + const MyComponent = () => null; + attachComponentData(MyComponent, 'my-data', data); + expect(() => attachComponentData(MyComponent, 'my-data', data)).toThrow( + 'Attempted to attach duplicate data "my-data" to component "MyComponent"', + ); + }); + + describe('works across versions', () => { + function getDataSymbol() { + const Component = () => null; + attachComponentData(Component, 'my-data', {}); + const [symbol] = Object.getOwnPropertySymbols(Component); + return symbol; + } + + it('should should be able to get data from older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + expect((element as any).type[symbol].map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for older versions', () => { + const symbol = getDataSymbol(); + + const data = { foo: 'bar' }; + const Component = () => null; + (Component as any)[symbol] = { + map: new Map([['my-data', data]]), + }; + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + + it('should be able to get data from newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + attachComponentData(Component, 'my-data', data); + + const element = ; + const container = (global as any)[ + '__@backstage/component-data-store__' + ].get(element.type); + expect(container.map.get('my-data')).toBe(data); + }); + + it('should should be able to attach data for newer versions', () => { + const data = { foo: 'bar' }; + const Component = () => null; + (global as any)['__@backstage/component-data-store__'].set(Component, { + map: new Map([['my-data', data]]), + }); + + const element = ; + expect(getComponentData(element, 'my-data')).toBe(data); + }); + }); +}); diff --git a/packages/plugin-api/src/extensions/componentData.tsx b/packages/plugin-api/src/extensions/componentData.tsx new file mode 100644 index 0000000000..fc8594039c --- /dev/null +++ b/packages/plugin-api/src/extensions/componentData.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType, ReactNode } from 'react'; +import { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x +const DATA_KEY = Symbol('backstage-component-data'); + +type ComponentWithData

= ComponentType

& { + [DATA_KEY]?: DataContainer; +}; + +type DataContainer = { + map: Map; +}; + +type MaybeComponentNode = ReactNode & { + type?: ComponentType & { [DATA_KEY]?: DataContainer }; +}; + +// The store is bridged across versions using the global object +const store = getOrCreateGlobalSingleton( + 'component-data-store', + () => new WeakMap, DataContainer>(), +); + +export function attachComponentData

( + component: ComponentType

, + type: string, + data: unknown, +) { + const dataComponent = component as ComponentWithData

; + + let container = store.get(component) || dataComponent[DATA_KEY]; + if (!container) { + container = { map: new Map() }; + store.set(component, container); + dataComponent[DATA_KEY] = container; + } + + if (container.map.has(type)) { + const name = component.displayName || component.name; + throw new Error( + `Attempted to attach duplicate data "${type}" to component "${name}"`, + ); + } + + container.map.set(type, data); +} + +export function getComponentData( + node: ReactNode, + type: string, +): T | undefined { + if (!node) { + return undefined; + } + + const component = (node as MaybeComponentNode).type; + if (!component) { + return undefined; + } + + const container = store.get(component) || component[DATA_KEY]; + if (!container) { + return undefined; + } + + return container.map.get(type) as T | undefined; +} diff --git a/packages/plugin-api/src/extensions/extensions.test.tsx b/packages/plugin-api/src/extensions/extensions.test.tsx new file mode 100644 index 0000000000..29592574ec --- /dev/null +++ b/packages/plugin-api/src/extensions/extensions.test.tsx @@ -0,0 +1,76 @@ +/* + * 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 { createPlugin } from '../plugin'; +import { createRouteRef } from '../routing'; +import { getComponentData } from './componentData'; +import { + createComponentExtension, + createReactExtension, + createRoutableExtension, +} from './extensions'; + +const plugin = createPlugin({ + id: 'my-plugin', +}); + +describe('extensions', () => { + it('should create a react extension with component data', () => { + const Component = () =>

; + + const extension = createReactExtension({ + component: { + sync: Component, + }, + data: { + myData: { foo: 'bar' }, + }, + }); + + const ExtensionComponent = plugin.provide(extension); + const element = ; + + expect(getComponentData(element, 'core.plugin')).toBe(plugin); + expect(getComponentData(element, 'myData')).toEqual({ foo: 'bar' }); + }); + + it('should create react extensions of different types', () => { + const Component = () =>
; + const routeRef = createRouteRef({ id: 'foo' }); + + const extension1 = createComponentExtension({ + component: { + sync: Component, + }, + }); + + const extension2 = createRoutableExtension({ + component: () => Promise.resolve(Component), + mountPoint: routeRef, + }); + + const ExtensionComponent1 = plugin.provide(extension1); + const ExtensionComponent2 = plugin.provide(extension2); + + const element1 = ; + const element2 = ; + + expect(getComponentData(element1, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.plugin')).toBe(plugin); + expect(getComponentData(element2, 'core.mountPoint')).toBe(routeRef); + }); +}); diff --git a/packages/plugin-api/src/extensions/extensions.tsx b/packages/plugin-api/src/extensions/extensions.tsx new file mode 100644 index 0000000000..e56b6901dd --- /dev/null +++ b/packages/plugin-api/src/extensions/extensions.tsx @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { lazy, Suspense } from 'react'; +import { RouteRef, useRouteRef } from '../routing'; +import { attachComponentData } from './componentData'; +import { Extension, BackstagePlugin } from '../plugin/types'; + +type ComponentLoader = + | { + lazy: () => Promise; + } + | { + sync: T; + }; + +export function createRoutableExtension< + T extends (props: any) => JSX.Element +>(options: { + component: () => Promise; + mountPoint: RouteRef; +}): Extension { + const { component, mountPoint } = options; + return createReactExtension({ + component: { + lazy: () => + component().then(InnerComponent => { + const RoutableExtensionWrapper = ((props: any) => { + // Validate that the routing is wired up correctly in the App.tsx + try { + useRouteRef(mountPoint); + } catch { + throw new Error( + 'Routable extension component was not discovered in the app element tree. ' + + 'Routable extension components may not be rendered by other components and must be ' + + 'directly available as an element within the App provider component.', + ); + } + return ; + }) as T; + return RoutableExtensionWrapper; + }), + }, + data: { + 'core.mountPoint': mountPoint, + }, + }); +} + +export function createComponentExtension< + T extends (props: any) => JSX.Element +>(options: { component: ComponentLoader }): Extension { + const { component } = options; + return createReactExtension({ component }); +} + +export function createReactExtension< + T extends (props: any) => JSX.Element +>(options: { + component: ComponentLoader; + data?: Record; +}): Extension { + const { data = {} } = options; + + let Component: T; + if ('lazy' in options.component) { + const lazyLoader = options.component.lazy; + Component = (lazy(() => + lazyLoader().then(component => ({ default: component })), + ) as unknown) as T; + } else { + Component = options.component.sync; + } + const componentName = + (Component as { displayName?: string }).displayName || + Component.name || + 'Component'; + + return { + expose(plugin: BackstagePlugin) { + const Result: any = (props: any) => ( + + + + ); + + attachComponentData(Result, 'core.plugin', plugin); + for (const [key, value] of Object.entries(data)) { + attachComponentData(Result, key, value); + } + + Result.displayName = `Extension(${componentName})`; + return Result; + }, + }; +} diff --git a/packages/plugin-api/src/extensions/index.ts b/packages/plugin-api/src/extensions/index.ts new file mode 100644 index 0000000000..26a0c597b1 --- /dev/null +++ b/packages/plugin-api/src/extensions/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { attachComponentData, getComponentData } from './componentData'; +export { + createReactExtension, + createRoutableExtension, + createComponentExtension, +} from './extensions'; diff --git a/packages/plugin-api/src/icons/index.ts b/packages/plugin-api/src/icons/index.ts new file mode 100644 index 0000000000..50e9534751 --- /dev/null +++ b/packages/plugin-api/src/icons/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 * from './types'; diff --git a/packages/plugin-api/src/icons/types.ts b/packages/plugin-api/src/icons/types.ts new file mode 100644 index 0000000000..156ab3e0b6 --- /dev/null +++ b/packages/plugin-api/src/icons/types.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentType } from 'react'; +// import { SvgIconProps } from '@material-ui/core'; + +export type IconComponent = ComponentType; diff --git a/packages/plugin-api/src/index.test.ts b/packages/plugin-api/src/index.test.ts new file mode 100644 index 0000000000..7d642c81ff --- /dev/null +++ b/packages/plugin-api/src/index.test.ts @@ -0,0 +1,71 @@ +/* + * 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 * as index from '.'; +import { createApiRef } from '.'; + +describe('index', () => { + const ApiRef = Object.getPrototypeOf(createApiRef({ id: 'x' })).constructor; + + it('exports the plugin api', () => { + expect(index).toEqual({ + FeatureFlagState: { + 0: 'None', + 1: 'Active', + Active: 1, + None: 0, + }, + SessionState: expect.any(Object), + attachComponentData: expect.any(Function), + createApiFactory: expect.any(Function), + createComponentExtension: expect.any(Function), + createPlugin: expect.any(Function), + createReactExtension: expect.any(Function), + createRoutableExtension: expect.any(Function), + + getComponentData: expect.any(Function), + useApi: expect.any(Function), + useApiHolder: expect.any(Function), + useApp: expect.any(Function), + useRouteRef: expect.any(Function), + + createApiRef: expect.any(Function), + createExternalRouteRef: expect.any(Function), + createRouteRef: expect.any(Function), + createSubRouteRef: expect.any(Function), + + alertApiRef: expect.any(ApiRef), + appThemeApiRef: expect.any(ApiRef), + auth0AuthApiRef: expect.any(ApiRef), + configApiRef: expect.any(ApiRef), + discoveryApiRef: expect.any(ApiRef), + errorApiRef: expect.any(ApiRef), + featureFlagsApiRef: expect.any(ApiRef), + githubAuthApiRef: expect.any(ApiRef), + gitlabAuthApiRef: expect.any(ApiRef), + googleAuthApiRef: expect.any(ApiRef), + identityApiRef: expect.any(ApiRef), + microsoftAuthApiRef: expect.any(ApiRef), + oauth2ApiRef: expect.any(ApiRef), + oauthRequestApiRef: expect.any(ApiRef), + oidcAuthApiRef: expect.any(ApiRef), + oktaAuthApiRef: expect.any(ApiRef), + oneloginAuthApiRef: expect.any(ApiRef), + samlAuthApiRef: expect.any(ApiRef), + storageApiRef: expect.any(ApiRef), + }); + }); +}); diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts new file mode 100644 index 0000000000..144e125204 --- /dev/null +++ b/packages/plugin-api/src/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './apis'; +export * from './app'; +export * from './extensions'; +export * from './plugin'; +export * from './routing'; +export * from './types'; diff --git a/packages/plugin-api/src/lib/globalObject.test.ts b/packages/plugin-api/src/lib/globalObject.test.ts new file mode 100644 index 0000000000..2bf07b3142 --- /dev/null +++ b/packages/plugin-api/src/lib/globalObject.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 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 { getGlobalSingleton, getOrCreateGlobalSingleton } from './globalObject'; + +const anyGlobal = global as any; + +describe('getGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + const myOtherThing = {}; + + anyGlobal['__@backstage/my-thing__'] = myThing; + expect(getGlobalSingleton('my-thing')).toBe(myThing); + expect(getGlobalSingleton('my-thing')).toBe(myThing); + anyGlobal['__@backstage/my-thing__'] = myOtherThing; + expect(getGlobalSingleton('my-thing')).toBe(myOtherThing); + }); + + it('should throw if the value is not set', () => { + expect(() => getGlobalSingleton('my-thing')).toThrow( + 'Global my-thing is not set', + ); + }); +}); + +describe('getOrCreateGlobalSingleton', () => { + beforeEach(() => { + delete anyGlobal['__@backstage/my-thing__']; + }); + + it('should return an existing value', () => { + const myThing = {}; + anyGlobal['__@backstage/my-thing__'] = myThing; + + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myThing); + }); + + it('should should create a new value', () => { + const myNewThing = {}; + + expect(anyGlobal['__@backstage/my-thing__']).toBe(undefined); + expect(getOrCreateGlobalSingleton('my-thing', () => myNewThing)).toBe( + myNewThing, + ); + expect(anyGlobal['__@backstage/my-thing__']).toBe(myNewThing); + expect(getOrCreateGlobalSingleton('my-thing', () => ({}))).toBe(myNewThing); + }); +}); diff --git a/packages/plugin-api/src/lib/globalObject.ts b/packages/plugin-api/src/lib/globalObject.ts new file mode 100644 index 0000000000..87be58499d --- /dev/null +++ b/packages/plugin-api/src/lib/globalObject.ts @@ -0,0 +1,73 @@ +/* + * Copyright 2021 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. + */ + +// https://github.com/zloirock/core-js/issues/86#issuecomment-115759028 +function getGlobalObject() { + if (typeof window !== 'undefined' && window.Math === Math) { + return window; + } + if (typeof self !== 'undefined' && self.Math === Math) { + return self; + } + // eslint-disable-next-line no-new-func + return Function('return this')(); +} + +const globalObject = getGlobalObject(); + +const makeKey = (id: string) => `__@backstage/${id}__`; + +/** + * Used to provide a global singleton value, failing if it is already set. + */ +export function setGlobalSingleton(id: string, value: unknown): void { + const key = makeKey(id); + if (key in globalObject) { + throw new Error(`Global ${id} is already set`); // TODO some sort of special build err + } + globalObject[key] = value; +} + +/** + * Used to access a global singleton value, failing if it is not already set. + */ +export function getGlobalSingleton(id: string): T { + const key = makeKey(id); + if (!(key in globalObject)) { + throw new Error(`Global ${id} is not set`); // TODO some sort of special build err + } + + return globalObject[key]; +} + +/** + * Serializes access to a global singleton value, with the first caller creating the value. + */ +export function getOrCreateGlobalSingleton( + id: string, + supplier: () => T, +): T { + const key = makeKey(id); + + let value = globalObject[key]; + if (value) { + return value; + } + + value = supplier(); + globalObject[key] = value; + return value; +} diff --git a/packages/plugin-api/src/lib/versionedValues.ts b/packages/plugin-api/src/lib/versionedValues.ts new file mode 100644 index 0000000000..bc80425edd --- /dev/null +++ b/packages/plugin-api/src/lib/versionedValues.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2021 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 { createContext, useContext, Context } from 'react'; +import { getGlobalSingleton, setGlobalSingleton } from './globalObject'; + +/** + * The versioned value interface is a container for a set of values that + * can be looked up by version. It is intended to be used as a container + * for values that can be versioned independently of package versions. + */ +export type VersionedValue = { + atVersion( + version: Version, + ): Versions[Version] | undefined; +}; + +/** + * Creates a container for a map of versioned values that implements VersionedValue. + */ +export function createVersionedValueMap< + Versions extends { [version: number]: any } +>(versions: Versions): VersionedValue { + Object.freeze(versions); + return { + atVersion(version) { + return versions[version]; + }, + }; +} + +export function useVersionedContext< + Versions extends { [version in number]: any } +>(key: string): VersionedValue { + const versionedValue = useContext( + getGlobalSingleton>>(key), + ); + if (!versionedValue) { + throw new Error(`No provider available for ${key} context`); + } + return versionedValue; +} + +export function createVersionedContextForTesting(key: string) { + return { + set(versions: { [version in number]: unknown }) { + setGlobalSingleton(key, createContext(createVersionedValueMap(versions))); + }, + reset() { + delete (globalThis as any)[`__@backstage/${key}__`]; + }, + }; +} diff --git a/packages/plugin-api/src/plugin/Plugin.tsx b/packages/plugin-api/src/plugin/Plugin.tsx new file mode 100644 index 0000000000..428d9d9d48 --- /dev/null +++ b/packages/plugin-api/src/plugin/Plugin.tsx @@ -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 { + PluginConfig, + PluginOutput, + BackstagePlugin, + Extension, + AnyRoutes, + AnyExternalRoutes, +} from './types'; +import { AnyApiFactory } from '../apis'; + +export class PluginImpl< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> implements BackstagePlugin { + private storedOutput?: PluginOutput[]; + + constructor(private readonly config: PluginConfig) {} + + getId(): string { + return this.config.id; + } + + getApis(): Iterable { + return this.config.apis ?? []; + } + + get routes(): Routes { + return this.config.routes ?? ({} as Routes); + } + + get externalRoutes(): ExternalRoutes { + return this.config.externalRoutes ?? ({} as ExternalRoutes); + } + + output(): PluginOutput[] { + if (this.storedOutput) { + return this.storedOutput; + } + if (!this.config.register) { + return []; + } + + const outputs = new Array(); + + this.config.register({ + featureFlags: { + register(name) { + outputs.push({ type: 'feature-flag', name }); + }, + }, + }); + + this.storedOutput = outputs; + return this.storedOutput; + } + + provide(extension: Extension): T { + return extension.expose(this); + } + + toString() { + return `plugin{${this.config.id}}`; + } +} + +export function createPlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +>( + config: PluginConfig, +): BackstagePlugin { + return new PluginImpl(config); +} diff --git a/packages/plugin-api/src/plugin/index.ts b/packages/plugin-api/src/plugin/index.ts new file mode 100644 index 0000000000..860aad3d1d --- /dev/null +++ b/packages/plugin-api/src/plugin/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 { createPlugin } from './Plugin'; +export type { + BackstagePlugin, + Extension, + FeatureFlagOutput, + FeatureFlagsHooks, + PluginConfig, + PluginHooks, + PluginOutput, + RouteOptions, + RoutePath, +} from './types'; diff --git a/packages/plugin-api/src/plugin/types.ts b/packages/plugin-api/src/plugin/types.ts new file mode 100644 index 0000000000..fbaafd344c --- /dev/null +++ b/packages/plugin-api/src/plugin/types.ts @@ -0,0 +1,72 @@ +/* + * 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 { RouteRef, ExternalRouteRef } from '../routing'; +import { AnyApiFactory } from '../apis/system'; + +export type RouteOptions = { + // Whether the route path must match exactly, defaults to true. + exact?: boolean; +}; + +export type RoutePath = string; + +// Replace with using RouteRefs +export type FeatureFlagOutput = { + type: 'feature-flag'; + name: string; +}; + +export type PluginOutput = FeatureFlagOutput; + +export type Extension = { + expose(plugin: BackstagePlugin): T; +}; + +export type AnyRoutes = { [name: string]: RouteRef }; + +export type AnyExternalRoutes = { [name: string]: ExternalRouteRef }; + +export type BackstagePlugin< + Routes extends AnyRoutes = {}, + ExternalRoutes extends AnyExternalRoutes = {} +> = { + getId(): string; + output(): PluginOutput[]; + getApis(): Iterable; + provide(extension: Extension): T; + routes: Routes; + externalRoutes: ExternalRoutes; +}; + +export type PluginConfig< + Routes extends AnyRoutes, + ExternalRoutes extends AnyExternalRoutes +> = { + id: string; + apis?: Iterable; + register?(hooks: PluginHooks): void; + routes?: Routes; + externalRoutes?: ExternalRoutes; +}; + +export type PluginHooks = { + featureFlags: FeatureFlagsHooks; +}; + +export type FeatureFlagsHooks = { + register(name: string): void; +}; diff --git a/packages/plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/plugin-api/src/routing/ExternalRouteRef.test.ts new file mode 100644 index 0000000000..785ad2732f --- /dev/null +++ b/packages/plugin-api/src/routing/ExternalRouteRef.test.ts @@ -0,0 +1,119 @@ +/* + * 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 { AnyParams, ExternalRouteRef } from './types'; +import { createExternalRouteRef, isExternalRouteRef } from './ExternalRouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isRouteRef } from './RouteRef'; + +describe('ExternalRouteRef', () => { + it('should be created', () => { + const routeRef: ExternalRouteRef = createExternalRouteRef({ + id: 'my-route-ref', + }); + expect(routeRef.params).toEqual([]); + expect(routeRef.optional).toBe(false); + expect(String(routeRef)).toBe('routeRef{type=external,id=my-route-ref}'); + expect(isRouteRef(routeRef)).toBe(false); + expect(isSubRouteRef(routeRef)).toBe(false); + expect(isExternalRouteRef(routeRef)).toBe(true); + + expect(isRouteRef({} as ExternalRouteRef)).toBe(false); + }); + + it('should be created as optional', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: [], + optional: true, + }); + expect(routeRef.params).toEqual([]); + expect(routeRef.optional).toEqual(true); + }); + + it('should be created with params', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + }); + expect(routeRef.params).toEqual(['x', 'y']); + expect(routeRef.optional).toEqual(false); + }); + + it('should be created as optional with params', () => { + const routeRef: ExternalRouteRef<{ + x: string; + y: string; + }> = createExternalRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + optional: true, + }); + expect(routeRef.params).toEqual(['x', 'y']); + expect(routeRef.optional).toEqual(true); + }); + + it('should properly infer and validate parameter types and assignments', () => { + function validateType( + _ref: ExternalRouteRef, + ) {} + + const _1 = createExternalRouteRef({ id: '1', params: ['notX'] }); + // @ts-expect-error + validateType<{ x: string }, any>(_1); + validateType<{ notX: string }, any>(_1); + + const _2 = createExternalRouteRef({ + id: '2', + params: ['x'], + optional: true, + }); + // @ts-expect-error + validateType(_2); + validateType<{ x: string }, true>(_2); + + const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] }); + // @ts-expect-error + validateType<{ x: string }, any>(_3); + // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + validateType<{ x: string; y: string; z: string }, any>(_3); + validateType<{ x: string; y: string }, false>(_3); + + const _4 = createExternalRouteRef({ id: '4', params: [] }); + // @ts-expect-error + validateType<{ x: string }, any>(_4); + validateType(_4); + + const _5 = createExternalRouteRef({ id: '5' }); + // @ts-expect-error + validateType<{ x: string }, any>(_5); + validateType(_5); + + const _6 = createExternalRouteRef({ id: '6', optional: true }); + // @ts-expect-error + validateType(_6); + validateType(_6); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4, _5, _6].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/plugin-api/src/routing/ExternalRouteRef.ts b/packages/plugin-api/src/routing/ExternalRouteRef.ts new file mode 100644 index 0000000000..e6af9cf8a7 --- /dev/null +++ b/packages/plugin-api/src/routing/ExternalRouteRef.ts @@ -0,0 +1,84 @@ +/* + * 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 { + RouteRef, + SubRouteRef, + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; + +export class ExternalRouteRefImpl< + Params extends AnyParams, + Optional extends boolean +> implements ExternalRouteRef { + readonly [routeRefType] = 'external'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + readonly optional: Optional, + ) {} + + toString() { + return `routeRef{type=external,id=${this.id}}`; + } +} + +export function createExternalRouteRef< + Params extends { [param in ParamKey]: string }, + Optional extends boolean = false, + ParamKey extends string = never +>(options: { + /** + * An identifier for this route, used to identify it in error messages + */ + id: string; + + /** + * The parameters that will be provided to the external route reference. + */ + params?: ParamKey[]; + + /** + * Whether or not this route is optional, defaults to false. + * + * Optional external routes are not required to be bound in the app, and + * if they aren't, `useRouteRef` will return `undefined`. + */ + optional?: Optional; +}): ExternalRouteRef, Optional> { + return new ExternalRouteRefImpl( + options.id, + (options.params ?? []) as ParamKeys>, + Boolean(options.optional) as Optional, + ); +} + +export function isExternalRouteRef< + Params extends AnyParams, + Optional extends boolean +>( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is ExternalRouteRef { + return routeRef[routeRefType] === 'external'; +} diff --git a/packages/plugin-api/src/routing/RouteRef.test.ts b/packages/plugin-api/src/routing/RouteRef.test.ts new file mode 100644 index 0000000000..a2fa9b246d --- /dev/null +++ b/packages/plugin-api/src/routing/RouteRef.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { AnyParams, RouteRef } from './types'; +import { createRouteRef, isRouteRef } from './RouteRef'; +import { isSubRouteRef } from './SubRouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; + +describe('RouteRef', () => { + it('should be created', () => { + const routeRef: RouteRef = createRouteRef({ + id: 'my-route-ref', + }); + expect(routeRef.params).toEqual([]); + expect(String(routeRef)).toBe('routeRef{type=absolute,id=my-route-ref}'); + expect(isRouteRef(routeRef)).toBe(true); + expect(isSubRouteRef(routeRef)).toBe(false); + expect(isExternalRouteRef(routeRef)).toBe(false); + + expect(isRouteRef({} as RouteRef)).toBe(false); + }); + + it('should be created with params', () => { + const routeRef: RouteRef<{ + x: string; + y: string; + }> = createRouteRef({ + id: 'my-other-route-ref', + params: ['x', 'y'], + }); + expect(routeRef.params).toEqual(['x', 'y']); + }); + + it('should properly infer and validate parameter types and assignments', () => { + function validateType(_ref: RouteRef) {} + + const _1 = createRouteRef({ id: '1', params: ['x'] }); + // @ts-expect-error + validateType<{ y: string }>(_1); + // @ts-expect-error + validateType(_1); + validateType<{ x: string }>(_1); + + const _2 = createRouteRef({ id: '2', params: ['x', 'y'] }); + // @ts-expect-error + validateType<{ x: string }>(_2); + // @ts-expect-error + validateType(_2); + // @ts-expect-error + validateType<{ x: string; z: string }>(_2); + // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + validateType<{ x: string; y: string; z: string }>(_2); + validateType<{ x: string; y: string }>(_2); + + const _3 = createRouteRef({ id: '3', params: [] }); + // @ts-expect-error + validateType<{ x: string }>(_3); + validateType(_3); + + const _4 = createRouteRef({ id: '4' }); + // @ts-expect-error + validateType<{ x: string }>(_4); + validateType(_4); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/plugin-api/src/routing/RouteRef.ts b/packages/plugin-api/src/routing/RouteRef.ts new file mode 100644 index 0000000000..5c641e971a --- /dev/null +++ b/packages/plugin-api/src/routing/RouteRef.ts @@ -0,0 +1,71 @@ +/* + * 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 { + RouteRef, + SubRouteRef, + ExternalRouteRef, + routeRefType, + AnyParams, + ParamKeys, + OptionalParams, +} from './types'; + +export class RouteRefImpl + implements RouteRef { + readonly [routeRefType] = 'absolute'; + + constructor( + private readonly id: string, + readonly params: ParamKeys, + ) {} + + toString() { + return `routeRef{type=absolute,id=${this.id}}`; + } +} + +export function createRouteRef< + // Params is the type that we care about and the one to be embedded in the route ref. + // For example, given the params ['name', 'kind'], Params will be {name: string, kind: string} + Params extends { [param in ParamKey]: string }, + // ParamKey is here to make sure the Params type properly has its keys narrowed down + // to only the elements of params. Defaulting to never makes sure we end up with + // Param = {} if the params array is empty. + ParamKey extends string = never +>(config: { + /** The id of the route ref, used to identify it when printed */ + id: string; + /** A list of parameter names that the path that this route ref is bound to must contain */ + params?: ParamKey[]; +}): RouteRef> { + if (!config.id) { + throw new Error('RouteRef must be provided a non-empty id'); + } + return new RouteRefImpl( + config.id, + (config.params ?? []) as ParamKeys>, + ); +} + +export function isRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is RouteRef { + return routeRef[routeRefType] === 'absolute'; +} diff --git a/packages/plugin-api/src/routing/SubRouteRef.test.ts b/packages/plugin-api/src/routing/SubRouteRef.test.ts new file mode 100644 index 0000000000..1c1a3c1b21 --- /dev/null +++ b/packages/plugin-api/src/routing/SubRouteRef.test.ts @@ -0,0 +1,134 @@ +/* + * 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 { AnyParams, SubRouteRef } from './types'; +import { createSubRouteRef, isSubRouteRef } from './SubRouteRef'; +import { createRouteRef, isRouteRef } from './RouteRef'; +import { isExternalRouteRef } from './ExternalRouteRef'; + +const parent = createRouteRef({ id: 'parent' }); +const parentX = createRouteRef({ id: 'parent-x', params: ['x'] }); + +describe('SubRouteRef', () => { + it('should be created', () => { + const routeRef: SubRouteRef = createSubRouteRef({ + parent, + id: 'my-route-ref', + path: '/foo', + }); + expect(routeRef.path).toBe('/foo'); + expect(routeRef.parent).toBe(parent); + expect(routeRef.params).toEqual([]); + expect(String(routeRef)).toBe('routeRef{type=sub,id=my-route-ref}'); + expect(isRouteRef(routeRef)).toBe(false); + expect(isSubRouteRef(routeRef)).toBe(true); + expect(isExternalRouteRef(routeRef)).toBe(false); + + expect(isRouteRef({} as SubRouteRef)).toBe(false); + }); + + it('should be created with params', () => { + const routeRef: SubRouteRef<{ bar: string }> = createSubRouteRef({ + parent, + id: 'my-other-route-ref', + path: '/foo/:bar', + }); + expect(routeRef.path).toBe('/foo/:bar'); + expect(routeRef.parent).toBe(parent); + expect(routeRef.params).toEqual(['bar']); + }); + + it('should be created with merged params', () => { + const routeRef: SubRouteRef<{ + x: string; + y: string; + z: string; + }> = createSubRouteRef({ + parent: parentX, + id: 'my-other-route-ref', + path: '/foo/:y/:z', + }); + expect(routeRef.path).toBe('/foo/:y/:z'); + expect(routeRef.parent).toBe(parentX); + expect(routeRef.params).toEqual(['x', 'y', 'z']); + }); + + it('should be created with params from parent', () => { + const routeRef: SubRouteRef<{ x: string }> = createSubRouteRef({ + parent: parentX, + id: 'my-other-route-ref', + path: '/foo/bar', + }); + expect(routeRef.path).toBe('/foo/bar'); + expect(routeRef.parent).toBe(parentX); + expect(routeRef.params).toEqual(['x']); + }); + + it.each([ + ['foo', "SubRouteRef path must start with '/', got 'foo'"], + [':foo', "SubRouteRef path must start with '/', got ':foo'"], + ['', "SubRouteRef path must start with '/', got ''"], + ['/', "SubRouteRef path must not end with '/', got '/'"], + ['/foo/', "SubRouteRef path must not end with '/', got '/foo/'"], + ['/foo/:x', 'SubRouteRef may not have params that overlap with its parent'], + ['/:/foo', "SubRouteRef path has invalid param, got ''"], + ['/:inva:lid/foo', "SubRouteRef path has invalid param, got 'inva:lid'"], + ['/:inva=lid/foo', "SubRouteRef path has invalid param, got 'inva=lid'"], + ])('should throw if path is invalid, %s', (path, message) => { + expect(() => + createSubRouteRef({ path, parent: parentX, id: path }), + ).toThrow(message); + }); + + it('should properly infer and parse path parameters', () => { + function validateType(_ref: SubRouteRef) {} + + const _1 = createSubRouteRef({ id: '1', parent, path: '/foo/bar' }); + // @ts-expect-error + validateType<{ x: string }>(_1); + validateType(_1); + + const _2 = createSubRouteRef({ id: '2', parent, path: '/foo/:x/:y' }); + // @ts-expect-error + validateType(_2); + // @ts-expect-error + validateType<{ x: string; z: string }>(_2); + // @ts-expect-error + validateType<{ y: string }>(_2); + // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + validateType<{ x: string; y: string; z: string }>(_2); + validateType<{ x: string; y: string }>(_2); + + const _3 = createSubRouteRef({ id: '3', parent: parentX, path: '/foo' }); + // @ts-expect-error + validateType(_3); + // @ts-expect-error + validateType<{ y: string }>(_3); + validateType<{ x: string }>(_3); + + const _4 = createSubRouteRef({ id: '4', parent: parentX, path: '/foo/:y' }); + // @ts-expect-error + validateType(_4); + // @ts-expect-error + validateType<{ x: string; z: string }>(_4); + // @ts-expect-error + validateType<{ y: string }>(_4); + validateType<{ x: string; y: string }>(_4); + + // To avoid complains about missing expectations and unused vars + expect([_1, _2, _3, _4].join('')).toEqual(expect.any(String)); + }); +}); diff --git a/packages/plugin-api/src/routing/SubRouteRef.ts b/packages/plugin-api/src/routing/SubRouteRef.ts new file mode 100644 index 0000000000..7ddfc89c80 --- /dev/null +++ b/packages/plugin-api/src/routing/SubRouteRef.ts @@ -0,0 +1,128 @@ +/* + * 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 { + AnyParams, + ExternalRouteRef, + OptionalParams, + ParamKeys, + RouteRef, + routeRefType, + SubRouteRef, +} from './types'; + +// Should match the pattern in react-router +const PARAM_PATTERN = /^\w+$/; + +export class SubRouteRefImpl + implements SubRouteRef { + readonly [routeRefType] = 'sub'; + + constructor( + private readonly id: string, + readonly path: string, + readonly parent: RouteRef, + readonly params: ParamKeys, + ) {} + + toString() { + return `routeRef{type=sub,id=${this.id}}`; + } +} + +// These utility types help us infer a Param object type from a string path +// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }` +type ParamPart = S extends `:${infer Param}` ? Param : never; +type ParamNames = S extends `${infer Part}/${infer Rest}` + ? ParamPart | ParamNames + : ParamPart; +type PathParams = { [name in ParamNames]: string }; + +/** + * Merges a param object type with with an optional params type into a params object + */ +type MergeParams< + P1 extends { [param in string]: string }, + P2 extends AnyParams +> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2); + +/** + * Creates a SubRouteRef type given the desired parameters and parent route parameters. + * The parameters types are merged together while ensuring that there is no overlap between the two. + */ +type MakeSubRouteRef< + Params extends { [param in string]: string }, + ParentParams extends AnyParams +> = keyof Params & keyof ParentParams extends never + ? SubRouteRef>> + : never; + +export function createSubRouteRef< + Path extends string, + ParentParams extends AnyParams = never +>(config: { + id: string; + path: Path; + parent: RouteRef; +}): MakeSubRouteRef, ParentParams> { + const { id, path, parent } = config; + type Params = PathParams; + + // Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz' + const pathParams = path + .split('/') + .filter(p => p.startsWith(':')) + .map(p => p.substring(1)); + const params = [...parent.params, ...pathParams]; + + if (parent.params.some(p => pathParams.includes(p as string))) { + throw new Error( + 'SubRouteRef may not have params that overlap with its parent', + ); + } + if (!path.startsWith('/')) { + throw new Error(`SubRouteRef path must start with '/', got '${path}'`); + } + if (path.endsWith('/')) { + throw new Error(`SubRouteRef path must not end with '/', got '${path}'`); + } + for (const param of pathParams) { + if (!PARAM_PATTERN.test(param)) { + throw new Error(`SubRouteRef path has invalid param, got '${param}'`); + } + } + + // We ensure that the type of the return type is sane here + const subRouteRef = new SubRouteRefImpl( + id, + path, + parent, + params as ParamKeys>, + ) as SubRouteRef>>; + + // But skip type checking of the return value itself, because the conditional + // type checking of the parent parameter overlap is tricky to express. + return subRouteRef as any; +} + +export function isSubRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): routeRef is SubRouteRef { + return routeRef[routeRefType] === 'sub'; +} diff --git a/packages/plugin-api/src/routing/index.ts b/packages/plugin-api/src/routing/index.ts new file mode 100644 index 0000000000..907d403dc2 --- /dev/null +++ b/packages/plugin-api/src/routing/index.ts @@ -0,0 +1,21 @@ +/* + * 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 type { RouteRef, ExternalRouteRef } from './types'; +export { createRouteRef } from './RouteRef'; +export { createSubRouteRef } from './SubRouteRef'; +export { createExternalRouteRef } from './ExternalRouteRef'; +export { useRouteRef } from './useRouteRef'; diff --git a/packages/plugin-api/src/routing/types.ts b/packages/plugin-api/src/routing/types.ts new file mode 100644 index 0000000000..aba66dc1e7 --- /dev/null +++ b/packages/plugin-api/src/routing/types.ts @@ -0,0 +1,80 @@ +/* + * 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 { getOrCreateGlobalSingleton } from '../lib/globalObject'; + +export type AnyParams = { [param in string]: string } | undefined; +export type ParamKeys = keyof Params extends never + ? [] + : (keyof Params)[]; +export type OptionalParams< + Params extends { [param in string]: string } +> = Params[keyof Params] extends never ? undefined : Params; + +// The extra TS magic here is to require a single params argument if the RouteRef +// had at least one param defined, but require 0 arguments if there are no params defined. +// Without this we'd have to pass in empty object to all parameter-less RouteRefs +// just to make TypeScript happy, or we would have to make the argument optional in +// which case you might forget to pass it in when it is actually required. +export type RouteFunc = ( + ...[params]: Params extends undefined ? readonly [] : readonly [Params] +) => string; + +export const routeRefType: unique symbol = getOrCreateGlobalSingleton( + 'route-ref-type', + () => Symbol('route-ref-type'), +); + +export type RouteRef = { + readonly [routeRefType]: 'absolute'; + + params: ParamKeys; +}; + +export type SubRouteRef = { + readonly [routeRefType]: 'sub'; + + parent: RouteRef; + + path: string; + + params: ParamKeys; +}; + +export type ExternalRouteRef< + Params extends AnyParams = any, + Optional extends boolean = any +> = { + readonly [routeRefType]: 'external'; + + params: ParamKeys; + + optional?: Optional; +}; + +export type AnyRouteRef = + | RouteRef + | SubRouteRef + | ExternalRouteRef; + +// A duplicate of the react-router RouteObject, but with routeRef added +export interface BackstageRouteObject { + caseSensitive: boolean; + children?: BackstageRouteObject[]; + element: React.ReactNode; + path: string; + routeRefs: Set; +} diff --git a/packages/plugin-api/src/routing/useRouteRef.test.tsx b/packages/plugin-api/src/routing/useRouteRef.test.tsx new file mode 100644 index 0000000000..2fa9bb3137 --- /dev/null +++ b/packages/plugin-api/src/routing/useRouteRef.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 { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { createVersionedContextForTesting } from '../lib/versionedValues'; +import { useRouteRef } from './useRouteRef'; +import { createRouteRef } from './RouteRef'; + +describe('v1 consumer', () => { + const context = createVersionedContextForTesting('routing-context'); + + afterEach(() => { + context.reset(); + }); + + it('should resolve routes', () => { + const resolve = jest.fn(() => () => '/hello'); + context.set({ 1: { resolve } }); + + const routeRef = createRouteRef({ id: 'ref1' }); + + const renderedHook = renderHook(() => useRouteRef(routeRef), { + wrapper: ({ children }) => ( + + ), + }); + + const routeFunc = renderedHook.result.current; + expect(routeFunc()).toBe('/hello'); + expect(resolve).toHaveBeenCalledWith( + routeRef, + expect.objectContaining({ + pathname: '/my-page', + }), + ); + }); +}); diff --git a/packages/plugin-api/src/routing/useRouteRef.tsx b/packages/plugin-api/src/routing/useRouteRef.tsx new file mode 100644 index 0000000000..8cd4082e70 --- /dev/null +++ b/packages/plugin-api/src/routing/useRouteRef.tsx @@ -0,0 +1,73 @@ +/* + * 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 { useMemo } from 'react'; +import { matchRoutes, useLocation } from 'react-router-dom'; +import { useVersionedContext } from '../lib/versionedValues'; +import { + AnyParams, + ExternalRouteRef, + RouteFunc, + RouteRef, + SubRouteRef, +} from './types'; + +export interface RouteResolver { + resolve( + anyRouteRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, + sourceLocation: Parameters[1], + ): RouteFunc | undefined; +} + +export function useRouteRef( + routeRef: ExternalRouteRef, +): Optional extends true ? RouteFunc | undefined : RouteFunc; +export function useRouteRef( + routeRef: RouteRef | SubRouteRef, +): RouteFunc; +export function useRouteRef( + routeRef: + | RouteRef + | SubRouteRef + | ExternalRouteRef, +): RouteFunc | undefined { + const sourceLocation = useLocation(); + const versionedContext = useVersionedContext<{ 1: RouteResolver }>( + 'routing-context', + ); + const resolver = versionedContext.atVersion(1); + const routeFunc = useMemo( + () => resolver && resolver.resolve(routeRef, sourceLocation), + [resolver, routeRef, sourceLocation], + ); + + if (!versionedContext) { + throw new Error('useRouteRef used outside of routing context'); + } + if (!resolver) { + throw new Error('RoutingContext v1 not available'); + } + + const isOptional = 'optional' in routeRef && routeRef.optional; + if (!routeFunc && !isOptional) { + throw new Error(`No path for ${routeRef}`); + } + + return routeFunc; +} diff --git a/packages/plugin-api/src/setupTests.ts b/packages/plugin-api/src/setupTests.ts new file mode 100644 index 0000000000..aea2220869 --- /dev/null +++ b/packages/plugin-api/src/setupTests.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import '@testing-library/jest-dom'; +import 'cross-fetch/polyfill'; diff --git a/packages/plugin-api/src/types.ts b/packages/plugin-api/src/types.ts new file mode 100644 index 0000000000..13a8a72570 --- /dev/null +++ b/packages/plugin-api/src/types.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. + */ + +/** + * This file contains non-react related core types used throughout Backstage. + */ + +/** + * Observer interface for consuming an Observer, see TC39. + */ +export type Observer = { + next?(value: T): void; + error?(error: Error): void; + complete?(): void; +}; + +/** + * Subscription returned when subscribing to an Observable, see TC39. + */ +export type Subscription = { + /** + * Cancels the subscription + */ + unsubscribe(): void; + + /** + * Value indicating whether the subscription is closed. + */ + readonly closed: Boolean; +}; + +/** + * Observable sequence of values and errors, see TC39. + * + * https://github.com/tc39/proposal-observable + * + * This is used as a common return type for observable values and can be created + * using many different observable implementations, such as zen-observable or RxJS 5. + */ +export type Observable = { + /** + * Subscribes to this observable to start receiving new values. + */ + subscribe(observer: Observer): Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: Error) => void, + onComplete?: () => void, + ): Subscription; +}; From cd61be652312029c424726d0e15da55b30a77285 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 10 Mar 2021 10:40:16 +0100 Subject: [PATCH 02/37] plugin-api: remove component data symbol access Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- .../src/extensions/componentData.test.tsx | 56 ------------------- .../src/extensions/componentData.tsx | 20 ++----- 2 files changed, 5 insertions(+), 71 deletions(-) diff --git a/packages/plugin-api/src/extensions/componentData.test.tsx b/packages/plugin-api/src/extensions/componentData.test.tsx index 417fe07414..6c4abda40f 100644 --- a/packages/plugin-api/src/extensions/componentData.test.tsx +++ b/packages/plugin-api/src/extensions/componentData.test.tsx @@ -59,60 +59,4 @@ describe('elementData', () => { 'Attempted to attach duplicate data "my-data" to component "MyComponent"', ); }); - - describe('works across versions', () => { - function getDataSymbol() { - const Component = () => null; - attachComponentData(Component, 'my-data', {}); - const [symbol] = Object.getOwnPropertySymbols(Component); - return symbol; - } - - it('should should be able to get data from older versions', () => { - const symbol = getDataSymbol(); - - const data = { foo: 'bar' }; - const Component = () => null; - attachComponentData(Component, 'my-data', data); - - const element = ; - expect((element as any).type[symbol].map.get('my-data')).toBe(data); - }); - - it('should should be able to attach data for older versions', () => { - const symbol = getDataSymbol(); - - const data = { foo: 'bar' }; - const Component = () => null; - (Component as any)[symbol] = { - map: new Map([['my-data', data]]), - }; - - const element = ; - expect(getComponentData(element, 'my-data')).toBe(data); - }); - - it('should be able to get data from newer versions', () => { - const data = { foo: 'bar' }; - const Component = () => null; - attachComponentData(Component, 'my-data', data); - - const element = ; - const container = (global as any)[ - '__@backstage/component-data-store__' - ].get(element.type); - expect(container.map.get('my-data')).toBe(data); - }); - - it('should should be able to attach data for newer versions', () => { - const data = { foo: 'bar' }; - const Component = () => null; - (global as any)['__@backstage/component-data-store__'].set(Component, { - map: new Map([['my-data', data]]), - }); - - const element = ; - expect(getComponentData(element, 'my-data')).toBe(data); - }); - }); }); diff --git a/packages/plugin-api/src/extensions/componentData.tsx b/packages/plugin-api/src/extensions/componentData.tsx index fc8594039c..f835ad38a8 100644 --- a/packages/plugin-api/src/extensions/componentData.tsx +++ b/packages/plugin-api/src/extensions/componentData.tsx @@ -17,23 +17,16 @@ import { ComponentType, ReactNode } from 'react'; import { getOrCreateGlobalSingleton } from '../lib/globalObject'; -// TODO(Rugvip): Access via symbol is deprecated, remove once on 0.3.x -const DATA_KEY = Symbol('backstage-component-data'); - -type ComponentWithData

= ComponentType

& { - [DATA_KEY]?: DataContainer; -}; - type DataContainer = { map: Map; }; type MaybeComponentNode = ReactNode & { - type?: ComponentType & { [DATA_KEY]?: DataContainer }; + type?: ComponentType; }; // The store is bridged across versions using the global object -const store = getOrCreateGlobalSingleton( +const globalStore = getOrCreateGlobalSingleton( 'component-data-store', () => new WeakMap, DataContainer>(), ); @@ -43,13 +36,10 @@ export function attachComponentData

( type: string, data: unknown, ) { - const dataComponent = component as ComponentWithData

; - - let container = store.get(component) || dataComponent[DATA_KEY]; + let container = globalStore.get(component); if (!container) { container = { map: new Map() }; - store.set(component, container); - dataComponent[DATA_KEY] = container; + globalStore.set(component, container); } if (container.map.has(type)) { @@ -75,7 +65,7 @@ export function getComponentData( return undefined; } - const container = store.get(component) || component[DATA_KEY]; + const container = globalStore.get(component); if (!container) { return undefined; } From 7803503b1eb334fe8a931e277105729b8aa27c09 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 10 Mar 2021 10:47:08 +0100 Subject: [PATCH 03/37] plugin-api: clean up todos Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- packages/plugin-api/src/apis/definitions/IdentityApi.ts | 1 - packages/plugin-api/src/lib/globalObject.ts | 4 ++-- packages/plugin-api/src/routing/ExternalRouteRef.test.ts | 2 +- packages/plugin-api/src/routing/RouteRef.test.ts | 2 +- packages/plugin-api/src/routing/SubRouteRef.test.ts | 2 +- 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/plugin-api/src/apis/definitions/IdentityApi.ts b/packages/plugin-api/src/apis/definitions/IdentityApi.ts index 14afe65fad..a0cfde537a 100644 --- a/packages/plugin-api/src/apis/definitions/IdentityApi.ts +++ b/packages/plugin-api/src/apis/definitions/IdentityApi.ts @@ -30,7 +30,6 @@ export type IdentityApi = { */ getUserId(): string; - // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. /** * The profile of the signed in user. */ diff --git a/packages/plugin-api/src/lib/globalObject.ts b/packages/plugin-api/src/lib/globalObject.ts index 87be58499d..5bd1809147 100644 --- a/packages/plugin-api/src/lib/globalObject.ts +++ b/packages/plugin-api/src/lib/globalObject.ts @@ -36,7 +36,7 @@ const makeKey = (id: string) => `__@backstage/${id}__`; export function setGlobalSingleton(id: string, value: unknown): void { const key = makeKey(id); if (key in globalObject) { - throw new Error(`Global ${id} is already set`); // TODO some sort of special build err + throw new Error(`Global ${id} is already set`); } globalObject[key] = value; } @@ -47,7 +47,7 @@ export function setGlobalSingleton(id: string, value: unknown): void { export function getGlobalSingleton(id: string): T { const key = makeKey(id); if (!(key in globalObject)) { - throw new Error(`Global ${id} is not set`); // TODO some sort of special build err + throw new Error(`Global ${id} is not set`); } return globalObject[key]; diff --git a/packages/plugin-api/src/routing/ExternalRouteRef.test.ts b/packages/plugin-api/src/routing/ExternalRouteRef.test.ts index 785ad2732f..ffe2fdf241 100644 --- a/packages/plugin-api/src/routing/ExternalRouteRef.test.ts +++ b/packages/plugin-api/src/routing/ExternalRouteRef.test.ts @@ -94,7 +94,7 @@ describe('ExternalRouteRef', () => { const _3 = createExternalRouteRef({ id: '3', params: ['x', 'y'] }); // @ts-expect-error validateType<{ x: string }, any>(_3); - // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + // extra z, we validate this at runtime instead validateType<{ x: string; y: string; z: string }, any>(_3); validateType<{ x: string; y: string }, false>(_3); diff --git a/packages/plugin-api/src/routing/RouteRef.test.ts b/packages/plugin-api/src/routing/RouteRef.test.ts index a2fa9b246d..4683e157b5 100644 --- a/packages/plugin-api/src/routing/RouteRef.test.ts +++ b/packages/plugin-api/src/routing/RouteRef.test.ts @@ -61,7 +61,7 @@ describe('RouteRef', () => { validateType(_2); // @ts-expect-error validateType<{ x: string; z: string }>(_2); - // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + // extra z, we validate this at runtime instead validateType<{ x: string; y: string; z: string }>(_2); validateType<{ x: string; y: string }>(_2); diff --git a/packages/plugin-api/src/routing/SubRouteRef.test.ts b/packages/plugin-api/src/routing/SubRouteRef.test.ts index 1c1a3c1b21..6d62810907 100644 --- a/packages/plugin-api/src/routing/SubRouteRef.test.ts +++ b/packages/plugin-api/src/routing/SubRouteRef.test.ts @@ -108,7 +108,7 @@ describe('SubRouteRef', () => { validateType<{ x: string; z: string }>(_2); // @ts-expect-error validateType<{ y: string }>(_2); - // TODO(Rugvip): Ideally this would fail as well, but settle for validating it at runtime instead + // extra z, we validate this at runtime instead validateType<{ x: string; y: string; z: string }>(_2); validateType<{ x: string; y: string }>(_2); From 97b59b5821839dfc4d111e8f323c20fff711b139 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 10 Mar 2021 11:08:17 +0100 Subject: [PATCH 04/37] graphiql: migrate to use plugin-api Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- plugins/graphiql/dev/index.tsx | 4 +++- plugins/graphiql/package.json | 1 + .../src/components/GraphiQLPage/GraphiQLPage.tsx | 10 ++-------- plugins/graphiql/src/lib/api/GraphQLEndpoints.ts | 2 +- plugins/graphiql/src/lib/api/types.ts | 3 +-- plugins/graphiql/src/plugin.ts | 2 +- plugins/graphiql/src/route-refs.tsx | 7 ++----- 7 files changed, 11 insertions(+), 18 deletions(-) diff --git a/plugins/graphiql/dev/index.tsx b/plugins/graphiql/dev/index.tsx index 918695e0e0..7a50ed1afc 100644 --- a/plugins/graphiql/dev/index.tsx +++ b/plugins/graphiql/dev/index.tsx @@ -16,7 +16,8 @@ import React from 'react'; import { createDevApp } from '@backstage/dev-utils'; -import { githubAuthApiRef, errorApiRef } from '@backstage/core'; +import { githubAuthApiRef, errorApiRef } from '@backstage/plugin-api'; +import GraphiQLIcon from '../src/assets/graphiql.icon.svg'; import { graphiqlPlugin, GraphQLEndpoints, @@ -55,6 +56,7 @@ createDevApp() }) .addPage({ title: 'GraphiQL', + icon: GraphiQLIcon, element: , }) .render(); diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index 54f7b076c8..8e3ec7f4dd 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -32,6 +32,7 @@ }, "dependencies": { "@backstage/core": "^0.7.11", + "@backstage/plugin-api": "^0.1.0", "@backstage/theme": "^0.2.7", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", diff --git a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx index 224def1d78..37522ebe41 100644 --- a/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx +++ b/plugins/graphiql/src/components/GraphiQLPage/GraphiQLPage.tsx @@ -14,14 +14,8 @@ * limitations under the License. */ import React from 'react'; -import { - Content, - Header, - HeaderLabel, - Page, - Progress, - useApi, -} from '@backstage/core'; +import { Content, Header, HeaderLabel, Page, Progress } from '@backstage/core'; +import { useApi } from '@backstage/plugin-api'; import { useAsync } from 'react-use'; import 'graphiql/graphiql.css'; import { graphQlBrowseApiRef } from '../../lib/api'; diff --git a/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts b/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts index 23a9e9e697..2f0e86ca7e 100644 --- a/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts +++ b/plugins/graphiql/src/lib/api/GraphQLEndpoints.ts @@ -15,7 +15,7 @@ */ import { GraphQLBrowseApi, GraphQLEndpoint } from './types'; -import { ErrorApi, OAuthApi } from '@backstage/core'; +import { ErrorApi, OAuthApi } from '@backstage/plugin-api'; // Helper for generic http endpoints export type EndpointConfig = { diff --git a/plugins/graphiql/src/lib/api/types.ts b/plugins/graphiql/src/lib/api/types.ts index dad06b8359..b968989d96 100644 --- a/plugins/graphiql/src/lib/api/types.ts +++ b/plugins/graphiql/src/lib/api/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { createApiRef } from '@backstage/core'; +import { createApiRef } from '@backstage/plugin-api'; export type GraphQLEndpoint = { // Will be used as unique key for storing history and query data @@ -35,5 +35,4 @@ export type GraphQLBrowseApi = { export const graphQlBrowseApiRef = createApiRef({ id: 'plugin.graphiql.browse', - description: 'Used to supply GraphQL endpoints for browsing', }); diff --git a/plugins/graphiql/src/plugin.ts b/plugins/graphiql/src/plugin.ts index 42969999d9..7cf9e443c6 100644 --- a/plugins/graphiql/src/plugin.ts +++ b/plugins/graphiql/src/plugin.ts @@ -18,7 +18,7 @@ import { createPlugin, createApiFactory, createRoutableExtension, -} from '@backstage/core'; +} from '@backstage/plugin-api'; import { graphQlBrowseApiRef, GraphQLEndpoints } from './lib/api'; import { graphiQLRouteRef } from './route-refs'; diff --git a/plugins/graphiql/src/route-refs.tsx b/plugins/graphiql/src/route-refs.tsx index 3e8d1d3a9c..8806bed826 100644 --- a/plugins/graphiql/src/route-refs.tsx +++ b/plugins/graphiql/src/route-refs.tsx @@ -14,11 +14,8 @@ * limitations under the License. */ -import { createRouteRef } from '@backstage/core'; -import GraphiQLIcon from './assets/graphiql.icon.svg'; +import { createRouteRef } from '@backstage/plugin-api'; export const graphiQLRouteRef = createRouteRef({ - icon: GraphiQLIcon, - path: '/graphiql', - title: 'GraphiQL', + id: 'graphiql-root', }); From 65da177bd26092f48469a8299344069022404f2f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 10 Mar 2021 11:26:31 +0100 Subject: [PATCH 05/37] catalog-react: migrate to use plugin-api Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- plugins/catalog-react/package.json | 1 + plugins/catalog-react/src/api.ts | 4 +--- plugins/catalog-react/src/hooks/useEntity.ts | 2 +- plugins/catalog-react/src/hooks/useRelatedEntities.ts | 2 +- .../catalog-react/src/hooks/useStarredEntities.test.tsx | 9 ++------- plugins/catalog-react/src/hooks/useStarredEntities.ts | 2 +- 6 files changed, 7 insertions(+), 13 deletions(-) diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index a41d2cf518..9763f731af 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -31,6 +31,7 @@ "@backstage/catalog-client": "^0.3.12", "@backstage/catalog-model": "^0.8.0", "@backstage/core": "^0.7.11", + "@backstage/plugin-api": "^0.1.0", "@material-ui/core": "^4.11.0", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", diff --git a/plugins/catalog-react/src/api.ts b/plugins/catalog-react/src/api.ts index 9379fa7fb3..b87957ba5b 100644 --- a/plugins/catalog-react/src/api.ts +++ b/plugins/catalog-react/src/api.ts @@ -15,10 +15,8 @@ */ import { CatalogApi } from '@backstage/catalog-client'; -import { createApiRef } from '@backstage/core'; +import { createApiRef } from '@backstage/plugin-api'; export const catalogApiRef = createApiRef({ id: 'plugin.catalog.service', - description: - 'Used by the Catalog plugin to make requests to accompanying backend', }); diff --git a/plugins/catalog-react/src/hooks/useEntity.ts b/plugins/catalog-react/src/hooks/useEntity.ts index 0eb365d932..c53b256227 100644 --- a/plugins/catalog-react/src/hooks/useEntity.ts +++ b/plugins/catalog-react/src/hooks/useEntity.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { Entity } from '@backstage/catalog-model'; -import { errorApiRef, useApi } from '@backstage/core'; +import { errorApiRef, useApi } from '@backstage/plugin-api'; import { createContext, useContext, useEffect } from 'react'; import { useNavigate } from 'react-router'; import { useAsync } from 'react-use'; diff --git a/plugins/catalog-react/src/hooks/useRelatedEntities.ts b/plugins/catalog-react/src/hooks/useRelatedEntities.ts index b47d941ff6..0f58b3aa98 100644 --- a/plugins/catalog-react/src/hooks/useRelatedEntities.ts +++ b/plugins/catalog-react/src/hooks/useRelatedEntities.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { Entity, EntityRelation } from '@backstage/catalog-model'; -import { useApi } from '@backstage/core'; +import { useApi } from '@backstage/plugin-api'; import { chunk, groupBy } from 'lodash'; import { useAsync } from 'react-use'; import { catalogApiRef } from '../api'; diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx index f1a8df1f32..ce81fa5792 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog-react/src/hooks/useStarredEntities.test.tsx @@ -17,13 +17,8 @@ import React, { PropsWithChildren } from 'react'; import { renderHook, act } from '@testing-library/react-hooks'; import { useStarredEntities } from './useStarredEntities'; -import { - ApiProvider, - ApiRegistry, - storageApiRef, - WebStorage, - StorageApi, -} from '@backstage/core'; +import { ApiProvider, ApiRegistry, WebStorage } from '@backstage/core'; +import { storageApiRef, StorageApi } from '@backstage/plugin-api'; import { MockErrorApi } from '@backstage/test-utils'; import { Entity } from '@backstage/catalog-model'; diff --git a/plugins/catalog-react/src/hooks/useStarredEntities.ts b/plugins/catalog-react/src/hooks/useStarredEntities.ts index 7cbbbb7ce6..d959514562 100644 --- a/plugins/catalog-react/src/hooks/useStarredEntities.ts +++ b/plugins/catalog-react/src/hooks/useStarredEntities.ts @@ -15,7 +15,7 @@ */ import { Entity } from '@backstage/catalog-model'; -import { storageApiRef, useApi } from '@backstage/core'; +import { storageApiRef, useApi } from '@backstage/plugin-api'; import { useCallback, useEffect, useState } from 'react'; import { useObservable } from 'react-use'; From b738adebe3a1e970c383d9eb961586e2975e797b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 10 Mar 2021 12:30:24 +0100 Subject: [PATCH 06/37] duplicate packages/core to packages/componments, omitting api-wrappers Co-authored-by: Juan Lulkin Signed-off-by: Patrik Oldsberg --- packages/components/.eslintrc.js | 8 + packages/components/.npmrc | 1 + packages/components/CHANGELOG.md | 358 +++++++++++++ packages/components/README.md | 22 + packages/components/config.d.ts | 113 ++++ packages/components/package.json | 91 ++++ .../AlertDisplay/AlertDisplay.test.tsx | 65 +++ .../components/AlertDisplay/AlertDisplay.tsx | 71 +++ .../src/components/AlertDisplay/index.ts | 17 + .../src/components/Avatar/Avatar.stories.tsx | 42 ++ .../src/components/Avatar/Avatar.test.tsx | 27 + .../src/components/Avatar/Avatar.tsx | 59 +++ .../components/src/components/Avatar/index.ts | 16 + .../src/components/Avatar/util.test.ts | 37 ++ .../components/src/components/Avatar/utils.ts | 32 ++ .../src/components/Button/Button.stories.tsx | 174 +++++++ .../src/components/Button/Button.test.tsx | 42 ++ .../src/components/Button/Button.tsx | 30 ++ .../components/src/components/Button/index.ts | 17 + .../CheckboxTree/CheckboxTree.stories.tsx | 105 ++++ .../CheckboxTree/CheckboxTree.test.tsx | 60 +++ .../components/CheckboxTree/CheckboxTree.tsx | 358 +++++++++++++ .../src/components/CheckboxTree/index.tsx | 17 + .../src/components/Chip/Chip.stories.tsx | 43 ++ .../CodeSnippet/CodeSnippet.stories.tsx | 94 ++++ .../CodeSnippet/CodeSnippet.test.tsx | 74 +++ .../components/CodeSnippet/CodeSnippet.tsx | 72 +++ .../src/components/CodeSnippet/index.tsx | 17 + .../CopyTextButton/CopyTextButton.stories.tsx | 42 ++ .../CopyTextButton/CopyTextButton.test.tsx | 85 +++ .../CopyTextButton/CopyTextButton.tsx | 111 ++++ .../src/components/CopyTextButton/index.tsx | 17 + .../DependencyGraph/DefaultLabel.tsx | 35 ++ .../DependencyGraph/DefaultNode.tsx | 79 +++ .../DependencyGraph.stories.tsx | 178 +++++++ .../DependencyGraph/DependencyGraph.test.tsx | 106 ++++ .../DependencyGraph/DependencyGraph.tsx | 324 ++++++++++++ .../components/DependencyGraph/Edge.test.tsx | 100 ++++ .../src/components/DependencyGraph/Edge.tsx | 122 +++++ .../components/DependencyGraph/Node.test.tsx | 79 +++ .../src/components/DependencyGraph/Node.tsx | 76 +++ .../components/DependencyGraph/constants.ts | 21 + .../src/components/DependencyGraph/index.ts | 20 + .../src/components/DependencyGraph/types.ts | 88 ++++ .../DismissableBanner.stories.tsx | 114 +++++ .../DismissableBanner.test.tsx | 88 ++++ .../DismissableBanner/DismissableBanner.tsx | 135 +++++ .../src/components/DismissableBanner/index.ts | 17 + .../src/components/Drawer/Drawer.stories.tsx | 171 +++++++ .../EmptyState/EmptyState.stories.tsx | 78 +++ .../components/EmptyState/EmptyState.test.tsx | 39 ++ .../src/components/EmptyState/EmptyState.tsx | 68 +++ .../EmptyState/EmptyStateImage.test.tsx | 49 ++ .../components/EmptyState/EmptyStateImage.tsx | 73 +++ .../MissingAnnotationEmptyState.tsx | 86 ++++ .../EmptyState/assets/createComponent.svg | 1 + .../EmptyState/assets/missingAnnotation.svg | 1 + .../components/EmptyState/assets/noBuild.svg | 1 + .../EmptyState/assets/noInformation.svg | 1 + .../src/components/EmptyState/index.ts | 18 + .../FeatureCalloutCircular.test.tsx | 154 ++++++ .../FeatureCalloutCircular.tsx | 199 ++++++++ .../src/components/FeatureDiscovery/index.ts | 17 + .../FeatureDiscovery/lib/usePortal.ts | 99 ++++ .../FeatureDiscovery/lib/useShowCallout.ts | 58 +++ .../HeaderIconLinkRow/HeaderIconLinkRow.tsx | 43 ++ .../HeaderIconLinkRow/IconLinkVertical.tsx | 95 ++++ .../src/components/HeaderIconLinkRow/index.ts | 19 + .../HorizontalScrollGrid.stories.tsx | 37 ++ .../HorizontalScrollGrid.test.tsx | 87 ++++ .../HorizontalScrollGrid.tsx | 247 +++++++++ .../components/HorizontalScrollGrid/index.tsx | 17 + .../Lifecycle/Lifecycle.stories.tsx | 43 ++ .../components/Lifecycle/Lifecycle.test.tsx | 41 ++ .../src/components/Lifecycle/Lifecycle.tsx | 56 ++ .../src/components/Lifecycle/index.ts | 17 + .../src/components/Link/Link.stories.tsx | 92 ++++ .../src/components/Link/Link.test.tsx | 42 ++ .../components/src/components/Link/Link.tsx | 45 ++ .../components/src/components/Link/index.ts | 18 + .../MarkdownContent.stories.tsx | 120 +++++ .../MarkdownContent/MarkdownContent.test.tsx | 65 +++ .../MarkdownContent/MarkdownContent.tsx | 89 ++++ .../src/components/MarkdownContent/index.ts | 17 + .../LoginRequestListItem.tsx | 81 +++ .../OAuthRequestDialog/OAuthRequestDialog.tsx | 86 ++++ .../components/OAuthRequestDialog/index.ts | 17 + .../OverflowTooltip.stories.tsx | 48 ++ .../OverflowTooltip/OverflowTooltip.tsx | 57 +++ .../src/components/OverflowTooltip/index.ts | 16 + .../components/Progress/Progress.stories.tsx | 25 + .../src/components/Progress/Progress.test.tsx | 34 ++ .../src/components/Progress/Progress.tsx | 33 ++ .../src/components/Progress/index.ts | 17 + .../components/ProgressBars/Gauge.stories.tsx | 55 ++ .../components/ProgressBars/Gauge.test.tsx | 71 +++ .../src/components/ProgressBars/Gauge.tsx | 106 ++++ .../ProgressBars/GaugeCard.stories.tsx | 85 +++ .../ProgressBars/GaugeCard.test.tsx | 46 ++ .../src/components/ProgressBars/GaugeCard.tsx | 55 ++ .../ProgressBars/LinearGauge.stories.tsx | 43 ++ .../ProgressBars/LinearGauge.test.tsx | 37 ++ .../components/ProgressBars/LinearGauge.tsx | 53 ++ .../src/components/ProgressBars/index.ts | 19 + .../src/components/Select/Select.stories.tsx | 57 +++ .../src/components/Select/Select.test.tsx | 58 +++ .../src/components/Select/Select.tsx | 231 +++++++++ .../src/components/Select/index.tsx | 17 + .../Select/static/ClosedDropdown.tsx | 45 ++ .../Select/static/OpenedDropdown.tsx | 45 ++ .../SimpleStepper/SimpleStepper.stories.tsx | 81 +++ .../SimpleStepper/SimpleStepper.test.tsx | 148 ++++++ .../SimpleStepper/SimpleStepper.tsx | 98 ++++ .../SimpleStepper/SimpleStepperFooter.tsx | 156 ++++++ .../SimpleStepper/SimpleStepperStep.tsx | 84 +++ .../src/components/SimpleStepper/index.ts | 18 + .../src/components/Status/Status.stories.tsx | 90 ++++ .../src/components/Status/Status.test.tsx | 69 +++ .../src/components/Status/Status.tsx | 130 +++++ .../components/src/components/Status/index.ts | 24 + .../StructuredMetadataTable/MetadataTable.tsx | 109 ++++ .../StructuredMetadataTable/README.md | 62 +++ .../StructuredMetadataTable.stories.tsx | 73 +++ .../StructuredMetadataTable.test.tsx | 118 +++++ .../StructuredMetadataTable.tsx | 163 ++++++ .../StructuredMetadataTable/index.tsx | 17 + .../SupportButton/SupportButton.test.tsx | 66 +++ .../SupportButton/SupportButton.tsx | 133 +++++ .../src/components/SupportButton/index.ts | 17 + .../TabbedLayout/RoutedTabs.test.tsx | 150 ++++++ .../components/TabbedLayout/RoutedTabs.tsx | 75 +++ .../TabbedLayout/TabbedLayout.stories.tsx | 44 ++ .../TabbedLayout/TabbedLayout.test.tsx | 94 ++++ .../components/TabbedLayout/TabbedLayout.tsx | 89 ++++ .../src/components/TabbedLayout/index.ts | 16 + .../src/components/TabbedLayout/types.ts | 21 + .../src/components/Table/Filters.tsx | 157 ++++++ .../src/components/Table/SubvalueCell.tsx | 45 ++ .../src/components/Table/Table.stories.tsx | 332 ++++++++++++ .../src/components/Table/Table.test.tsx | 68 +++ .../components/src/components/Table/Table.tsx | 483 ++++++++++++++++++ .../components/src/components/Table/index.ts | 19 + .../src/components/Tabs/Tab.test.tsx | 26 + .../components/src/components/Tabs/Tab.tsx | 62 +++ .../components/src/components/Tabs/TabBar.tsx | 52 ++ .../src/components/Tabs/TabIcon.tsx | 60 +++ .../src/components/Tabs/TabPanel.tsx | 38 ++ .../src/components/Tabs/Tabs.stories.tsx | 71 +++ .../components/src/components/Tabs/Tabs.tsx | 159 ++++++ .../components/src/components/Tabs/index.ts | 17 + .../components/src/components/Tabs/utils.ts | 28 + .../TrendLine/TrendLine.stories.tsx | 141 +++++ .../components/TrendLine/TrendLine.test.tsx | 69 +++ .../src/components/TrendLine/TrendLine.tsx | 48 ++ .../src/components/TrendLine/index.ts | 17 + .../WarningPanel/WarningPanel.stories.tsx | 72 +++ .../WarningPanel/WarningPanel.test.tsx | 67 +++ .../components/WarningPanel/WarningPanel.tsx | 136 +++++ .../src/components/WarningPanel/index.ts | 16 + packages/components/src/components/index.ts | 44 ++ packages/components/src/hooks/index.ts | 23 + .../src/hooks/useQueryParamState.ts | 93 ++++ .../components/src/hooks/useSupportConfig.ts | 74 +++ packages/components/src/index.ts | 19 + .../src/layout/BottomLink/BottomLink.test.tsx | 30 ++ .../src/layout/BottomLink/BottomLink.tsx | 70 +++ .../components/src/layout/BottomLink/index.ts | 18 + .../Breadcrumbs/Breadcrumbs.stories.tsx | 137 +++++ .../layout/Breadcrumbs/Breadcrumbs.test.tsx | 54 ++ .../src/layout/Breadcrumbs/Breadcrumbs.tsx | 99 ++++ .../src/layout/Breadcrumbs/index.ts | 17 + .../components/src/layout/Content/Content.tsx | 64 +++ .../components/src/layout/Content/index.ts | 17 + .../ContentHeader/ContentHeader.test.tsx | 48 ++ .../layout/ContentHeader/ContentHeader.tsx | 111 ++++ .../src/layout/ContentHeader/index.ts | 17 + .../ErrorBoundary/ErrorBoundary.test.tsx | 71 +++ .../layout/ErrorBoundary/ErrorBoundary.tsx | 73 +++ .../src/layout/ErrorBoundary/index.ts | 17 + .../src/layout/ErrorPage/ErrorPage.test.tsx | 33 ++ .../src/layout/ErrorPage/ErrorPage.tsx | 85 +++ .../src/layout/ErrorPage/MicDrop.tsx | 46 ++ .../components/src/layout/ErrorPage/index.ts | 17 + .../src/layout/ErrorPage/mic-drop.svg | 1 + .../src/layout/Header/Header.stories.tsx | 102 ++++ .../src/layout/Header/Header.test.tsx | 67 +++ .../components/src/layout/Header/Header.tsx | 213 ++++++++ .../components/src/layout/Header/index.ts | 17 + .../HeaderActionMenu.test.tsx | 104 ++++ .../HeaderActionMenu/HeaderActionMenu.tsx | 106 ++++ .../HeaderActionMenu/VerticalMenuIcon.tsx | 25 + .../src/layout/HeaderActionMenu/index.ts | 17 + .../layout/HeaderLabel/HeaderLabel.test.tsx | 54 ++ .../src/layout/HeaderLabel/HeaderLabel.tsx | 72 +++ .../src/layout/HeaderLabel/index.ts | 17 + .../src/layout/HeaderTabs/HeaderTabs.test.tsx | 49 ++ .../src/layout/HeaderTabs/HeaderTabs.tsx | 95 ++++ .../src/layout/HeaderTabs/index.tsx | 18 + .../HomepageTimer/HomepageTimer.test.tsx | 53 ++ .../layout/HomepageTimer/HomepageTimer.tsx | 99 ++++ .../src/layout/HomepageTimer/index.ts | 17 + .../src/layout/InfoCard/InfoCard.stories.tsx | 69 +++ .../src/layout/InfoCard/InfoCard.test.tsx | 39 ++ .../src/layout/InfoCard/InfoCard.tsx | 208 ++++++++ .../components/src/layout/InfoCard/index.ts | 18 + .../src/layout/ItemCard/ItemCard.stories.tsx | 113 ++++ .../src/layout/ItemCard/ItemCard.test.tsx | 61 +++ .../src/layout/ItemCard/ItemCard.tsx | 104 ++++ .../src/layout/ItemCard/ItemCardGrid.test.tsx | 50 ++ .../src/layout/ItemCard/ItemCardGrid.tsx | 62 +++ .../layout/ItemCard/ItemCardHeader.test.tsx | 60 +++ .../src/layout/ItemCard/ItemCardHeader.tsx | 85 +++ .../components/src/layout/ItemCard/index.ts | 21 + .../src/layout/Page/Page.stories.tsx | 245 +++++++++ packages/components/src/layout/Page/Page.tsx | 48 ++ packages/components/src/layout/Page/index.ts | 17 + .../components/src/layout/Sidebar/Bar.tsx | 150 ++++++ .../components/src/layout/Sidebar/Intro.tsx | 182 +++++++ .../src/layout/Sidebar/Items.test.tsx | 75 +++ .../components/src/layout/Sidebar/Items.tsx | 295 +++++++++++ .../components/src/layout/Sidebar/Page.tsx | 74 +++ .../src/layout/Sidebar/Sidebar.stories.tsx | 56 ++ .../components/src/layout/Sidebar/config.ts | 49 ++ .../components/src/layout/Sidebar/index.ts | 33 ++ .../src/layout/Sidebar/localStorage.test.ts | 25 + .../src/layout/Sidebar/localStorage.ts | 40 ++ .../src/layout/SignInPage/SignInPage.tsx | 162 ++++++ .../src/layout/SignInPage/auth0Provider.tsx | 88 ++++ .../src/layout/SignInPage/commonProvider.tsx | 96 ++++ .../src/layout/SignInPage/customProvider.tsx | 126 +++++ .../src/layout/SignInPage/guestProvider.tsx | 61 +++ .../components/src/layout/SignInPage/index.ts | 17 + .../src/layout/SignInPage/providers.tsx | 177 +++++++ .../src/layout/SignInPage/styles.tsx | 42 ++ .../components/src/layout/SignInPage/types.ts | 49 ++ .../layout/TabbedCard/TabbedCard.stories.tsx | 113 ++++ .../src/layout/TabbedCard/TabbedCard.test.tsx | 99 ++++ .../src/layout/TabbedCard/TabbedCard.tsx | 132 +++++ .../components/src/layout/TabbedCard/index.ts | 17 + packages/components/src/layout/index.ts | 31 ++ packages/components/src/setupTests.ts | 17 + 241 files changed, 17776 insertions(+) create mode 100644 packages/components/.eslintrc.js create mode 100644 packages/components/.npmrc create mode 100644 packages/components/CHANGELOG.md create mode 100644 packages/components/README.md create mode 100644 packages/components/config.d.ts create mode 100644 packages/components/package.json create mode 100644 packages/components/src/components/AlertDisplay/AlertDisplay.test.tsx create mode 100644 packages/components/src/components/AlertDisplay/AlertDisplay.tsx create mode 100644 packages/components/src/components/AlertDisplay/index.ts create mode 100644 packages/components/src/components/Avatar/Avatar.stories.tsx create mode 100644 packages/components/src/components/Avatar/Avatar.test.tsx create mode 100644 packages/components/src/components/Avatar/Avatar.tsx create mode 100644 packages/components/src/components/Avatar/index.ts create mode 100644 packages/components/src/components/Avatar/util.test.ts create mode 100644 packages/components/src/components/Avatar/utils.ts create mode 100644 packages/components/src/components/Button/Button.stories.tsx create mode 100644 packages/components/src/components/Button/Button.test.tsx create mode 100644 packages/components/src/components/Button/Button.tsx create mode 100644 packages/components/src/components/Button/index.ts create mode 100644 packages/components/src/components/CheckboxTree/CheckboxTree.stories.tsx create mode 100644 packages/components/src/components/CheckboxTree/CheckboxTree.test.tsx create mode 100644 packages/components/src/components/CheckboxTree/CheckboxTree.tsx create mode 100644 packages/components/src/components/CheckboxTree/index.tsx create mode 100644 packages/components/src/components/Chip/Chip.stories.tsx create mode 100644 packages/components/src/components/CodeSnippet/CodeSnippet.stories.tsx create mode 100644 packages/components/src/components/CodeSnippet/CodeSnippet.test.tsx create mode 100644 packages/components/src/components/CodeSnippet/CodeSnippet.tsx create mode 100644 packages/components/src/components/CodeSnippet/index.tsx create mode 100644 packages/components/src/components/CopyTextButton/CopyTextButton.stories.tsx create mode 100644 packages/components/src/components/CopyTextButton/CopyTextButton.test.tsx create mode 100644 packages/components/src/components/CopyTextButton/CopyTextButton.tsx create mode 100644 packages/components/src/components/CopyTextButton/index.tsx create mode 100644 packages/components/src/components/DependencyGraph/DefaultLabel.tsx create mode 100644 packages/components/src/components/DependencyGraph/DefaultNode.tsx create mode 100644 packages/components/src/components/DependencyGraph/DependencyGraph.stories.tsx create mode 100644 packages/components/src/components/DependencyGraph/DependencyGraph.test.tsx create mode 100644 packages/components/src/components/DependencyGraph/DependencyGraph.tsx create mode 100644 packages/components/src/components/DependencyGraph/Edge.test.tsx create mode 100644 packages/components/src/components/DependencyGraph/Edge.tsx create mode 100644 packages/components/src/components/DependencyGraph/Node.test.tsx create mode 100644 packages/components/src/components/DependencyGraph/Node.tsx create mode 100644 packages/components/src/components/DependencyGraph/constants.ts create mode 100644 packages/components/src/components/DependencyGraph/index.ts create mode 100644 packages/components/src/components/DependencyGraph/types.ts create mode 100644 packages/components/src/components/DismissableBanner/DismissableBanner.stories.tsx create mode 100644 packages/components/src/components/DismissableBanner/DismissableBanner.test.tsx create mode 100644 packages/components/src/components/DismissableBanner/DismissableBanner.tsx create mode 100644 packages/components/src/components/DismissableBanner/index.ts create mode 100644 packages/components/src/components/Drawer/Drawer.stories.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyState.stories.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyState.test.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyState.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyStateImage.test.tsx create mode 100644 packages/components/src/components/EmptyState/EmptyStateImage.tsx create mode 100644 packages/components/src/components/EmptyState/MissingAnnotationEmptyState.tsx create mode 100644 packages/components/src/components/EmptyState/assets/createComponent.svg create mode 100644 packages/components/src/components/EmptyState/assets/missingAnnotation.svg create mode 100644 packages/components/src/components/EmptyState/assets/noBuild.svg create mode 100644 packages/components/src/components/EmptyState/assets/noInformation.svg create mode 100644 packages/components/src/components/EmptyState/index.ts create mode 100644 packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx create mode 100644 packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx create mode 100644 packages/components/src/components/FeatureDiscovery/index.ts create mode 100644 packages/components/src/components/FeatureDiscovery/lib/usePortal.ts create mode 100644 packages/components/src/components/FeatureDiscovery/lib/useShowCallout.ts create mode 100644 packages/components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx create mode 100644 packages/components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx create mode 100644 packages/components/src/components/HeaderIconLinkRow/index.ts create mode 100644 packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx create mode 100644 packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.tsx create mode 100644 packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx create mode 100644 packages/components/src/components/HorizontalScrollGrid/index.tsx create mode 100644 packages/components/src/components/Lifecycle/Lifecycle.stories.tsx create mode 100644 packages/components/src/components/Lifecycle/Lifecycle.test.tsx create mode 100644 packages/components/src/components/Lifecycle/Lifecycle.tsx create mode 100644 packages/components/src/components/Lifecycle/index.ts create mode 100644 packages/components/src/components/Link/Link.stories.tsx create mode 100644 packages/components/src/components/Link/Link.test.tsx create mode 100644 packages/components/src/components/Link/Link.tsx create mode 100644 packages/components/src/components/Link/index.ts create mode 100644 packages/components/src/components/MarkdownContent/MarkdownContent.stories.tsx create mode 100644 packages/components/src/components/MarkdownContent/MarkdownContent.test.tsx create mode 100644 packages/components/src/components/MarkdownContent/MarkdownContent.tsx create mode 100644 packages/components/src/components/MarkdownContent/index.ts create mode 100644 packages/components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx create mode 100644 packages/components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx create mode 100644 packages/components/src/components/OAuthRequestDialog/index.ts create mode 100644 packages/components/src/components/OverflowTooltip/OverflowTooltip.stories.tsx create mode 100644 packages/components/src/components/OverflowTooltip/OverflowTooltip.tsx create mode 100644 packages/components/src/components/OverflowTooltip/index.ts create mode 100644 packages/components/src/components/Progress/Progress.stories.tsx create mode 100644 packages/components/src/components/Progress/Progress.test.tsx create mode 100644 packages/components/src/components/Progress/Progress.tsx create mode 100644 packages/components/src/components/Progress/index.ts create mode 100644 packages/components/src/components/ProgressBars/Gauge.stories.tsx create mode 100644 packages/components/src/components/ProgressBars/Gauge.test.tsx create mode 100644 packages/components/src/components/ProgressBars/Gauge.tsx create mode 100644 packages/components/src/components/ProgressBars/GaugeCard.stories.tsx create mode 100644 packages/components/src/components/ProgressBars/GaugeCard.test.tsx create mode 100644 packages/components/src/components/ProgressBars/GaugeCard.tsx create mode 100644 packages/components/src/components/ProgressBars/LinearGauge.stories.tsx create mode 100644 packages/components/src/components/ProgressBars/LinearGauge.test.tsx create mode 100644 packages/components/src/components/ProgressBars/LinearGauge.tsx create mode 100644 packages/components/src/components/ProgressBars/index.ts create mode 100644 packages/components/src/components/Select/Select.stories.tsx create mode 100644 packages/components/src/components/Select/Select.test.tsx create mode 100644 packages/components/src/components/Select/Select.tsx create mode 100644 packages/components/src/components/Select/index.tsx create mode 100644 packages/components/src/components/Select/static/ClosedDropdown.tsx create mode 100644 packages/components/src/components/Select/static/OpenedDropdown.tsx create mode 100644 packages/components/src/components/SimpleStepper/SimpleStepper.stories.tsx create mode 100644 packages/components/src/components/SimpleStepper/SimpleStepper.test.tsx create mode 100644 packages/components/src/components/SimpleStepper/SimpleStepper.tsx create mode 100644 packages/components/src/components/SimpleStepper/SimpleStepperFooter.tsx create mode 100644 packages/components/src/components/SimpleStepper/SimpleStepperStep.tsx create mode 100644 packages/components/src/components/SimpleStepper/index.ts create mode 100644 packages/components/src/components/Status/Status.stories.tsx create mode 100644 packages/components/src/components/Status/Status.test.tsx create mode 100644 packages/components/src/components/Status/Status.tsx create mode 100644 packages/components/src/components/Status/index.ts create mode 100644 packages/components/src/components/StructuredMetadataTable/MetadataTable.tsx create mode 100644 packages/components/src/components/StructuredMetadataTable/README.md create mode 100644 packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx create mode 100644 packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx create mode 100644 packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx create mode 100644 packages/components/src/components/StructuredMetadataTable/index.tsx create mode 100644 packages/components/src/components/SupportButton/SupportButton.test.tsx create mode 100644 packages/components/src/components/SupportButton/SupportButton.tsx create mode 100644 packages/components/src/components/SupportButton/index.ts create mode 100644 packages/components/src/components/TabbedLayout/RoutedTabs.test.tsx create mode 100644 packages/components/src/components/TabbedLayout/RoutedTabs.tsx create mode 100644 packages/components/src/components/TabbedLayout/TabbedLayout.stories.tsx create mode 100644 packages/components/src/components/TabbedLayout/TabbedLayout.test.tsx create mode 100644 packages/components/src/components/TabbedLayout/TabbedLayout.tsx create mode 100644 packages/components/src/components/TabbedLayout/index.ts create mode 100644 packages/components/src/components/TabbedLayout/types.ts create mode 100644 packages/components/src/components/Table/Filters.tsx create mode 100644 packages/components/src/components/Table/SubvalueCell.tsx create mode 100644 packages/components/src/components/Table/Table.stories.tsx create mode 100644 packages/components/src/components/Table/Table.test.tsx create mode 100644 packages/components/src/components/Table/Table.tsx create mode 100644 packages/components/src/components/Table/index.ts create mode 100644 packages/components/src/components/Tabs/Tab.test.tsx create mode 100644 packages/components/src/components/Tabs/Tab.tsx create mode 100644 packages/components/src/components/Tabs/TabBar.tsx create mode 100644 packages/components/src/components/Tabs/TabIcon.tsx create mode 100644 packages/components/src/components/Tabs/TabPanel.tsx create mode 100644 packages/components/src/components/Tabs/Tabs.stories.tsx create mode 100644 packages/components/src/components/Tabs/Tabs.tsx create mode 100644 packages/components/src/components/Tabs/index.ts create mode 100644 packages/components/src/components/Tabs/utils.ts create mode 100644 packages/components/src/components/TrendLine/TrendLine.stories.tsx create mode 100644 packages/components/src/components/TrendLine/TrendLine.test.tsx create mode 100644 packages/components/src/components/TrendLine/TrendLine.tsx create mode 100644 packages/components/src/components/TrendLine/index.ts create mode 100644 packages/components/src/components/WarningPanel/WarningPanel.stories.tsx create mode 100644 packages/components/src/components/WarningPanel/WarningPanel.test.tsx create mode 100644 packages/components/src/components/WarningPanel/WarningPanel.tsx create mode 100644 packages/components/src/components/WarningPanel/index.ts create mode 100644 packages/components/src/components/index.ts create mode 100644 packages/components/src/hooks/index.ts create mode 100644 packages/components/src/hooks/useQueryParamState.ts create mode 100644 packages/components/src/hooks/useSupportConfig.ts create mode 100644 packages/components/src/index.ts create mode 100644 packages/components/src/layout/BottomLink/BottomLink.test.tsx create mode 100644 packages/components/src/layout/BottomLink/BottomLink.tsx create mode 100644 packages/components/src/layout/BottomLink/index.ts create mode 100644 packages/components/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx create mode 100644 packages/components/src/layout/Breadcrumbs/Breadcrumbs.test.tsx create mode 100644 packages/components/src/layout/Breadcrumbs/Breadcrumbs.tsx create mode 100644 packages/components/src/layout/Breadcrumbs/index.ts create mode 100644 packages/components/src/layout/Content/Content.tsx create mode 100644 packages/components/src/layout/Content/index.ts create mode 100644 packages/components/src/layout/ContentHeader/ContentHeader.test.tsx create mode 100644 packages/components/src/layout/ContentHeader/ContentHeader.tsx create mode 100644 packages/components/src/layout/ContentHeader/index.ts create mode 100644 packages/components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx create mode 100644 packages/components/src/layout/ErrorBoundary/ErrorBoundary.tsx create mode 100644 packages/components/src/layout/ErrorBoundary/index.ts create mode 100644 packages/components/src/layout/ErrorPage/ErrorPage.test.tsx create mode 100644 packages/components/src/layout/ErrorPage/ErrorPage.tsx create mode 100644 packages/components/src/layout/ErrorPage/MicDrop.tsx create mode 100644 packages/components/src/layout/ErrorPage/index.ts create mode 100644 packages/components/src/layout/ErrorPage/mic-drop.svg create mode 100644 packages/components/src/layout/Header/Header.stories.tsx create mode 100644 packages/components/src/layout/Header/Header.test.tsx create mode 100644 packages/components/src/layout/Header/Header.tsx create mode 100644 packages/components/src/layout/Header/index.ts create mode 100644 packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx create mode 100644 packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx create mode 100644 packages/components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx create mode 100644 packages/components/src/layout/HeaderActionMenu/index.ts create mode 100644 packages/components/src/layout/HeaderLabel/HeaderLabel.test.tsx create mode 100644 packages/components/src/layout/HeaderLabel/HeaderLabel.tsx create mode 100644 packages/components/src/layout/HeaderLabel/index.ts create mode 100644 packages/components/src/layout/HeaderTabs/HeaderTabs.test.tsx create mode 100644 packages/components/src/layout/HeaderTabs/HeaderTabs.tsx create mode 100644 packages/components/src/layout/HeaderTabs/index.tsx create mode 100644 packages/components/src/layout/HomepageTimer/HomepageTimer.test.tsx create mode 100644 packages/components/src/layout/HomepageTimer/HomepageTimer.tsx create mode 100644 packages/components/src/layout/HomepageTimer/index.ts create mode 100644 packages/components/src/layout/InfoCard/InfoCard.stories.tsx create mode 100644 packages/components/src/layout/InfoCard/InfoCard.test.tsx create mode 100644 packages/components/src/layout/InfoCard/InfoCard.tsx create mode 100644 packages/components/src/layout/InfoCard/index.ts create mode 100644 packages/components/src/layout/ItemCard/ItemCard.stories.tsx create mode 100644 packages/components/src/layout/ItemCard/ItemCard.test.tsx create mode 100644 packages/components/src/layout/ItemCard/ItemCard.tsx create mode 100644 packages/components/src/layout/ItemCard/ItemCardGrid.test.tsx create mode 100644 packages/components/src/layout/ItemCard/ItemCardGrid.tsx create mode 100644 packages/components/src/layout/ItemCard/ItemCardHeader.test.tsx create mode 100644 packages/components/src/layout/ItemCard/ItemCardHeader.tsx create mode 100644 packages/components/src/layout/ItemCard/index.ts create mode 100644 packages/components/src/layout/Page/Page.stories.tsx create mode 100644 packages/components/src/layout/Page/Page.tsx create mode 100644 packages/components/src/layout/Page/index.ts create mode 100644 packages/components/src/layout/Sidebar/Bar.tsx create mode 100644 packages/components/src/layout/Sidebar/Intro.tsx create mode 100644 packages/components/src/layout/Sidebar/Items.test.tsx create mode 100644 packages/components/src/layout/Sidebar/Items.tsx create mode 100644 packages/components/src/layout/Sidebar/Page.tsx create mode 100644 packages/components/src/layout/Sidebar/Sidebar.stories.tsx create mode 100644 packages/components/src/layout/Sidebar/config.ts create mode 100644 packages/components/src/layout/Sidebar/index.ts create mode 100644 packages/components/src/layout/Sidebar/localStorage.test.ts create mode 100644 packages/components/src/layout/Sidebar/localStorage.ts create mode 100644 packages/components/src/layout/SignInPage/SignInPage.tsx create mode 100644 packages/components/src/layout/SignInPage/auth0Provider.tsx create mode 100644 packages/components/src/layout/SignInPage/commonProvider.tsx create mode 100644 packages/components/src/layout/SignInPage/customProvider.tsx create mode 100644 packages/components/src/layout/SignInPage/guestProvider.tsx create mode 100644 packages/components/src/layout/SignInPage/index.ts create mode 100644 packages/components/src/layout/SignInPage/providers.tsx create mode 100644 packages/components/src/layout/SignInPage/styles.tsx create mode 100644 packages/components/src/layout/SignInPage/types.ts create mode 100644 packages/components/src/layout/TabbedCard/TabbedCard.stories.tsx create mode 100644 packages/components/src/layout/TabbedCard/TabbedCard.test.tsx create mode 100644 packages/components/src/layout/TabbedCard/TabbedCard.tsx create mode 100644 packages/components/src/layout/TabbedCard/index.ts create mode 100644 packages/components/src/layout/index.ts create mode 100644 packages/components/src/setupTests.ts diff --git a/packages/components/.eslintrc.js b/packages/components/.eslintrc.js new file mode 100644 index 0000000000..d592a653c8 --- /dev/null +++ b/packages/components/.eslintrc.js @@ -0,0 +1,8 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], + rules: { + // TODO: add prop types to JS and remove + 'react/prop-types': 0, + 'jest/expect-expect': 0, + }, +}; diff --git a/packages/components/.npmrc b/packages/components/.npmrc new file mode 100644 index 0000000000..214c29d139 --- /dev/null +++ b/packages/components/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org/ diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md new file mode 100644 index 0000000000..fc61083340 --- /dev/null +++ b/packages/components/CHANGELOG.md @@ -0,0 +1,358 @@ +# @backstage/core + +## 0.7.0 + +### Minor Changes + +- 4c049a1a1: - Adds onClick and other props to IconLinkVertical; + + - Allows TriggerButton component to render when pager duty key is missing; + - Refactors TriggerButton and PagerDutyCard not to have shared state; + - Removes the `action` prop of the IconLinkVertical component while adding `onClick`. + + Instead of having an action including a button with onClick, now the whole component can be clickable making it easier to implement and having a better UX. + + Before: + + ```ts + const myLink: IconLinkVerticalProps = { + label: 'Click me', + action: + + + + Secondary Button: + Used for actions that cancel, skip, and in general perform negative + functions, etc. +
+

color="secondary" variant="contained"
+ + + + + + + Tertiary Button: + Used commonly in a ButtonGroup and when the button function itself is + not a primary function on a page. +
+
color="default" variant="outlined"
+
+ + +
+ + ); +}; + +export const ButtonLinks = () => { + const routeRef = createRouteRef({ + path: '/hello', + title: 'Hi there!', + }); + + const handleClick = () => { + return 'Your click worked!'; + }; + + return ( + <> + + { + // TODO: Refactor to use new routing mechanisms + } + + +   has props for both Material-UI's component as well as for + react-router-dom's Route object. + + + + +   links to a statically defined route. In general, this should be + avoided. + + + + + View URL + +   links to a defined URL using Material-UI's Button. + + + + + Trigger Event + +   triggers an onClick event using Material-UI's Button. + + + + ); +}; diff --git a/packages/components/src/components/Button/Button.test.tsx b/packages/components/src/components/Button/Button.test.tsx new file mode 100644 index 0000000000..8bae5f2767 --- /dev/null +++ b/packages/components/src/components/Button/Button.test.tsx @@ -0,0 +1,42 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent, act } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { Button } from './Button'; +import { Route, Routes } from 'react-router'; + +describe(' + , + ), + ); + + expect(() => getByText(testString)).toThrow(); + await act(async () => { + fireEvent.click(getByText(buttonLabel)); + }); + expect(getByText(testString)).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/components/Button/Button.tsx b/packages/components/src/components/Button/Button.tsx new file mode 100644 index 0000000000..ca45b3da7f --- /dev/null +++ b/packages/components/src/components/Button/Button.tsx @@ -0,0 +1,30 @@ +/* + * 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, { ComponentProps } from 'react'; +import { Button as MaterialButton } from '@material-ui/core'; +import { Link as RouterLink } from 'react-router-dom'; + +type Props = ComponentProps & + ComponentProps; + +/** + * Thin wrapper on top of material-ui's Button component + * Makes the Button to utilise react-router + */ +export const Button = React.forwardRef((props, ref) => ( + +)); diff --git a/packages/components/src/components/Button/index.ts b/packages/components/src/components/Button/index.ts new file mode 100644 index 0000000000..7b584ed799 --- /dev/null +++ b/packages/components/src/components/Button/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 { Button } from './Button'; diff --git a/packages/components/src/components/CheckboxTree/CheckboxTree.stories.tsx b/packages/components/src/components/CheckboxTree/CheckboxTree.stories.tsx new file mode 100644 index 0000000000..db590e6524 --- /dev/null +++ b/packages/components/src/components/CheckboxTree/CheckboxTree.stories.tsx @@ -0,0 +1,105 @@ +/* + * 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, { useState } from 'react'; +import { CheckboxTree } from '.'; + +const CHECKBOX_TREE_ITEMS = [ + { + label: 'Genereic subcategory name 1', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, + { + label: 'Genereic subcategory name 2', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, + { + label: 'Genereic subcategory name 3', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, +]; + +export default { + title: 'Inputs/CheckboxTree', + component: CheckboxTree, +}; + +export const Default = () => ( + {}} + label="default" + subCategories={CHECKBOX_TREE_ITEMS} + /> +); + +export const DynamicTree = () => { + function generateTree(showMore: boolean = false) { + const t = [ + { + label: 'Show more', + options: [], + }, + ]; + + if (showMore) { + t.push({ + label: 'More', + options: [], + }); + } + + return t; + } + + const [tree, setTree] = useState(generateTree()); + + return ( + { + setTree(generateTree(state.some(c => c.category === 'Show more'))); + }} + label="default" + subCategories={tree} + /> + ); +}; diff --git a/packages/components/src/components/CheckboxTree/CheckboxTree.test.tsx b/packages/components/src/components/CheckboxTree/CheckboxTree.test.tsx new file mode 100644 index 0000000000..f5eaa0e58b --- /dev/null +++ b/packages/components/src/components/CheckboxTree/CheckboxTree.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React from 'react'; +import { render, fireEvent } from '@testing-library/react'; + +import { CheckboxTree } from '.'; + +const CHECKBOX_TREE_ITEMS = [ + { + label: 'Genereic subcategory name 1', + options: [ + { + label: 'Option 1', + value: 1, + }, + { + label: 'Option 2', + value: 2, + }, + ], + }, +]; + +const minProps = { + onChange: jest.fn(), + label: 'Default', + subCategories: CHECKBOX_TREE_ITEMS, +}; + +describe('', () => { + it('renders without exploding', async () => { + const { getByText, getByTestId } = render(); + + expect(getByText('Genereic subcategory name 1')).toBeInTheDocument(); + const checkbox = await getByTestId('expandable'); + + // Simulate click on expandable arrow + fireEvent.click(checkbox); + + // Simulate click on option + const option = getByText('Option 1'); + expect(getByText('Option 1')).toBeInTheDocument(); + fireEvent.click(option); + expect(minProps.onChange).toHaveBeenCalled(); + }); +}); diff --git a/packages/components/src/components/CheckboxTree/CheckboxTree.tsx b/packages/components/src/components/CheckboxTree/CheckboxTree.tsx new file mode 100644 index 0000000000..d6d3410913 --- /dev/null +++ b/packages/components/src/components/CheckboxTree/CheckboxTree.tsx @@ -0,0 +1,358 @@ +/* + * 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. + */ +/* eslint-disable guard-for-in */ +import { + Checkbox, + Collapse, + List, + ListItem, + ListItemIcon, + ListItemText, + Typography, +} from '@material-ui/core'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import ExpandLess from '@material-ui/icons/ExpandLess'; +import ExpandMore from '@material-ui/icons/ExpandMore'; +import produce from 'immer'; +import { isEqual } from 'lodash'; +import React, { useEffect, useReducer } from 'react'; +import { usePrevious } from 'react-use'; + +type IndexedObject = { + [key: string]: T; +}; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + width: '100%', + minWidth: 10, + maxWidth: 360, + backgroundColor: 'transparent', + '&:hover': { + backgroundColor: 'transparent', + }, + '&:active': { + animation: 'none', + transform: 'none', + }, + }, + nested: { + paddingLeft: theme.spacing(5), + height: '32px', + '&:hover': { + backgroundColor: 'transparent', + }, + }, + listItemIcon: { + minWidth: 10, + }, + listItem: { + '&:hover': { + backgroundColor: 'transparent', + }, + }, + text: { + '& span, & svg': { + fontWeight: 'normal', + fontSize: 14, + }, + }, + }), +); + +/* SUB_CATEGORY */ + +type SubCategory = { + label: string; + isChecked?: boolean; + isOpen?: boolean; + options?: Option[]; +}; + +type SubCategoryWithIndexedOptions = { + label: string; + isChecked?: boolean; + isOpen?: boolean; + options: IndexedObject