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: ,
+ icon: ,
+ };
+ ```
+
+ After:
+
+ ```ts
+ const myLink: IconLinkVerticalProps = {
+ label: 'Click me',
+ onClick: myAction,
+ icon: ,
+ };
+ ```
+
+### 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.
+- bece09057: Improve rendering of multiple support item links in the `SupportButton`
+- 169f48deb: Added the color prop to TrendLine from the Sparklines props types to be able to have custom colors.
+- 8a1566719: Added a new useSupportConfig hook that reads a new `app.support` config key. Also updated the SupportButton and ErrorPage components to use the new config.
+- Updated dependencies [40c0fdbaa]
+- Updated dependencies [2a271d89e]
+ - @backstage/core-api@0.2.12
+
+## 0.6.3
+
+### Patch Changes
+
+- 3a58084b6: The `FlatRoutes` components now renders the not found page of the app if no routes are matched.
+- e799e74d4: Fix `OverflowTooltip` cutting off the bottom of letters like "g" and "y".
+- 1407b34c6: More informative error message for missing ApiContext.
+- 9615e68fb: Forward link styling of `EntityRefLink` and `EnriryRefLinks` into the underling
+ `Link`.
+- 49f9b7346: Deprecate `type` of `ItemCard` and introduce new `subtitle` which allows passing
+ react nodes.
+- 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.
+- 2c1f2a7c2: Introduced generic OverflowTooltip component for cases where longer text needs to be truncated with ellipsis and show hover tooltip with full text. This is particularly useful in the cases where longer description text is rendered in table. e.g. CatalogTable and ApiExplorerTable.
+- Updated dependencies [3a58084b6]
+- Updated dependencies [1407b34c6]
+- Updated dependencies [b6c4f485d]
+- Updated dependencies [3a58084b6]
+- Updated dependencies [a1f5e6545]
+ - @backstage/core-api@0.2.11
+ - @backstage/config@0.1.3
+
+## 0.6.2
+
+### Patch Changes
+
+- fd3f2a8c0: Export `createExternalRouteRef`, as well as give it an `id` for easier debugging, and fix parameter requirements when used with `useRouteRef`.
+- f4c2bcf54: Use a more strict type for `variant` of cards.
+- 07e226872: Export Select component
+- f62e7abe5: Make sure that SidebarItems are also active when on sub route.
+- 96f378d10: Add support for custom empty state of `Table` components.
+
+ You can now optionally pass `emptyContent` to `Table` that is displayed
+ if the table has now rows.
+
+- 688b73110: Add Breadcrumbs component
+- Updated dependencies [f10950bd2]
+- Updated dependencies [fd3f2a8c0]
+ - @backstage/core-api@0.2.10
+
+## 0.6.1
+
+### Patch Changes
+
+- b51ee6ece: Fixed type inference of `createRouteRef`.
+
+## 0.6.0
+
+### Minor Changes
+
+- 21e624ba9: Closes #3556
+ The scroll bar of collapsed sidebar is now hidden without full screen.
+
+ 
+
+### Patch Changes
+
+- 12ece98cd: Add className to the SidebarItem
+- d82246867: Update `WarningPanel` component to use accordion-style expansion
+- 5fa3bdb55: Add `href` in addition to `onClick` to `ItemCard`. Ensure that the height of a
+ `ItemCard` with and without tags is equal.
+- da9f53c60: Add a `prop` union for `SignInPage` that allows it to be used for just a single provider, with inline errors, and optionally with automatic sign-in.
+- 32c95605f: Fix check that determines whether popup was closed or the messaging was misconfigured.
+- 54c7d02f7: Introduce `TabbedLayout` for creating tabs that are routed.
+
+ ```typescript
+
+
+ This is rendered under /example/anything-here route
+
+
+ ```
+
+- Updated dependencies [c810082ae]
+ - @backstage/theme@0.2.3
+
+## 0.5.0
+
+### Minor Changes
+
+- efd6ef753: Removed `InfoCard` variant `height100`, originally deprecated in [#2826](https://github.com/backstage/backstage/pull/2826).
+
+ If your component still relies on this variant, simply replace it with `gridItem`.
+
+- a187b8ad0: Removed deprecated `router.registerRoute` method in `createPlugin`.
+
+ Deprecated `router.addRoute` method in `createPlugin`.
+
+ Replace usage of the above two components with a routable extension.
+
+ For example, given the following:
+
+ ```ts
+ import { createPlugin } from '@backstage/core';
+ import { MyPage } from './components/MyPage';
+ import { rootRoute } from './routes';
+
+ export const plugin = createPlugin({
+ id: 'my-plugin',
+ register({ router }) {
+ router.addRoute(rootRoute, MyPage);
+ },
+ });
+ ```
+
+ Migrate to
+
+ ```ts
+ import { createPlugin, createRoutableExtension } from '@backstage/core';
+ import { rootRoute } from './routes';
+
+ export const plugin = createPlugin({
+ id: 'my-plugin',
+ routes: {
+ root: rootRoute,
+ },
+ });
+
+ export const MyPage = plugin.provide(
+ createRoutableExtension({
+ component: () => import('./components/MyPage').then(m => m.MyPage),
+ mountPoint: rootRoute,
+ }),
+ );
+ ```
+
+ And then use `MyPage` like this in the app:
+
+ ```tsx
+
+ ...
+ }>
+ ...
+
+ ```
+
+## 0.4.4
+
+### Patch Changes
+
+- 265a7ab30: Fix issue where `SidebarItem` with `onClick` and without `to` renders an inaccessible div. It now renders a button.
+
+## 0.4.3
+
+### 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.
+- Updated dependencies [a08c32ced]
+- Updated dependencies [86c3c652a]
+- Updated dependencies [27f2af935]
+ - @backstage/core-api@0.2.8
+
+## 0.4.2
+
+### Patch Changes
+
+- 1dc445e89: Update to use new plugin extension API
+- 342270e4d: Create AboutCard in core and use it in pagerduty and catalog plugin
+- Updated dependencies [d681db2b5]
+- Updated dependencies [1dc445e89]
+ - @backstage/core-api@0.2.7
+
+## 0.4.1
+
+### Patch Changes
+
+- 8ef71ed32: Add a `` component to `@backstage/core`.
+- Updated dependencies [7dd2ef7d1]
+ - @backstage/core-api@0.2.6
+
+## 0.4.0
+
+### Minor Changes
+
+- ff243ce96: Introducing a new optional property within `app-config.yaml` called `auth.environment` to have configurable environment value for `auth.providers`
+
+ **Default Value:** 'development'
+
+ **Optional Values:** 'production' | 'development'
+
+ **Migration-steps:**
+
+ - To override the default value, one could simply introduce the new property `environment` within the `auth` section of the `config.yaml`
+ - re-run the build to reflect the changed configs
+
+### Patch Changes
+
+- 2527628e1: Link `component` prop now accepts any element type.
+- 1c69d4716: Fix React warning of descendant paragraph tag
+- 04f26f88d: Export the `defaultConfigLoader` implementation
+- Updated dependencies [b6557c098]
+- Updated dependencies [e3bd9fc2f]
+- Updated dependencies [d8d5a17da]
+- Updated dependencies [1665ae8bb]
+- Updated dependencies [e3bd9fc2f]
+ - @backstage/core-api@0.2.5
+ - @backstage/config@0.1.2
+ - @backstage/theme@0.2.2
+
+## 0.3.2
+
+### Patch Changes
+
+- 475fc0aaa: Clear sidebar search field once a search is executed
+
+## 0.3.1
+
+### Patch Changes
+
+- 1722cb53c: Added configuration schema
+
+## 0.3.0
+
+### Minor Changes
+
+- 199237d2f: New DependencyGraph component added to core package.
+
+### Patch Changes
+
+- 7b37d65fd: Adds the MarkdownContent component to render and display Markdown content with the default
+ [GFM](https://github.github.com/gfm/) (GitHub Flavored Markdown) dialect.
+
+ ```
+
+ ```
+
+ To render the Markdown content with plain [CommonMark](https://commonmark.org/), set the dialect to `common-mark`
+
+ ```
+ ...`
+
+ Changed the InfoCards in '@backstage/plugin-github-actions', '@backstage/plugin-jenkins', '@backstage/plugin-lighthouse'
+ to pass an optional variant to the corresponding card of the plugin.
+
+ As a result the overview content of the EntityPage shows cards with full height suitable for Grid.
+
+### Patch Changes
+
+- ae5983387: Fix banner position and color
+
+ This PR closes: #2245
+
+ The "fixed" props added to control the position of the banner. When it is set to true the banner will be shown in bottom of that page and the width will be based on the content of the message.
+
+ 
+
+ 
+
+- 144c66d50: Fixed banner component position in DismissableBanner component
+- 93a3fa3ae: Add forwardRef to the SidebarItem
+- 782f3b354: add test case for Progress component
+- 2713f28f4: fix the warning of all the core components test cases
+- 406015b0d: Update ItemCard headers to pass color contrast standards.
+- 82759d3e4: rename stories folder top Chip
+- ac8d5d5c7: update the test cases of CodeSnippet component
+- ebca83d48: add test cases for Status components
+- aca79334f: update ItemCard component and it's story
+- c0d5242a0: Proper render boolean values on StructuredMetadataTable component
+- 3beb5c9fc: make ErrorPage responsive + fix the test case
+- 754e31db5: give aria-label attribute to Status Ok, Warning and Error
+- 1611c6dbc: fix the responsive of page story
+- Updated dependencies [819a70229]
+- Updated dependencies [ae5983387]
+- Updated dependencies [0d4459c08]
+- Updated dependencies [cbbd271c4]
+- Updated dependencies [b79017fd3]
+- Updated dependencies [26e69ab1a]
+- Updated dependencies [cbab5bbf8]
+ - @backstage/core-api@0.2.0
+ - @backstage/theme@0.2.0
diff --git a/packages/components/README.md b/packages/components/README.md
new file mode 100644
index 0000000000..6d8519d46f
--- /dev/null
+++ b/packages/components/README.md
@@ -0,0 +1,22 @@
+# @backstage/core
+
+This package provides the core API used by Backstage plugins and apps.
+
+## Installation
+
+Install the package via npm or Yarn:
+
+```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/components/config.d.ts b/packages/components/config.d.ts
new file mode 100644
index 0000000000..9e43c6f3ad
--- /dev/null
+++ b/packages/components/config.d.ts
@@ -0,0 +1,113 @@
+/*
+ * 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 interface Config {
+ /**
+ * Generic frontend configuration.
+ */
+ app: {
+ /**
+ * The public absolute root URL that the frontend.
+ * @visibility frontend
+ */
+ baseUrl: string;
+
+ /**
+ * The title of the app.
+ * @visibility frontend
+ */
+ title?: string;
+
+ /**
+ * Information about support of this Backstage instance and how to contact the integrator team.
+ */
+ support?: {
+ /**
+ * The primary support url.
+ * @visibility frontend
+ */
+ url: string;
+ /**
+ * A list of categorized support item groupings.
+ */
+ items: {
+ /**
+ * The title of the support item grouping.
+ * @visibility frontend
+ */
+ title: string;
+ /**
+ * An optional icon for the support item grouping.
+ * @visibility frontend
+ */
+ icon?: string;
+ /**
+ * A list of support links for the Backstage instance.
+ */
+ links: {
+ /** @visibility frontend */
+ url: string;
+ /** @visibility frontend */
+ title?: string;
+ }[];
+ }[];
+ };
+ };
+
+ /**
+ * Generic backend configuration.
+ */
+ backend: {
+ /**
+ * The public absolute root URL that the backend is reachable at.
+ * @visibility frontend
+ */
+ baseUrl: string;
+ };
+
+ /**
+ * Configuration that provides information about the organization that the app is for.
+ */
+ organization?: {
+ /**
+ * The name of the organization that the app belongs to.
+ * @visibility frontend
+ */
+ name?: string;
+ };
+
+ homepage?: {
+ clocks?: {
+ /** @visibility frontend */
+ label: string;
+ /** @visibility frontend */
+ timezone: string;
+ }[];
+ };
+
+ /**
+ * Configuration that provides information on available authentication providers configured for app
+ */
+ auth?: {
+ /**
+ * The 'environment' attribute added as an optional parameter to have configurable environment value for `auth.providers`.
+ * default value: 'development'
+ * optional values: 'development' | 'production'
+ * @visibility frontend
+ */
+ environment?: string;
+ };
+}
diff --git a/packages/components/package.json b/packages/components/package.json
new file mode 100644
index 0000000000..623eb64a3e
--- /dev/null
+++ b/packages/components/package.json
@@ -0,0 +1,91 @@
+{
+ "name": "@backstage/components",
+ "description": "Core components used by Backstage plugins and apps",
+ "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/core"
+ },
+ "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/plugin-api": "^0.1.0",
+ "@backstage/theme": "^0.2.3",
+ "@material-ui/core": "^4.11.0",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.45",
+ "@testing-library/react-hooks": "^3.4.2",
+ "@types/dagre": "^0.7.44",
+ "@types/prop-types": "^15.7.3",
+ "@types/react": "^16.9",
+ "@types/react-sparklines": "^1.7.0",
+ "@types/react-text-truncate": "^0.14.0",
+ "classnames": "^2.2.6",
+ "clsx": "^1.1.0",
+ "d3-selection": "^2.0.0",
+ "d3-shape": "^2.0.0",
+ "d3-zoom": "^2.0.0",
+ "dagre": "^0.8.5",
+ "immer": "^8.0.1",
+ "lodash": "^4.17.15",
+ "material-table": "^1.69.1",
+ "prop-types": "^15.7.2",
+ "qs": "^6.9.4",
+ "rc-progress": "^3.0.0",
+ "react": "^16.12.0",
+ "react-dom": "^16.12.0",
+ "react-helmet": "6.1.0",
+ "react-hook-form": "^6.6.0",
+ "react-markdown": "^5.0.2",
+ "react-router": "6.0.0-beta.0",
+ "react-router-dom": "6.0.0-beta.0",
+ "react-sparklines": "^1.7.0",
+ "react-syntax-highlighter": "^13.5.1",
+ "react-text-truncate": "^0.16.0",
+ "react-use": "^15.3.3",
+ "remark-gfm": "^1.0.0",
+ "zen-observable": "^0.8.15"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.6.3",
+ "@backstage/test-utils": "^0.1.8",
+ "@testing-library/jest-dom": "^5.10.1",
+ "@testing-library/react": "^11.2.5",
+ "@testing-library/user-event": "^12.0.7",
+ "@types/classnames": "^2.2.9",
+ "@types/d3-selection": "^2.0.0",
+ "@types/d3-shape": "^2.0.0",
+ "@types/d3-zoom": "^2.0.0",
+ "@types/google-protobuf": "^3.7.2",
+ "@types/jest": "^26.0.7",
+ "@types/node": "^12.0.0",
+ "@types/react-helmet": "^6.1.0",
+ "@types/zen-observable": "^0.8.0"
+ },
+ "files": [
+ "dist",
+ "config.d.ts"
+ ],
+ "configSchema": "config.d.ts"
+}
diff --git a/packages/components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/components/src/components/AlertDisplay/AlertDisplay.test.tsx
new file mode 100644
index 0000000000..3f85dab9f3
--- /dev/null
+++ b/packages/components/src/components/AlertDisplay/AlertDisplay.test.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 { AlertDisplay } from './AlertDisplay';
+import {
+ ApiProvider,
+ ApiRegistry,
+ alertApiRef,
+ AlertApiForwarder,
+} from '@backstage/core-api';
+import Observable from 'zen-observable';
+import { renderInTestApp } from '@backstage/test-utils';
+
+const TEST_MESSAGE = 'TEST_MESSAGE';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const apiRegistry = ApiRegistry.from([
+ [alertApiRef, new AlertApiForwarder()],
+ ]);
+
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+ expect(queryByText(TEST_MESSAGE)).not.toBeInTheDocument();
+ });
+
+ it('renders with message', async () => {
+ const apiRegistry = ApiRegistry.from([
+ [
+ alertApiRef,
+ {
+ post() {},
+ alert$() {
+ return Observable.of({ message: TEST_MESSAGE });
+ },
+ },
+ ],
+ ]);
+
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(queryByText(TEST_MESSAGE)).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/AlertDisplay/AlertDisplay.tsx b/packages/components/src/components/AlertDisplay/AlertDisplay.tsx
new file mode 100644
index 0000000000..6d6646fa18
--- /dev/null
+++ b/packages/components/src/components/AlertDisplay/AlertDisplay.tsx
@@ -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 React, { useEffect, useState } from 'react';
+import { Snackbar, IconButton } from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import { Alert } from '@material-ui/lab';
+import { AlertMessage, useApi, alertApiRef } from '@backstage/core-api';
+
+// TODO: improve on this and promote to a shared component for use by all apps.
+export const AlertDisplay = () => {
+ const [messages, setMessages] = useState>([]);
+ const alertApi = useApi(alertApiRef);
+
+ useEffect(() => {
+ const subscription = alertApi
+ .alert$()
+ .subscribe(message => setMessages(msgs => msgs.concat(message)));
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [alertApi]);
+
+ if (messages.length === 0) {
+ return null;
+ }
+
+ const [firstMessage] = messages;
+
+ const handleClose = () => {
+ setMessages(msgs => msgs.filter(msg => msg !== firstMessage));
+ };
+
+ return (
+
+
+
+
+ }
+ severity={firstMessage.severity}
+ >
+ {firstMessage.message.toString()}
+
+
+ );
+};
diff --git a/packages/components/src/components/AlertDisplay/index.ts b/packages/components/src/components/AlertDisplay/index.ts
new file mode 100644
index 0000000000..72aa1c5ad8
--- /dev/null
+++ b/packages/components/src/components/AlertDisplay/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 { AlertDisplay } from './AlertDisplay';
diff --git a/packages/components/src/components/Avatar/Avatar.stories.tsx b/packages/components/src/components/Avatar/Avatar.stories.tsx
new file mode 100644
index 0000000000..5ac628d72b
--- /dev/null
+++ b/packages/components/src/components/Avatar/Avatar.stories.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 { Avatar } from './Avatar';
+
+export default {
+ title: 'Data Display/Avatar',
+ component: Avatar,
+};
+
+export const Default = () => (
+
+);
+
+export const NameFallback = () => ;
+
+export const Empty = () => ;
+
+export const CustomStyling = () => (
+
+);
diff --git a/packages/components/src/components/Avatar/Avatar.test.tsx b/packages/components/src/components/Avatar/Avatar.test.tsx
new file mode 100644
index 0000000000..da6ca8f42e
--- /dev/null
+++ b/packages/components/src/components/Avatar/Avatar.test.tsx
@@ -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 { render } from '@testing-library/react';
+import React from 'react';
+import { Avatar } from './Avatar';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText } = render( );
+
+ expect(getByText('JD')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Avatar/Avatar.tsx b/packages/components/src/components/Avatar/Avatar.tsx
new file mode 100644
index 0000000000..95aa4a8ced
--- /dev/null
+++ b/packages/components/src/components/Avatar/Avatar.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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, { CSSProperties } from 'react';
+import {
+ Avatar as MaterialAvatar,
+ createStyles,
+ makeStyles,
+ Theme,
+} from '@material-ui/core';
+import { extractInitials, stringToColor } from './utils';
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ avatar: {
+ width: '4rem',
+ height: '4rem',
+ color: '#fff',
+ fontWeight: theme.typography.fontWeightBold,
+ letterSpacing: '1px',
+ textTransform: 'uppercase',
+ },
+ }),
+);
+
+export type AvatarProps = {
+ displayName?: string;
+ picture?: string;
+ customStyles?: CSSProperties;
+};
+
+export const Avatar = ({ displayName, picture, customStyles }: AvatarProps) => {
+ const classes = useStyles();
+ return (
+
+ {displayName && extractInitials(displayName)}
+
+ );
+};
diff --git a/packages/components/src/components/Avatar/index.ts b/packages/components/src/components/Avatar/index.ts
new file mode 100644
index 0000000000..962414634e
--- /dev/null
+++ b/packages/components/src/components/Avatar/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { Avatar } from './Avatar';
diff --git a/packages/components/src/components/Avatar/util.test.ts b/packages/components/src/components/Avatar/util.test.ts
new file mode 100644
index 0000000000..94de957e8e
--- /dev/null
+++ b/packages/components/src/components/Avatar/util.test.ts
@@ -0,0 +1,37 @@
+/*
+ * 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 { extractInitials, stringToColor } from './utils';
+
+describe('stringToColor', () => {
+ it('extract color', async () => {
+ expect(stringToColor('Jenny Doe')).toEqual('#7809fa');
+ });
+});
+
+describe('extractInitials', () => {
+ it('extract initials', async () => {
+ expect(extractInitials('Jenny Doe')).toEqual('JD');
+ });
+
+ it('extract single letter for short name', async () => {
+ expect(extractInitials('Doe')).toEqual('D');
+ });
+
+ it('limit the initials to two letters', async () => {
+ expect(extractInitials('John Jonathan Doe')).toEqual('JJ');
+ });
+});
diff --git a/packages/components/src/components/Avatar/utils.ts b/packages/components/src/components/Avatar/utils.ts
new file mode 100644
index 0000000000..5990a72955
--- /dev/null
+++ b/packages/components/src/components/Avatar/utils.ts
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export function stringToColor(str: string) {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = str.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ let color = '#';
+ for (let i = 0; i < 3; i++) {
+ const value = (hash >> (i * 8)) & 0xff;
+ color += `00${value.toString(16)}`.substr(-2);
+ }
+ return color;
+}
+
+export function extractInitials(value: string) {
+ return value.match(/\b\w/g)!.join('').substring(0, 2);
+}
diff --git a/packages/components/src/components/Button/Button.stories.tsx b/packages/components/src/components/Button/Button.stories.tsx
new file mode 100644
index 0000000000..af95e64d52
--- /dev/null
+++ b/packages/components/src/components/Button/Button.stories.tsx
@@ -0,0 +1,174 @@
+/*
+ * 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, { FunctionComponentFactory } from 'react';
+import { Button } from './Button';
+import { MemoryRouter, useLocation } from 'react-router-dom';
+import { createRouteRef } from '@backstage/core-api';
+import {
+ Divider,
+ Link,
+ List,
+ ListItem,
+ ListItemText,
+ Typography,
+ Button as MaterialButton,
+} from '@material-ui/core';
+
+const Location = () => {
+ const location = useLocation();
+ return Current location: {location.pathname} ;
+};
+
+export default {
+ title: 'Inputs/Button',
+ component: Button,
+ decorators: [
+ (storyFn: FunctionComponentFactory<{}>) => (
+ <>
+
+ A collection of buttons that should be used in the Backstage
+ interface. These leverage the properties inherited from{' '}
+
+ Material-UI Button
+
+ , but include an opinionated set that align to the Backstage design.
+
+
+
+
+
+
+
+ >
+ ),
+ ],
+};
+
+export const Default = () => {
+ const routeRef = createRouteRef({
+ path: '/hello',
+ title: 'Hi there!',
+ });
+
+ // Design Permutations:
+ // color = default | primary | secondary
+ // variant = contained | outlined | text
+ return (
+
+
+
+ Default Button:
+ This is the default button design which should be used in most cases.
+
+ color="primary" variant="contained"
+
+
+
+ Register Component
+
+
+
+
+ Secondary Button:
+ Used for actions that cancel, skip, and in general perform negative
+ functions, etc.
+
+ color="secondary" variant="contained"
+
+
+
+ Cancel
+
+
+
+
+ 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"
+
+
+
+ View Details
+
+
+
+ );
+};
+
+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
+ }
+
+
+ Route Ref
+
+ has props for both Material-UI's component as well as for
+ react-router-dom's Route object.
+
+
+
+
+ Static Path
+
+ 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(' ', () => {
+ it('navigates using react-router', async () => {
+ const testString = 'This is test string';
+ const buttonLabel = 'Navigate!';
+ const { getByText } = render(
+ wrapInTestApp(
+
+ {testString}
} />
+ {buttonLabel}
+ ,
+ ),
+ );
+
+ 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;
+};
+
+/* OPTION */
+
+type Option = {
+ label: string;
+ value: string | number;
+ isChecked?: boolean;
+};
+
+type Selection = { category?: string; selectedChildren?: string[] }[];
+
+export type CheckboxTreeProps = {
+ subCategories: SubCategory[];
+ label: string;
+ triggerReset?: boolean;
+ selected?: Selection;
+ onChange: (arg: Selection) => any;
+};
+
+/* REDUCER */
+
+type checkOptionPayload = {
+ subCategoryLabel: string;
+ optionLabel: string;
+};
+
+type Action =
+ | { type: 'checkOption'; payload: checkOptionPayload }
+ | { type: 'checkCategory'; payload: string }
+ | { type: 'toggleCategory'; payload: string }
+ | {
+ type: 'updateCategories';
+ payload: IndexedObject;
+ }
+ | { type: 'updateSelected'; payload: Selection }
+ | { type: 'triggerReset' };
+
+const reducer = (
+ state: IndexedObject,
+ action: Action,
+) => {
+ switch (action.type) {
+ case 'checkOption': {
+ return produce(state, newState => {
+ const category = newState[action.payload.subCategoryLabel];
+ const option = category.options[action.payload.optionLabel];
+ option.isChecked = !option.isChecked;
+ category.isChecked = Object.values(category.options).every(
+ o => o.isChecked,
+ );
+ });
+ }
+ case 'checkCategory': {
+ return produce(state, newState => {
+ const category = newState[action.payload];
+ const options = category.options;
+ category.isChecked = !category.isChecked;
+ for (const option in options) {
+ options[option].isChecked = category.isChecked;
+ }
+ });
+ }
+ case 'toggleCategory':
+ return produce(state, newState => {
+ const category = newState[action.payload];
+ category.isOpen = !category.isOpen;
+ });
+ case 'triggerReset': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ newState[category].isChecked = false;
+ for (const option in newState[category].options) {
+ newState[category].options[option].isChecked =
+ newState[category].isChecked;
+ }
+ }
+ });
+ }
+ case 'updateCategories': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ delete newState[category];
+ }
+
+ for (const category in action.payload) {
+ newState[category] = action.payload[category];
+
+ if (state[category]) {
+ newState[category].isChecked = state[category].isChecked;
+ newState[category].isOpen = state[category].isOpen;
+ }
+ }
+ });
+ }
+ case 'updateSelected': {
+ return produce(state, newState => {
+ for (const category in newState) {
+ const selection = action.payload.find(s => s.category === category);
+
+ if (selection) {
+ newState[category].isChecked = true;
+
+ for (const option in newState[category].options) {
+ newState[category].options[option].isChecked =
+ selection.selectedChildren?.includes(option) || false;
+ }
+ }
+ }
+ });
+ }
+ default:
+ return state;
+ }
+};
+
+const indexer = (
+ arr: SubCategory[],
+): IndexedObject =>
+ arr.reduce((accumulator, el) => {
+ if (el.options) {
+ return {
+ ...accumulator,
+ [el.label]: {
+ label: el.label,
+ isChecked: el.isChecked || false,
+ isOpen: false,
+ options: indexer(el.options),
+ },
+ };
+ }
+ return {
+ ...accumulator,
+ [el.label]: { ...el, isChecked: el.isChecked || false },
+ };
+ }, {});
+
+export const CheckboxTree = ({
+ subCategories,
+ label,
+ selected,
+ onChange,
+ triggerReset,
+}: CheckboxTreeProps) => {
+ const classes = useStyles();
+
+ const [state, dispatch] = useReducer(reducer, indexer(subCategories));
+
+ const handleOpen = (event: any, value: any) => {
+ event.stopPropagation();
+ dispatch({ type: 'toggleCategory', payload: value });
+ };
+
+ const previousSubCategories = usePrevious(subCategories);
+
+ useEffect(() => {
+ const values = Object.values(state).map(category => ({
+ category: category.isChecked ? category.label : undefined,
+ selectedChildren: Object.values(category.options)
+ .filter(option => option.isChecked)
+ .map(option => option.label),
+ }));
+ onChange(values);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [state]);
+
+ useEffect(() => {
+ dispatch({ type: 'triggerReset' });
+ }, [triggerReset]);
+
+ useEffect(() => {
+ if (selected) {
+ dispatch({ type: 'updateSelected', payload: selected });
+ }
+ }, [selected]);
+
+ useEffect(() => {
+ if (!isEqual(subCategories, previousSubCategories)) {
+ dispatch({
+ type: 'updateCategories',
+ payload: indexer(subCategories),
+ });
+ }
+ }, [subCategories, previousSubCategories]);
+
+ return (
+
+
{label}
+
+ {Object.values(state).map(item => (
+
+
+ dispatch({
+ type: 'checkCategory',
+ payload: item.label,
+ })
+ }
+ >
+
+
+
+
+ {Object.values(item.options).length ? (
+ <>
+ {item.isOpen ? (
+ handleOpen(event, item.label)}
+ />
+ ) : (
+ handleOpen(event, item.label)}
+ />
+ )}
+ >
+ ) : null}
+
+
+
+ {Object.values(item.options).map(option => (
+
+ dispatch({
+ type: 'checkOption',
+ payload: {
+ subCategoryLabel: item.label,
+ optionLabel: option.label,
+ },
+ })
+ }
+ >
+
+
+
+
+
+ ))}
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/components/src/components/CheckboxTree/index.tsx b/packages/components/src/components/CheckboxTree/index.tsx
new file mode 100644
index 0000000000..d8e62460ab
--- /dev/null
+++ b/packages/components/src/components/CheckboxTree/index.tsx
@@ -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 { CheckboxTree } from './CheckboxTree';
diff --git a/packages/components/src/components/Chip/Chip.stories.tsx b/packages/components/src/components/Chip/Chip.stories.tsx
new file mode 100644
index 0000000000..87d904ad0e
--- /dev/null
+++ b/packages/components/src/components/Chip/Chip.stories.tsx
@@ -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 React from 'react';
+import { Chip } from '@material-ui/core';
+
+export default {
+ title: 'Data Display/Chip',
+ component: Chip,
+};
+
+export const Default = () => ;
+
+export const LargeDeletable = () => (
+ ({})} />
+);
+
+export const LargeNotDeletable = () => (
+
+);
+
+export const SmallDeletable = () => (
+ ({})} />
+);
+
+export const SmallNotDeletable = () => (
+
+);
+
+export const Outline = () => ;
diff --git a/packages/components/src/components/CodeSnippet/CodeSnippet.stories.tsx b/packages/components/src/components/CodeSnippet/CodeSnippet.stories.tsx
new file mode 100644
index 0000000000..6df7e5bb6d
--- /dev/null
+++ b/packages/components/src/components/CodeSnippet/CodeSnippet.stories.tsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { CodeSnippet } from './CodeSnippet';
+import { InfoCard } from '../../layout/InfoCard';
+
+export default {
+ title: 'Data Display/CodeSnippet',
+ component: CodeSnippet,
+};
+
+const containerStyle = { width: 300 };
+
+const JAVASCRIPT = `const greeting = "Hello";
+const world = "World";
+
+const greet = person => greeting + " " + person + "!";
+
+greet(world);
+`;
+
+const TYPESCRIPT = `const greeting: string = "Hello";
+const world: string = "World";
+
+const greet = (person: string): string => greeting + " " + person + "!";
+
+greet(world);
+`;
+
+const PYTHON = `greeting = "Hello"
+world = "World"
+
+def greet(person):
+ return f"{greeting} {person}!"
+
+greet(world)
+`;
+
+export const Default = () => (
+
+
+
+);
+
+export const MultipleLines = () => (
+
+
+
+);
+
+export const LineNumbers = () => (
+
+
+
+);
+
+export const Overflow = () => (
+
+
+
+
+
+
+
+
+);
+
+export const Languages = () => (
+
+
+
+
+
+);
+
+export const CopyCode = () => (
+
+
+
+);
diff --git a/packages/components/src/components/CodeSnippet/CodeSnippet.test.tsx b/packages/components/src/components/CodeSnippet/CodeSnippet.test.tsx
new file mode 100644
index 0000000000..7d5d4de087
--- /dev/null
+++ b/packages/components/src/components/CodeSnippet/CodeSnippet.test.tsx
@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
+import { renderInTestApp } from '@backstage/test-utils';
+
+import { CodeSnippet } from './CodeSnippet';
+
+const JAVASCRIPT = `
+ const greeting = "Hello";
+ const world = "World";
+ const greet = person => gretting + " " + person + "!";
+`;
+
+const minProps = {
+ text: JAVASCRIPT,
+ language: 'javascript',
+};
+
+describe(' ', () => {
+ it('renders text without exploding', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText(/"Hello"/)).toBeInTheDocument();
+ expect(getByText(/"World"/)).toBeInTheDocument();
+ });
+
+ it('renders without line numbers', async () => {
+ const { queryByText } = await renderInTestApp(
+ ,
+ );
+ expect(queryByText('1')).not.toBeInTheDocument();
+ expect(queryByText('2')).not.toBeInTheDocument();
+ expect(queryByText('3')).not.toBeInTheDocument();
+ });
+
+ it('renders with line numbers', async () => {
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ expect(getByText('1')).toBeInTheDocument();
+ expect(getByText('2')).toBeInTheDocument();
+ expect(getByText('3')).toBeInTheDocument();
+ });
+
+ it('copy code using button', async () => {
+ jest.useFakeTimers();
+ document.execCommand = jest.fn();
+ const { getByTitle } = await renderInTestApp(
+ ,
+ );
+ const button = getByTitle('Text copied to clipboard');
+ fireEvent.click(button);
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(document.execCommand).toHaveBeenCalled();
+ jest.useRealTimers();
+ });
+});
diff --git a/packages/components/src/components/CodeSnippet/CodeSnippet.tsx b/packages/components/src/components/CodeSnippet/CodeSnippet.tsx
new file mode 100644
index 0000000000..84b5aa403e
--- /dev/null
+++ b/packages/components/src/components/CodeSnippet/CodeSnippet.tsx
@@ -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 React from 'react';
+import SyntaxHighlighter from 'react-syntax-highlighter';
+import { docco, dark } from 'react-syntax-highlighter/dist/cjs/styles/hljs';
+import { useTheme } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+import { CopyTextButton } from '../CopyTextButton';
+
+type Props = {
+ text: string;
+ language: string;
+ showLineNumbers?: boolean;
+ showCopyCodeButton?: boolean;
+ highlightedNumbers?: number[];
+ customStyle?: any;
+};
+
+export const CodeSnippet = ({
+ text,
+ language,
+ showLineNumbers = false,
+ showCopyCodeButton = false,
+ highlightedNumbers,
+ customStyle,
+}: Props) => {
+ const theme = useTheme();
+ const mode = theme.palette.type === 'dark' ? dark : docco;
+ const highlightColor = theme.palette.type === 'dark' ? '#256bf3' : '#e6ffed';
+ return (
+
+
+ highlightedNumbers?.includes(lineNumber)
+ ? {
+ style: {
+ backgroundColor: highlightColor,
+ },
+ }
+ : {}
+ }
+ >
+ {text}
+
+ {showCopyCodeButton && (
+
+
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/components/CodeSnippet/index.tsx b/packages/components/src/components/CodeSnippet/index.tsx
new file mode 100644
index 0000000000..11ca1ecde2
--- /dev/null
+++ b/packages/components/src/components/CodeSnippet/index.tsx
@@ -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 { CodeSnippet } from './CodeSnippet';
diff --git a/packages/components/src/components/CopyTextButton/CopyTextButton.stories.tsx b/packages/components/src/components/CopyTextButton/CopyTextButton.stories.tsx
new file mode 100644
index 0000000000..e6f5f8cece
--- /dev/null
+++ b/packages/components/src/components/CopyTextButton/CopyTextButton.stories.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 { CopyTextButton } from '.';
+
+export default {
+ title: 'Inputs/CopyTextButton',
+ component: CopyTextButton,
+};
+
+export const Default = () => (
+
+);
+
+export const WithTooltip = () => (
+
+);
+
+export const LongerTooltipDelay = () => (
+
+);
diff --git a/packages/components/src/components/CopyTextButton/CopyTextButton.test.tsx b/packages/components/src/components/CopyTextButton/CopyTextButton.test.tsx
new file mode 100644
index 0000000000..07bce97d74
--- /dev/null
+++ b/packages/components/src/components/CopyTextButton/CopyTextButton.test.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+import { act } from 'react-dom/test-utils';
+import { renderInTestApp } from '@backstage/test-utils';
+import { CopyTextButton } from './CopyTextButton';
+import {
+ ApiRegistry,
+ errorApiRef,
+ ApiProvider,
+ ErrorApi,
+} from '@backstage/core-api';
+
+jest.mock('popper.js', () => {
+ const PopperJS = jest.requireActual('popper.js');
+
+ return class {
+ static placements = PopperJS.placements;
+ update() {}
+ destroy() {}
+ scheduleUpdate() {}
+ };
+});
+
+const props = {
+ text: 'mockText',
+ tooltipDelay: 2,
+ tooltipText: 'mockTooltip',
+};
+
+const apiRegistry = ApiRegistry.from([
+ [
+ errorApiRef,
+ {
+ post(error) {
+ throw error;
+ },
+ error$: jest.fn(),
+ } as ErrorApi,
+ ],
+]);
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByDisplayValue } = await renderInTestApp(
+
+
+ ,
+ );
+ getByDisplayValue('mockText');
+ });
+
+ it('displays tooltip on click', async () => {
+ jest.useFakeTimers();
+ document.execCommand = jest.fn();
+ const rendered = await renderInTestApp(
+
+
+ ,
+ );
+ const button = rendered.getByTitle('mockTooltip');
+ fireEvent.click(button);
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(document.execCommand).toHaveBeenCalled();
+ rendered.getByText('mockTooltip');
+ jest.useRealTimers();
+ });
+});
diff --git a/packages/components/src/components/CopyTextButton/CopyTextButton.tsx b/packages/components/src/components/CopyTextButton/CopyTextButton.tsx
new file mode 100644
index 0000000000..9f6cdd7e7c
--- /dev/null
+++ b/packages/components/src/components/CopyTextButton/CopyTextButton.tsx
@@ -0,0 +1,111 @@
+/*
+ * 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, { useRef, useState, MouseEventHandler } from 'react';
+import { IconButton, makeStyles, Tooltip } from '@material-ui/core';
+import PropTypes from 'prop-types';
+import CopyIcon from '@material-ui/icons/FileCopy';
+import { BackstageTheme } from '@backstage/theme';
+import { errorApiRef, useApi } from '@backstage/core-api';
+
+const useStyles = makeStyles(theme => ({
+ button: {
+ '&:hover': {
+ backgroundColor: theme.palette.highlight,
+ cursor: 'pointer',
+ },
+ },
+}));
+
+/**
+ * Copy text button with visual feedback in the form of
+ * - a hover color
+ * - click ripple
+ * - Tooltip shown when user has clicked
+ *
+ * Properties:
+ * - text: the text to be copied
+ * - tooltipDelay: Number os ms to show the tooltip, default: 1000ms
+ * - tooltipText: Text to show in the tooltip when user has clicked the button, default: "Text
+ * copied to clipboard"
+ *
+ * Example:
+ *
+ */
+type Props = {
+ text: string;
+ tooltipDelay?: number;
+ tooltipText?: string;
+};
+
+const defaultProps = {
+ tooltipDelay: 1000,
+ tooltipText: 'Text copied to clipboard',
+};
+
+export const CopyTextButton = (props: Props) => {
+ const { text, tooltipDelay, tooltipText } = {
+ ...defaultProps,
+ ...props,
+ };
+ const classes = useStyles(props);
+ const errorApi = useApi(errorApiRef);
+ const inputRef = useRef(null);
+ const [open, setOpen] = useState(false);
+
+ const handleCopyClick: MouseEventHandler = e => {
+ e.stopPropagation();
+ setOpen(true);
+
+ try {
+ if (inputRef.current) {
+ inputRef.current.select();
+ document.execCommand('copy');
+ }
+ } catch (error) {
+ errorApi.post(error);
+ }
+ };
+
+ return (
+ <>
+
+ setOpen(false)}
+ open={open}
+ >
+
+
+
+
+ >
+ );
+};
+
+// Type check for the JS files using this core component
+CopyTextButton.propTypes = {
+ text: PropTypes.string.isRequired,
+ tooltipDelay: PropTypes.number,
+ tooltipText: PropTypes.string,
+};
diff --git a/packages/components/src/components/CopyTextButton/index.tsx b/packages/components/src/components/CopyTextButton/index.tsx
new file mode 100644
index 0000000000..adde10a927
--- /dev/null
+++ b/packages/components/src/components/CopyTextButton/index.tsx
@@ -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 { CopyTextButton } from './CopyTextButton';
diff --git a/packages/components/src/components/DependencyGraph/DefaultLabel.tsx b/packages/components/src/components/DependencyGraph/DefaultLabel.tsx
new file mode 100644
index 0000000000..0679d1dae0
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/DefaultLabel.tsx
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import makeStyles from '@material-ui/core/styles/makeStyles';
+import { BackstageTheme } from '@backstage/theme';
+import { RenderLabelProps } from './types';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ text: {
+ fill: theme.palette.textContrast,
+ },
+}));
+
+export function DefaultLabel({ edge: { label } }: RenderLabelProps) {
+ const classes = useStyles();
+ return (
+
+ {label}
+
+ );
+}
diff --git a/packages/components/src/components/DependencyGraph/DefaultNode.tsx b/packages/components/src/components/DependencyGraph/DefaultNode.tsx
new file mode 100644
index 0000000000..9656c860ae
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/DefaultNode.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { makeStyles } from '@material-ui/core/styles';
+import { BackstageTheme } from '@backstage/theme';
+import { RenderNodeProps } from './types';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ node: {
+ fill: theme.palette.background.paper,
+ stroke: theme.palette.border,
+ },
+ text: {
+ fill: theme.palette.textContrast,
+ },
+}));
+
+export function DefaultNode({ node: { id } }: RenderNodeProps) {
+ const classes = useStyles();
+ const [width, setWidth] = React.useState(0);
+ const [height, setHeight] = React.useState(0);
+ const idRef = React.useRef(null);
+
+ React.useLayoutEffect(() => {
+ // set the width to the length of the ID
+ if (idRef.current) {
+ let {
+ height: renderedHeight,
+ width: renderedWidth,
+ } = idRef.current.getBBox();
+ renderedHeight = Math.round(renderedHeight);
+ renderedWidth = Math.round(renderedWidth);
+
+ if (renderedHeight !== height || renderedWidth !== width) {
+ setWidth(renderedWidth);
+ setHeight(renderedHeight);
+ }
+ }
+ }, [width, height]);
+
+ const padding = 10;
+ const paddedWidth = width + padding * 2;
+ const paddedHeight = height + padding * 2;
+
+ return (
+
+
+
+ {id}
+
+
+ );
+}
diff --git a/packages/components/src/components/DependencyGraph/DependencyGraph.stories.tsx b/packages/components/src/components/DependencyGraph/DependencyGraph.stories.tsx
new file mode 100644
index 0000000000..e39ecfc5da
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/DependencyGraph.stories.tsx
@@ -0,0 +1,178 @@
+/*
+ * 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 { DependencyGraph } from './DependencyGraph';
+import { Direction, LabelPosition } from './types';
+
+export default {
+ title: 'Data Display/DependencyGraph',
+ component: DependencyGraph,
+};
+
+const containerStyle = { width: '100%' };
+const graphStyle = { border: '1px solid grey' };
+
+const exampleNodes = [
+ { id: 'source' },
+ { id: 'downstream' },
+ { id: 'second-downstream' },
+ { id: 'third-downstream' },
+];
+
+const exampleEdges = [
+ { from: 'source', to: 'downstream' },
+ { from: 'downstream', to: 'second-downstream' },
+ { from: 'downstream', to: 'third-downstream' },
+];
+
+export const Default = () => (
+
+
+
+);
+
+export const BottomToTop = () => (
+
+
+
+);
+
+export const LeftToRight = () => (
+
+
+
+);
+
+export const RightToLeft = () => (
+
+
+
+);
+
+export const WithLabels = () => {
+ const edges = exampleEdges.map(edge => ({ ...edge, label: 'label' }));
+ return (
+
+
+
+ );
+};
+
+export const CustomNodes = () => {
+ const colors = ['pink', 'coral', 'yellowgreen', 'aquamarine'];
+ const nodes = exampleNodes.map((node, index) => ({
+ ...node,
+ description: 'Description text',
+ color: colors[index],
+ }));
+ return (
+
+ (
+
+
+
+ {props.node.id}
+
+
+ {props.node.description}
+
+
+ )}
+ />
+
+ );
+};
+
+export const CustomLabels = () => {
+ const colors = ['pink', 'coral', 'aqua'];
+ const edges = exampleEdges.map((edge, index) => ({
+ ...edge,
+ label: colors[index],
+ color: colors[index],
+ }));
+ return (
+
+ (
+
+
+
+ {props.edge.label}
+
+
+ )}
+ />
+
+ );
+};
diff --git a/packages/components/src/components/DependencyGraph/DependencyGraph.test.tsx b/packages/components/src/components/DependencyGraph/DependencyGraph.test.tsx
new file mode 100644
index 0000000000..526dc6d7dd
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/DependencyGraph.test.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { DependencyGraph } from './DependencyGraph';
+import { RenderLabelProps, RenderNodeProps } from './types';
+import { EDGE_TEST_ID, LABEL_TEST_ID, NODE_TEST_ID } from './constants';
+
+describe(' ', () => {
+ beforeAll(() => {
+ Object.defineProperty(window.SVGElement.prototype, 'getBBox', {
+ value: () => ({ width: 100, height: 100 }),
+ configurable: true,
+ });
+ });
+
+ const nodes = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
+ const edges = [
+ { from: nodes[0].id, to: nodes[1].id },
+ { from: nodes[1].id, to: nodes[2].id },
+ ];
+
+ const CUSTOM_TEST_ID = 'custom-test-id';
+
+ it('renders each node and edge supplied', async () => {
+ const { getByText, queryAllByTestId, findAllByTestId } = render(
+ ,
+ );
+ const renderedNodes = await findAllByTestId(NODE_TEST_ID);
+ expect(renderedNodes).toHaveLength(3);
+ expect(getByText(nodes[0].id)).toBeInTheDocument();
+ expect(getByText(nodes[1].id)).toBeInTheDocument();
+ expect(getByText(nodes[2].id)).toBeInTheDocument();
+ expect(queryAllByTestId(EDGE_TEST_ID)).toHaveLength(2);
+ expect(queryAllByTestId(LABEL_TEST_ID)).toHaveLength(0);
+ });
+
+ it('renders edge labels if present', async () => {
+ const labeledEdges = [
+ { ...edges[0], label: 'first' },
+ { ...edges[1], label: 'second' },
+ ];
+ const { getByText, getAllByTestId, findAllByTestId } = render(
+ ,
+ );
+ const renderedEdges = await findAllByTestId(EDGE_TEST_ID);
+ expect(renderedEdges).toHaveLength(2);
+ expect(getAllByTestId(LABEL_TEST_ID)).toHaveLength(2);
+ expect(getByText(labeledEdges[0].label)).toBeInTheDocument();
+ expect(getByText(labeledEdges[1].label)).toBeInTheDocument();
+ });
+
+ it('renders nodes according to renderNode prop', async () => {
+ const singleNode = [nodes[0]];
+
+ const renderNode = (props: RenderNodeProps) => (
+
+ {props.node.id}
+
+
+ );
+ const { getByText, findByTestId, container } = render(
+ ,
+ );
+ const node = await findByTestId(CUSTOM_TEST_ID);
+ expect(node).toBeInTheDocument();
+ expect(container.querySelector('circle')).toBeInTheDocument();
+ expect(getByText(singleNode[0].id)).toBeInTheDocument();
+ });
+
+ it('renders labels according to renderLabel prop', async () => {
+ const labeledEdge = [{ ...edges[0], label: 'label' }];
+
+ const renderLabel = (props: RenderLabelProps) => (
+
+ {props.edge.label}
+
+
+ );
+ const { getByText, findByTestId, container } = render(
+ ,
+ );
+ const node = await findByTestId(CUSTOM_TEST_ID);
+ expect(node).toBeInTheDocument();
+ expect(container.querySelector('circle')).toBeInTheDocument();
+ expect(getByText(labeledEdge[0].label)).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/components/src/components/DependencyGraph/DependencyGraph.tsx
new file mode 100644
index 0000000000..ced0bdf276
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/DependencyGraph.tsx
@@ -0,0 +1,324 @@
+/*
+ * 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 * as d3Zoom from 'd3-zoom';
+import * as d3Selection from 'd3-selection';
+import useTheme from '@material-ui/core/styles/useTheme';
+import dagre from 'dagre';
+import debounce from 'lodash/debounce';
+import { BackstageTheme } from '@backstage/theme';
+import {
+ DependencyEdge,
+ DependencyNode,
+ Direction,
+ Alignment,
+ Ranker,
+ RenderNodeFunction,
+ RenderLabelFunction,
+ GraphEdge,
+ GraphNode,
+ LabelPosition,
+} from './types';
+import { Node } from './Node';
+import { Edge } from './Edge';
+import { ARROW_MARKER_ID } from './constants';
+
+export type DependencyGraphProps = React.SVGProps & {
+ edges: DependencyEdge[];
+ nodes: DependencyNode[];
+ direction?: Direction;
+ align?: Alignment;
+ nodeMargin?: number;
+ edgeMargin?: number;
+ rankMargin?: number;
+ paddingX?: number;
+ paddingY?: number;
+ acyclicer?: 'greedy';
+ ranker?: Ranker;
+ labelPosition?: LabelPosition;
+ labelOffset?: number;
+ edgeRanks?: number;
+ edgeWeight?: number;
+ renderNode?: RenderNodeFunction;
+ renderLabel?: RenderLabelFunction;
+ defs?: SVGDefsElement | SVGDefsElement[];
+};
+
+const WORKSPACE_ID = 'workspace';
+
+export function DependencyGraph({
+ edges,
+ nodes,
+ renderNode,
+ direction = Direction.TOP_BOTTOM,
+ align,
+ nodeMargin = 50,
+ edgeMargin = 10,
+ rankMargin = 50,
+ paddingX = 0,
+ paddingY = 0,
+ acyclicer,
+ ranker = Ranker.NETWORK_SIMPLEX,
+ labelPosition = LabelPosition.RIGHT,
+ labelOffset = 10,
+ edgeRanks = 1,
+ edgeWeight = 1,
+ renderLabel,
+ defs,
+ ...svgProps
+}: DependencyGraphProps) {
+ const theme: BackstageTheme = useTheme();
+ const [containerWidth, setContainerWidth] = React.useState(100);
+ const [containerHeight, setContainerHeight] = React.useState(100);
+
+ const graph = React.useRef>(
+ new dagre.graphlib.Graph(),
+ );
+ const [graphWidth, setGraphWidth] = React.useState(
+ graph.current.graph()?.width || 0,
+ );
+ const [graphHeight, setGraphHeight] = React.useState(
+ graph.current.graph()?.height || 0,
+ );
+ const [graphNodes, setGraphNodes] = React.useState([]);
+ const [graphEdges, setGraphEdges] = React.useState([]);
+
+ const maxWidth = Math.max(graphWidth, containerWidth);
+ const maxHeight = Math.max(graphHeight, containerHeight);
+
+ const containerRef = React.useMemo(
+ () =>
+ debounce((node: SVGSVGElement) => {
+ if (!node) {
+ return;
+ }
+ // Set up zooming + panning
+ const container = d3Selection.select(node);
+ const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID));
+ const zoom = d3Zoom
+ .zoom()
+ .scaleExtent([1, 10])
+ .on('zoom', event => {
+ event.transform.x = Math.min(
+ 0,
+ Math.max(
+ event.transform.x,
+ maxWidth - maxWidth * event.transform.k,
+ ),
+ );
+ event.transform.y = Math.min(
+ 0,
+ Math.max(
+ event.transform.y,
+ maxHeight - maxHeight * event.transform.k,
+ ),
+ );
+ workspace.attr('transform', event.transform);
+ });
+
+ container.call(zoom);
+
+ const {
+ width: newContainerWidth,
+ height: newContainerHeight,
+ } = node.getBoundingClientRect();
+ if (containerWidth !== newContainerWidth) {
+ setContainerWidth(newContainerWidth);
+ }
+ if (containerHeight !== newContainerHeight) {
+ setContainerHeight(newContainerHeight);
+ }
+ }, 100),
+ [containerHeight, containerWidth, maxWidth, maxHeight],
+ );
+
+ const setNodesAndEdges = React.useCallback(() => {
+ // Cleaning up lingering nodes and edges
+ const currentGraphNodes = graph.current.nodes();
+ const currentGraphEdges = graph.current.edges();
+
+ currentGraphNodes.forEach(nodeId => {
+ const remainingNode = nodes.some(node => node.id === nodeId);
+ if (!remainingNode) {
+ graph.current.removeNode(nodeId);
+ }
+ });
+
+ currentGraphEdges.forEach(e => {
+ const remainingEdge = edges.some(
+ edge => edge.from === e.v && edge.to === e.w,
+ );
+ if (!remainingEdge) {
+ graph.current.removeEdge(e.v, e.w);
+ }
+ });
+
+ // Adding/updating nodes and edges
+ nodes.forEach(node => {
+ const existingNode = graph.current
+ .nodes()
+ .find(nodeId => node.id === nodeId);
+
+ if (existingNode) {
+ const { width, height, x, y } = graph.current.node(existingNode);
+ graph.current.setNode(existingNode, { ...node, width, height, x, y });
+ } else {
+ graph.current.setNode(node.id, { ...node, width: 0, height: 0 });
+ }
+ });
+
+ edges.forEach(e => {
+ graph.current.setEdge(e.from, e.to, {
+ ...e,
+ label: e.label,
+ width: 0,
+ height: 0,
+ labelpos: labelPosition,
+ labeloffset: labelOffset,
+ weight: edgeWeight,
+ minlen: edgeRanks,
+ });
+ });
+ }, [edges, nodes, labelPosition, labelOffset, edgeWeight, edgeRanks]);
+
+ const updateGraph = React.useMemo(
+ () =>
+ debounce(
+ () => {
+ dagre.layout(graph.current);
+ const { height, width } = graph.current.graph();
+ const newHeight = Math.max(0, height || 0);
+ const newWidth = Math.max(0, width || 0);
+ setGraphWidth(newWidth);
+ setGraphHeight(newHeight);
+
+ setGraphNodes(graph.current.nodes());
+ setGraphEdges(graph.current.edges());
+ },
+ 250,
+ { leading: true },
+ ),
+ [],
+ );
+
+ React.useEffect(() => {
+ graph.current.setGraph({
+ rankdir: direction,
+ align,
+ nodesep: nodeMargin,
+ edgesep: edgeMargin,
+ ranksep: rankMargin,
+ marginx: paddingX,
+ marginy: paddingY,
+ acyclicer,
+ ranker,
+ });
+
+ setNodesAndEdges();
+ updateGraph();
+
+ return updateGraph.cancel;
+ }, [
+ acyclicer,
+ align,
+ direction,
+ edgeMargin,
+ paddingX,
+ paddingY,
+ nodeMargin,
+ rankMargin,
+ ranker,
+ setNodesAndEdges,
+ updateGraph,
+ ]);
+
+ function setNode(id: string, node: DependencyNode) {
+ graph.current.setNode(id, node);
+ updateGraph();
+ return graph.current;
+ }
+
+ function setEdge(id: dagre.Edge, edge: DependencyEdge) {
+ graph.current.setEdge(id, edge);
+ updateGraph();
+ return graph.current;
+ }
+
+ return (
+
+
+
+
+
+ {defs}
+
+
+
+ {graphEdges.map(e => {
+ const edge = graph.current.edge(e) as GraphEdge;
+ if (!edge) return null;
+ return (
+
+ );
+ })}
+ {graphNodes.map((id: string) => {
+ const node = graph.current.node(id) as GraphNode;
+ if (!node) return null;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/packages/components/src/components/DependencyGraph/Edge.test.tsx b/packages/components/src/components/DependencyGraph/Edge.test.tsx
new file mode 100644
index 0000000000..b651c17bf0
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/Edge.test.tsx
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { Edge } from './Edge';
+import { RenderLabelProps } from './types';
+
+const fromNode = 'node';
+const toNode = 'other-node';
+
+const edge = {
+ from: fromNode,
+ to: toNode,
+};
+
+const id = {
+ v: fromNode,
+ w: toNode,
+};
+
+const setEdge = jest.fn();
+const renderElement = jest.fn((props: RenderLabelProps) => (
+ {props.edge.label}
+));
+
+const minProps = {
+ points: [
+ { x: 10, y: 20 },
+ { x: 20, y: 20 },
+ ],
+ id,
+ setEdge,
+ renderElement,
+ edge,
+};
+
+const label = 'label';
+const edgeWithLabel = { ...edge, label };
+
+describe(' ', () => {
+ beforeEach(() => {
+ // jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement
+ Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', {
+ value: () => ({ width: 100, height: 100 }),
+ configurable: true,
+ });
+ });
+
+ afterEach(jest.clearAllMocks);
+
+ it('does not render the supplied label element if label is missing', () => {
+ const { container } = render( );
+ expect(container.getElementsByTagName('g')).toHaveLength(0);
+ });
+
+ it('renders the supplied label element if label is present', () => {
+ const { getByText } = render( );
+ expect(getByText(label)).toBeInTheDocument();
+ });
+
+ it('passes down edge properties to the render method if label is present', () => {
+ const edgeWithRandomProp = { ...edge, label, randomProp: true };
+ render(
+ ,
+ );
+
+ expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp });
+ });
+
+ it('calls setEdge with edge ID and actual label size after rendering', () => {
+ const { getByText } = render( );
+ expect(getByText(label)).toBeInTheDocument();
+
+ // Updates the edge in the graph
+ expect(setEdge).toHaveBeenCalledWith(id, {
+ height: 100,
+ width: 100,
+ ...edgeWithLabel,
+ });
+
+ // Does not pass down width/height to label
+ expect(renderElement).not.toHaveBeenCalledWith(
+ expect.objectContaining({ height: 100, width: 100 }),
+ );
+ });
+});
diff --git a/packages/components/src/components/DependencyGraph/Edge.tsx b/packages/components/src/components/DependencyGraph/Edge.tsx
new file mode 100644
index 0000000000..5e67aca63f
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/Edge.tsx
@@ -0,0 +1,122 @@
+/*
+ * 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 * as d3Shape from 'd3-shape';
+import isFinite from 'lodash/isFinite';
+import makeStyles from '@material-ui/core/styles/makeStyles';
+import { BackstageTheme } from '@backstage/theme';
+import {
+ GraphEdge,
+ RenderLabelProps,
+ RenderLabelFunction,
+ DependencyEdge,
+} from './types';
+import { ARROW_MARKER_ID, EDGE_TEST_ID, LABEL_TEST_ID } from './constants';
+import { DefaultLabel } from './DefaultLabel';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ path: {
+ strokeWidth: 2,
+ stroke: theme.palette.textSubtle,
+ fill: 'none',
+ transition: `${theme.transitions.duration.shortest}ms`,
+ },
+ label: {
+ transition: `${theme.transitions.duration.shortest}ms`,
+ },
+}));
+
+type EdgePoint = dagre.GraphEdge['points'][0];
+
+export type EdgeComponentProps = {
+ id: dagre.Edge;
+ edge: GraphEdge;
+ render?: RenderLabelFunction;
+ setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>;
+};
+
+const renderDefault = (props: RenderLabelProps) => ;
+
+const createPath = d3Shape
+ .line()
+ .x(d => d.x)
+ .y(d => d.y)
+ .curve(d3Shape.curveMonotoneX);
+
+export function Edge({
+ render = renderDefault,
+ setEdge,
+ id,
+ edge,
+}: EdgeComponentProps) {
+ const { x = 0, y = 0, width, height, points, ...labelProps } = edge;
+ const classes = useStyles();
+
+ const labelRef = React.useRef(null);
+
+ React.useLayoutEffect(() => {
+ // set the label width to the actual rendered width to properly layout graph
+ if (labelRef.current) {
+ let {
+ height: renderedHeight,
+ width: renderedWidth,
+ } = labelRef.current.getBBox();
+ renderedHeight = Math.round(renderedHeight);
+ renderedWidth = Math.round(renderedWidth);
+
+ if (renderedHeight !== height || renderedWidth !== width) {
+ setEdge(id, {
+ ...edge,
+ height: renderedHeight,
+ width: renderedWidth,
+ });
+ }
+ }
+ }, [edge, height, width, setEdge, id]);
+
+ let path: string = '';
+
+ if (points) {
+ const finitePoints = points.filter(
+ (point: EdgePoint) => isFinite(point.x) && isFinite(point.y),
+ );
+ path = createPath(finitePoints) || '';
+ }
+
+ return (
+ <>
+ {path && (
+
+ )}
+ {labelProps.label ? (
+
+ {render({ edge: labelProps })}
+
+ ) : null}
+ >
+ );
+}
diff --git a/packages/components/src/components/DependencyGraph/Node.test.tsx b/packages/components/src/components/DependencyGraph/Node.test.tsx
new file mode 100644
index 0000000000..9f9f7c693a
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/Node.test.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import dagre from 'dagre';
+import { render } from '@testing-library/react';
+import { Node } from './Node';
+import { RenderNodeProps } from './types';
+
+const node = { id: 'abc' };
+const setNode = jest.fn(() => new dagre.graphlib.Graph());
+const renderElement = jest.fn((props: RenderNodeProps) => (
+ {props.node.id}
+));
+
+const minProps = {
+ id: node.id,
+ node,
+ setNode,
+ render: renderElement,
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+};
+
+describe(' ', () => {
+ beforeEach(() => {
+ // jsdom does not support SVG elements so we have to fall back to HTMLUnknownElement
+ Object.defineProperty(window.HTMLUnknownElement.prototype, 'getBBox', {
+ value: () => ({ width: 100, height: 100 }),
+ configurable: true,
+ });
+ });
+
+ afterEach(jest.clearAllMocks);
+
+ it('renders the supplied element', () => {
+ const { getByText } = render( );
+ expect(getByText(minProps.id)).toBeInTheDocument();
+ });
+
+ it('passes down node properties to the render method', () => {
+ const nodeWithRandomProp = { ...node, randomProp: true };
+ render( );
+
+ expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp });
+ });
+
+ it('calls setNode with node ID and actual size after rendering', () => {
+ const { getByText } = render( );
+ expect(getByText(minProps.id)).toBeInTheDocument();
+
+ // Updates the node in the graph
+ expect(setNode).toHaveBeenCalledWith(node.id, {
+ height: 100,
+ width: 100,
+ ...node,
+ });
+
+ // Does not pass down width/height to node
+ expect(renderElement).not.toHaveBeenCalledWith(
+ expect.objectContaining({ height: 100, width: 100 }),
+ );
+ });
+});
diff --git a/packages/components/src/components/DependencyGraph/Node.tsx b/packages/components/src/components/DependencyGraph/Node.tsx
new file mode 100644
index 0000000000..4d64e31335
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/Node.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 makeStyles from '@material-ui/core/styles/makeStyles';
+import { DefaultNode } from './DefaultNode';
+import { RenderNodeFunction, RenderNodeProps, GraphNode } from './types';
+import { NODE_TEST_ID } from './constants';
+
+const useStyles = makeStyles(theme => ({
+ node: {
+ transition: `${theme.transitions.duration.shortest}ms`,
+ },
+}));
+
+export type NodeComponentProps = {
+ node: GraphNode;
+ render?: RenderNodeFunction;
+ setNode: dagre.graphlib.Graph['setNode'];
+};
+
+const renderDefault = (props: RenderNodeProps) => ;
+
+export function Node({
+ render = renderDefault,
+ setNode,
+ node,
+}: NodeComponentProps) {
+ const { width, height, x = 0, y = 0, ...nodeProps } = node;
+ const classes = useStyles();
+ const nodeRef = React.useRef(null);
+
+ React.useLayoutEffect(() => {
+ // set the node width to the actual rendered width to properly layout graph
+ if (nodeRef.current) {
+ let {
+ height: renderedHeight,
+ width: renderedWidth,
+ } = nodeRef.current.getBBox();
+ renderedHeight = Math.round(renderedHeight);
+ renderedWidth = Math.round(renderedWidth);
+
+ if (renderedHeight !== height || renderedWidth !== width) {
+ setNode(node.id, {
+ ...node,
+ height: renderedHeight,
+ width: renderedWidth,
+ });
+ }
+ }
+ }, [node, width, height, setNode]);
+
+ return (
+
+ {render({ node: nodeProps })}
+
+ );
+}
diff --git a/packages/components/src/components/DependencyGraph/constants.ts b/packages/components/src/components/DependencyGraph/constants.ts
new file mode 100644
index 0000000000..412a677f87
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/constants.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 const ARROW_MARKER_ID = 'arrow-marker';
+
+export const NODE_TEST_ID = 'node';
+export const EDGE_TEST_ID = 'edge';
+export const LABEL_TEST_ID = 'label';
diff --git a/packages/components/src/components/DependencyGraph/index.ts b/packages/components/src/components/DependencyGraph/index.ts
new file mode 100644
index 0000000000..9a4f0071e3
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/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.
+ */
+
+import * as DependencyGraphTypes from './types';
+
+export { DependencyGraph } from './DependencyGraph';
+export { DependencyGraphTypes };
diff --git a/packages/components/src/components/DependencyGraph/types.ts b/packages/components/src/components/DependencyGraph/types.ts
new file mode 100644
index 0000000000..19173db4b3
--- /dev/null
+++ b/packages/components/src/components/DependencyGraph/types.ts
@@ -0,0 +1,88 @@
+/*
+ * 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 dagre from 'dagre';
+
+type CustomType = { [customKey: string]: any };
+
+/* Edges */
+export type DependencyEdge = T & {
+ from: string;
+ to: string;
+ label?: string;
+};
+
+export type GraphEdge = DependencyEdge &
+ dagre.GraphEdge &
+ EdgeProperties;
+
+export type RenderLabelProps = { edge: DependencyEdge };
+
+export type RenderLabelFunction = (
+ props: RenderLabelProps,
+) => React.ReactNode;
+
+/* Nodes */
+export type DependencyNode = T & {
+ id: string;
+};
+
+export type GraphNode = dagre.Node>;
+
+export type RenderNodeProps = { node: DependencyNode };
+
+export type RenderNodeFunction = (
+ props: RenderNodeProps,
+) => React.ReactNode;
+
+/* Based on: https://github.com/dagrejs/dagre/wiki#configuring-the-layout */
+
+export type EdgeProperties = {
+ label?: string;
+ width?: number;
+ height?: number;
+ labeloffset?: number;
+ labelpos?: LabelPosition;
+ minlen?: number;
+ weight?: number;
+ [customKey: string]: any;
+};
+
+export enum Direction {
+ TOP_BOTTOM = 'TB',
+ BOTTOM_TOP = 'BT',
+ LEFT_RIGHT = 'LR',
+ RIGHT_LEFT = 'RL',
+}
+
+export enum Alignment {
+ UP_LEFT = 'UL',
+ UP_RIGHT = 'UR',
+ DOWN_LEFT = 'DL',
+ DOWN_RIGHT = 'DR',
+}
+
+export enum Ranker {
+ NETWORK_SIMPLEX = 'network-simplex',
+ TIGHT_TREE = 'tight-tree',
+ LONGEST_PATH = 'longest-path',
+}
+
+export enum LabelPosition {
+ LEFT = 'l',
+ RIGHT = 'r',
+ CENTER = 'c',
+}
diff --git a/packages/components/src/components/DismissableBanner/DismissableBanner.stories.tsx b/packages/components/src/components/DismissableBanner/DismissableBanner.stories.tsx
new file mode 100644
index 0000000000..d77a713b34
--- /dev/null
+++ b/packages/components/src/components/DismissableBanner/DismissableBanner.stories.tsx
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { DismissableBanner } from './DismissableBanner';
+import { Link, Typography } from '@material-ui/core';
+import {
+ ApiProvider,
+ ApiRegistry,
+ CreateStorageApiOptions,
+ ErrorApi,
+ storageApiRef,
+ StorageApi,
+ WebStorage,
+} from '@backstage/core-api';
+
+export default {
+ title: 'Feedback/DismissableBanner',
+ component: DismissableBanner,
+};
+
+let errorApi: ErrorApi;
+const containerStyle = { width: '70%' };
+
+const createWebStorage = (
+ args?: Partial,
+): StorageApi => {
+ return WebStorage.create({
+ errorApi: errorApi,
+ ...args,
+ });
+};
+
+const apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]);
+
+export const Default = () => (
+
+);
+
+export const Error = () => (
+
+);
+
+export const EmojisIncluded = () => (
+
+);
+
+export const WithLink = () => (
+
+
+
+ This is a dismissable banner with a link:{' '}
+
+ example.com
+
+
+ }
+ variant="info"
+ id="linked_dismissable"
+ />
+
+
+);
+export const Fixed = () => (
+
+);
diff --git a/packages/components/src/components/DismissableBanner/DismissableBanner.test.tsx b/packages/components/src/components/DismissableBanner/DismissableBanner.test.tsx
new file mode 100644
index 0000000000..b1fd32bc0a
--- /dev/null
+++ b/packages/components/src/components/DismissableBanner/DismissableBanner.test.tsx
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { DismissableBanner } from './DismissableBanner';
+import {
+ ApiRegistry,
+ ApiProvider,
+ storageApiRef,
+ CreateStorageApiOptions,
+ StorageApi,
+ WebStorage,
+} from '@backstage/core-api';
+
+describe(' ', () => {
+ let apis: ApiRegistry;
+ const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
+ const createWebStorage = (
+ args?: Partial,
+ ): StorageApi => {
+ return WebStorage.create({
+ errorApi: mockErrorApi,
+ ...args,
+ });
+ };
+
+ beforeEach(() => {
+ apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]);
+ });
+
+ it('renders the message and the popover', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+ const element = await rendered.findByText('test message');
+ expect(element).toBeInTheDocument();
+ });
+
+ it('gets placed in local storage on dismiss', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+
+
+ ,
+ ),
+ );
+ const webstore = apis.get(storageApiRef);
+ const notifications = webstore?.forBucket('notifications');
+ const button = await rendered.findByTitle(
+ 'Permanently dismiss this message',
+ );
+ fireEvent.click(button);
+ const dismissedBanners =
+ notifications?.get('dismissedBanners') ?? [];
+ expect(
+ dismissedBanners.includes('catalog_page_welcome_banner'),
+ ).toBeTruthy();
+ });
+});
diff --git a/packages/components/src/components/DismissableBanner/DismissableBanner.tsx b/packages/components/src/components/DismissableBanner/DismissableBanner.tsx
new file mode 100644
index 0000000000..568ab6306b
--- /dev/null
+++ b/packages/components/src/components/DismissableBanner/DismissableBanner.tsx
@@ -0,0 +1,135 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ReactNode, useState, useEffect } from 'react';
+import { useApi, storageApiRef } from '@backstage/core-api';
+import { useObservable } from 'react-use';
+import classNames from 'classnames';
+import { makeStyles } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+import Snackbar from '@material-ui/core/Snackbar';
+import SnackbarContent from '@material-ui/core/SnackbarContent';
+import IconButton from '@material-ui/core/IconButton';
+import Close from '@material-ui/icons/Close';
+
+const useStyles = makeStyles((theme: BackstageTheme) => ({
+ root: {
+ padding: theme.spacing(0),
+ marginBottom: theme.spacing(0),
+ marginTop: theme.spacing(0),
+ display: 'flex',
+ flexFlow: 'row nowrap',
+ },
+ // showing on top
+ topPosition: {
+ position: 'relative',
+ marginBottom: theme.spacing(6),
+ marginTop: -theme.spacing(3),
+ zIndex: 'unset',
+ },
+ icon: {
+ fontSize: 20,
+ },
+ content: {
+ width: '100%',
+ maxWidth: 'inherit',
+ },
+ message: {
+ display: 'flex',
+ alignItems: 'center',
+ color: theme.palette.banner.text,
+ '& a': {
+ color: theme.palette.banner.link,
+ },
+ },
+ info: {
+ backgroundColor: theme.palette.banner.info,
+ },
+ error: {
+ backgroundColor: theme.palette.banner.error,
+ },
+}));
+
+type Props = {
+ variant: 'info' | 'error';
+ message: ReactNode;
+ id: string;
+ fixed?: boolean;
+};
+
+export const DismissableBanner = ({
+ variant,
+ message,
+ id,
+ fixed = false,
+}: Props) => {
+ const classes = useStyles();
+ const storageApi = useApi(storageApiRef);
+ const notificationsStore = storageApi.forBucket('notifications');
+ const rawDismissedBanners =
+ notificationsStore.get('dismissedBanners') ?? [];
+
+ const [dismissedBanners, setDismissedBanners] = useState(
+ new Set(rawDismissedBanners),
+ );
+
+ const observedItems = useObservable(
+ notificationsStore.observe$('dismissedBanners'),
+ );
+
+ useEffect(() => {
+ if (observedItems?.newValue) {
+ const currentValue = observedItems?.newValue ?? [];
+ setDismissedBanners(new Set(currentValue));
+ }
+ }, [observedItems?.newValue]);
+
+ const handleClick = () => {
+ notificationsStore.set('dismissedBanners', [...dismissedBanners, id]);
+ };
+
+ return (
+
+
+
+ ,
+ ]}
+ />
+
+ );
+};
diff --git a/packages/components/src/components/DismissableBanner/index.ts b/packages/components/src/components/DismissableBanner/index.ts
new file mode 100644
index 0000000000..c1d69cd95e
--- /dev/null
+++ b/packages/components/src/components/DismissableBanner/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { DismissableBanner } from './DismissableBanner';
diff --git a/packages/components/src/components/Drawer/Drawer.stories.tsx b/packages/components/src/components/Drawer/Drawer.stories.tsx
new file mode 100644
index 0000000000..b399cfed8e
--- /dev/null
+++ b/packages/components/src/components/Drawer/Drawer.stories.tsx
@@ -0,0 +1,171 @@
+/*
+ * 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 {
+ Drawer,
+ Button,
+ Typography,
+ makeStyles,
+ IconButton,
+ createStyles,
+ Theme,
+} from '@material-ui/core';
+import Close from '@material-ui/icons/Close';
+
+export default {
+ title: 'Layout/Drawer',
+ component: Drawer,
+};
+
+const useDrawerStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ paper: {
+ width: '50%',
+ justifyContent: 'space-between',
+ padding: theme.spacing(2.5),
+ },
+ }),
+);
+
+const useDrawerContentStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ header: {
+ display: 'flex',
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ icon: {
+ fontSize: 20,
+ },
+ content: {
+ height: '80%',
+ backgroundColor: '#EEEEEE',
+ },
+ secondaryAction: {
+ marginLeft: theme.spacing(2.5),
+ },
+ }),
+);
+
+/* Example content wrapped inside the Drawer component */
+const DrawerContent = ({
+ toggleDrawer,
+}: {
+ toggleDrawer: (isOpen: boolean) => void;
+}) => {
+ const classes = useDrawerContentStyles();
+
+ return (
+ <>
+
+ Side Panel Title
+ toggleDrawer(false)}
+ color="inherit"
+ >
+
+
+
+
+
+ toggleDrawer(false)}
+ >
+ Primary Action
+
+ toggleDrawer(false)}
+ >
+ Secondary Action
+
+
+ >
+ );
+};
+
+/* Default drawer can toggle open or closed.
+ * It can be cancelled by clicking the overlay
+ * or pressing the esc key.
+ */
+export const DefaultDrawer = () => {
+ const [isOpen, toggleDrawer] = useState(false);
+ const classes = useDrawerStyles();
+
+ return (
+ <>
+ toggleDrawer(true)}
+ >
+ Open Default Drawer
+
+ toggleDrawer(false)}
+ >
+
+
+ >
+ );
+};
+
+/* Persistent drawer works like the default one -
+ * except that the content sits on the same level
+ * as the main content and you can't cancel it by
+ * clicking the overlay or pressing the esc key.
+ *
+ * Set the Drawer variant props: 'persistent'
+ */
+export const PersistentDrawer = () => {
+ const [isOpen, toggleDrawer] = useState(false);
+ const classes = useDrawerStyles();
+
+ return (
+ <>
+ toggleDrawer(true)}
+ >
+ Open Persistent Drawer
+
+ toggleDrawer(false)}
+ >
+
+
+ >
+ );
+};
diff --git a/packages/components/src/components/EmptyState/EmptyState.stories.tsx b/packages/components/src/components/EmptyState/EmptyState.stories.tsx
new file mode 100644
index 0000000000..dcfba73227
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.stories.tsx
@@ -0,0 +1,78 @@
+/*
+ * 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 { EmptyState } from './EmptyState';
+import { Button } from '@material-ui/core';
+import { MissingAnnotationEmptyState } from './MissingAnnotationEmptyState';
+
+export default {
+ title: 'Feedback/EmptyState',
+ component: EmptyState,
+};
+
+const containerStyle = { width: '100%', height: '100vh' };
+
+export const MissingAnnotation = () => (
+
+
+
+);
+
+export const Info = () => (
+
+
+
+);
+
+export const Content = () => (
+
+
+
+);
+
+export const Data = () => (
+
+
+
+);
+
+export const WithAction = () => (
+
+ {}} variant="contained">
+ DOCS
+
+ }
+ />
+
+);
diff --git a/packages/components/src/components/EmptyState/EmptyState.test.tsx b/packages/components/src/components/EmptyState/EmptyState.test.tsx
new file mode 100644
index 0000000000..32e71f044d
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.test.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { EmptyState } from './EmptyState';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { Button } from '@material-ui/core';
+
+describe(' ', () => {
+ it('render EmptyState component with type annotaion is missing', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+ DOCS}
+ />,
+ ),
+ );
+ expect(
+ rendered.getByText('Your plugin is missing an annotation'),
+ ).toBeInTheDocument();
+ expect(rendered.getByLabelText('button')).toBeInTheDocument();
+ expect(rendered.getByAltText('annotation is missing')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/EmptyState/EmptyState.tsx b/packages/components/src/components/EmptyState/EmptyState.tsx
new file mode 100644
index 0000000000..fdd9738735
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyState.tsx
@@ -0,0 +1,68 @@
+/*
+ * 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 { makeStyles, Typography, Grid } from '@material-ui/core';
+import { EmptyStateImage } from './EmptyStateImage';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ backgroundColor: theme.palette.background.default,
+ padding: theme.spacing(2, 0, 0, 0),
+ },
+ action: {
+ marginTop: theme.spacing(2),
+ },
+ imageContainer: {
+ position: 'relative',
+ },
+}));
+
+type Props = {
+ title: string;
+ description?: string | JSX.Element;
+ missing: 'field' | 'info' | 'content' | 'data';
+ action?: JSX.Element;
+};
+
+export const EmptyState = ({ title, description, missing, action }: Props) => {
+ const classes = useStyles();
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+ {action}
+
+
+
+
+
+
+ );
+};
diff --git a/packages/components/src/components/EmptyState/EmptyStateImage.test.tsx b/packages/components/src/components/EmptyState/EmptyStateImage.test.tsx
new file mode 100644
index 0000000000..258eee943d
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyStateImage.test.tsx
@@ -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 React from 'react';
+import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { EmptyStateImage } from './EmptyStateImage';
+
+describe(' ', () => {
+ it('render EmptyStateImage component with missing field', async () => {
+ const { getByAltText } = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+ expect(getByAltText('annotation is missing')).toBeInTheDocument();
+ });
+
+ it('render EmptyStateImage component with missing info', async () => {
+ const { getByAltText } = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+ expect(getByAltText('no Information')).toBeInTheDocument();
+ });
+
+ it('render EmptyStateImage component with missing content', async () => {
+ const { getByAltText } = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+ expect(getByAltText('create Component')).toBeInTheDocument();
+ });
+
+ it('render EmptyStateImage component with missing data', async () => {
+ const { getByAltText } = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+ expect(getByAltText('no Build')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/EmptyState/EmptyStateImage.tsx b/packages/components/src/components/EmptyState/EmptyStateImage.tsx
new file mode 100644
index 0000000000..1973ff9a23
--- /dev/null
+++ b/packages/components/src/components/EmptyState/EmptyStateImage.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 React from 'react';
+import missingAnnotation from './assets/missingAnnotation.svg';
+import noInformation from './assets/noInformation.svg';
+import createComponent from './assets/createComponent.svg';
+import noBuild from './assets/noBuild.svg';
+import { makeStyles } from '@material-ui/core';
+
+type Props = {
+ missing: 'field' | 'info' | 'content' | 'data';
+};
+
+const useStyles = makeStyles({
+ generalImg: {
+ width: '95%',
+ zIndex: 2,
+ position: 'absolute',
+ left: '50%',
+ top: '50%',
+ transform: 'translate(-50%, 15%)',
+ },
+});
+
+export const EmptyStateImage = ({ missing }: Props) => {
+ const classes = useStyles();
+ switch (missing) {
+ case 'field':
+ return (
+
+ );
+ case 'info':
+ return (
+
+ );
+ case 'content':
+ return (
+
+ );
+ case 'data':
+ return (
+
+ );
+ default:
+ return null;
+ }
+};
diff --git a/packages/components/src/components/EmptyState/MissingAnnotationEmptyState.tsx b/packages/components/src/components/EmptyState/MissingAnnotationEmptyState.tsx
new file mode 100644
index 0000000000..377373a06d
--- /dev/null
+++ b/packages/components/src/components/EmptyState/MissingAnnotationEmptyState.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 { Button, makeStyles, Typography } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+import { EmptyState } from './EmptyState';
+import { CodeSnippet } from '../CodeSnippet';
+
+const COMPONENT_YAML = `apiVersion: backstage.io/v1alpha1
+kind: Component
+metadata:
+ name: example
+ description: example.com
+ annotations:
+ ANNOTATION: value
+spec:
+ type: website
+ lifecycle: production
+ owner: guest`;
+
+type Props = {
+ annotation: string;
+};
+
+const useStyles = makeStyles(theme => ({
+ code: {
+ borderRadius: 6,
+ margin: `${theme.spacing(2)}px 0px`,
+ background: theme.palette.type === 'dark' ? '#444' : '#fff',
+ },
+}));
+
+export const MissingAnnotationEmptyState = ({ annotation }: Props) => {
+ const classes = useStyles();
+ const description = (
+ <>
+ The {annotation} annotation is missing. You need to add the
+ annotation to your component if you want to enable this tool.
+ >
+ );
+ return (
+
+
+ Add the annotation to your component YAML as shown in the
+ highlighted example below:
+
+
+
+
+
+ Read more
+
+ >
+ }
+ />
+ );
+};
diff --git a/packages/components/src/components/EmptyState/assets/createComponent.svg b/packages/components/src/components/EmptyState/assets/createComponent.svg
new file mode 100644
index 0000000000..b81b9f39aa
--- /dev/null
+++ b/packages/components/src/components/EmptyState/assets/createComponent.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/components/src/components/EmptyState/assets/missingAnnotation.svg b/packages/components/src/components/EmptyState/assets/missingAnnotation.svg
new file mode 100644
index 0000000000..08f5a513eb
--- /dev/null
+++ b/packages/components/src/components/EmptyState/assets/missingAnnotation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/components/src/components/EmptyState/assets/noBuild.svg b/packages/components/src/components/EmptyState/assets/noBuild.svg
new file mode 100644
index 0000000000..832d98efa7
--- /dev/null
+++ b/packages/components/src/components/EmptyState/assets/noBuild.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/components/src/components/EmptyState/assets/noInformation.svg b/packages/components/src/components/EmptyState/assets/noInformation.svg
new file mode 100644
index 0000000000..9a1c230e97
--- /dev/null
+++ b/packages/components/src/components/EmptyState/assets/noInformation.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/components/src/components/EmptyState/index.ts b/packages/components/src/components/EmptyState/index.ts
new file mode 100644
index 0000000000..2e2a88be94
--- /dev/null
+++ b/packages/components/src/components/EmptyState/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 { EmptyState } from './EmptyState';
+export { MissingAnnotationEmptyState } from './MissingAnnotationEmptyState';
diff --git a/packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx b/packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx
new file mode 100644
index 0000000000..83a31f198d
--- /dev/null
+++ b/packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.test.tsx
@@ -0,0 +1,154 @@
+/*
+ * 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 { act, fireEvent } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { FeatureCalloutCircular } from './FeatureCalloutCircular';
+
+const INITIAL_BOUNDING_RECT: DOMRect = {
+ width: 100,
+ height: 100,
+ x: 0,
+ y: 0,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ toJSON: () => {},
+};
+
+const UPDATED_BOUNDING_RECT: DOMRect = {
+ width: 200,
+ height: 200,
+ x: 50,
+ y: 50,
+ bottom: 0,
+ left: 0,
+ right: 0,
+ top: 0,
+ toJSON: () => {},
+};
+
+beforeEach(() => {
+ Element.prototype.getBoundingClientRect = jest.fn(
+ () => INITIAL_BOUNDING_RECT,
+ );
+});
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getByText('description');
+ rendered.getByText('title');
+ });
+
+ it('renders with correct style', async () => {
+ const { getByTestId } = await renderInTestApp(
+ ,
+ );
+ const dot = await getByTestId('dot');
+ const text = await getByTestId('text');
+
+ expect(dot).toBeInTheDocument();
+ expect(text).toBeInTheDocument();
+
+ // Dot style
+ expect(dot.style.left).toBe('-800px');
+ expect(dot.style.top).toBe('-800px');
+ expect(dot.style.width).toBe('1700px');
+ expect(dot.style.height).toBe('1700px');
+
+ // Text style
+ expect(text.style.left).toBe('-400px');
+ expect(text.style.top).toBe('120px');
+ expect(text.style.width).toBe('450px');
+ });
+
+ it('update when the user scrolls', async () => {
+ const { getByTestId } = await renderInTestApp(
+ ,
+ );
+ const dot = await getByTestId('dot');
+ const text = await getByTestId('text');
+
+ act(() => {
+ Element.prototype.getBoundingClientRect = jest.fn(
+ () => UPDATED_BOUNDING_RECT,
+ );
+
+ // Trigger the window resize event.
+ fireEvent(window, new Event('resize'));
+ });
+
+ // Dot style
+ expect(dot.style.left).toBe('-750px');
+ expect(dot.style.top).toBe('-750px');
+ expect(dot.style.width).toBe('1800px');
+ expect(dot.style.height).toBe('1800px');
+
+ // Text style
+ expect(text.style.left).toBe('-300px');
+ expect(text.style.top).toBe('270px');
+ expect(text.style.width).toBe('450px');
+ });
+
+ it('update when the user resizes the window', async () => {
+ const { getByTestId } = await renderInTestApp(
+ ,
+ );
+ const dot = await getByTestId('dot');
+ const text = await getByTestId('text');
+
+ act(() => {
+ Element.prototype.getBoundingClientRect = jest.fn(
+ () => UPDATED_BOUNDING_RECT,
+ );
+
+ // Trigger the window scroll event.
+ fireEvent(window, new Event('scroll'));
+ });
+
+ // Dot style
+ expect(dot.style.left).toBe('-750px');
+ expect(dot.style.top).toBe('-750px');
+ expect(dot.style.width).toBe('1800px');
+ expect(dot.style.height).toBe('1800px');
+
+ // Text style
+ expect(text.style.left).toBe('-300px');
+ expect(text.style.top).toBe('270px');
+ expect(text.style.width).toBe('450px');
+ });
+});
diff --git a/packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx b/packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx
new file mode 100644
index 0000000000..64722c24b0
--- /dev/null
+++ b/packages/components/src/components/FeatureDiscovery/FeatureCalloutCircular.tsx
@@ -0,0 +1,199 @@
+/*
+ * 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 { ClickAwayListener, makeStyles, Typography } from '@material-ui/core';
+import React, {
+ PropsWithChildren,
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useRef,
+ useState,
+} from 'react';
+import { createPortal } from 'react-dom';
+import { usePortal } from './lib/usePortal';
+import { useShowCallout } from './lib/useShowCallout';
+
+const useStyles = makeStyles({
+ '@keyframes pulsateSlightly': {
+ '0%': { transform: 'scale(1.0)' },
+ '100%': { transform: 'scale(1.1)' },
+ },
+ '@keyframes pulsateAndFade': {
+ '0%': { transform: 'scale(1.0)', opacity: 0.9 },
+ '100%': { transform: 'scale(1.5)', opacity: 0 },
+ },
+ featureWrapper: {
+ position: 'relative',
+ },
+ backdrop: {
+ zIndex: 2000,
+ position: 'fixed',
+ overflow: 'hidden',
+ left: 0,
+ right: 0,
+ top: 0,
+ bottom: 0,
+ },
+ dot: {
+ position: 'absolute',
+ backgroundColor: 'transparent',
+ borderRadius: '100%',
+ border: '1px solid rgba(103, 146, 180, 0.98)',
+ boxShadow: '0px 0px 0px 20000px rgba(0, 0, 0, 0.5)',
+ zIndex: 2001,
+ transformOrigin: 'center center',
+ animation:
+ '$pulsateSlightly 1744ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) alternate infinite',
+ },
+ pulseCircle: {
+ width: '100%',
+ height: '100%',
+ backgroundColor: 'transparent',
+ borderRadius: '100%',
+ border: '2px solid white',
+ zIndex: 2001,
+ transformOrigin: 'center center',
+ animation:
+ '$pulsateAndFade 872ms 1.2s cubic-bezier(0.4, 0, 0.2, 1) infinite',
+ },
+ text: {
+ position: 'absolute',
+ color: 'white',
+ zIndex: 2003,
+ },
+});
+
+export type Props = {
+ featureId: string;
+ title: string;
+ description: string;
+};
+
+type Placement = {
+ dotLeft: number;
+ dotTop: number;
+ dotSize: number;
+ borderWidth: number;
+ textLeft: number;
+ textTop: number;
+ textWidth: number;
+};
+
+export const FeatureCalloutCircular = ({
+ featureId,
+ title,
+ description,
+ children,
+}: PropsWithChildren) => {
+ const { show, hide } = useShowCallout(featureId);
+ const portalElement = usePortal('core.callout');
+ const wrapperRef = useRef(null);
+ const [placement, setPlacement] = useState();
+ const classes = useStyles();
+
+ const update = useCallback(() => {
+ if (wrapperRef.current) {
+ const wrapperBounds = wrapperRef.current.getBoundingClientRect();
+ const longest = Math.max(wrapperBounds.width, wrapperBounds.height);
+
+ const borderWidth = 800;
+ const dotLeft =
+ wrapperBounds.x - (longest - wrapperBounds.width) / 2 - borderWidth;
+ const dotTop =
+ wrapperBounds.y - (longest - wrapperBounds.height) / 2 - borderWidth;
+ const dotSize = longest + 2 * borderWidth;
+
+ const textWidth = 450;
+ const textLeft = wrapperBounds.x + wrapperBounds.width / 2 - textWidth;
+ const textTop =
+ wrapperBounds.y - (longest - wrapperBounds.height) / 2 + longest + 20;
+
+ setPlacement({
+ dotLeft,
+ dotTop,
+ dotSize,
+ borderWidth,
+ textTop,
+ textLeft,
+ textWidth,
+ });
+ }
+ }, []);
+
+ useEffect(() => {
+ window.addEventListener('resize', update);
+ window.addEventListener('scroll', update);
+ return () => {
+ window.removeEventListener('resize', update);
+ window.removeEventListener('scroll', update);
+ };
+ }, [update]);
+
+ useLayoutEffect(update, [wrapperRef.current, update]);
+
+ if (!show) {
+ return <>{children}>;
+ }
+
+ return (
+ <>
+
+ {children}
+
+ {createPortal(
+
+
+ <>
+
+
+
+ {title}
+
+ {description}
+
+ >
+
+
,
+ portalElement,
+ )}
+ >
+ );
+};
diff --git a/packages/components/src/components/FeatureDiscovery/index.ts b/packages/components/src/components/FeatureDiscovery/index.ts
new file mode 100644
index 0000000000..57e35e3855
--- /dev/null
+++ b/packages/components/src/components/FeatureDiscovery/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 { FeatureCalloutCircular } from './FeatureCalloutCircular';
diff --git a/packages/components/src/components/FeatureDiscovery/lib/usePortal.ts b/packages/components/src/components/FeatureDiscovery/lib/usePortal.ts
new file mode 100644
index 0000000000..d5fd2c23c9
--- /dev/null
+++ b/packages/components/src/components/FeatureDiscovery/lib/usePortal.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 { useRef, useEffect } from 'react';
+
+/**
+ * Creates DOM element to be used as React root.
+ */
+function createRootElement(id: string): Element {
+ const rootContainer = document.createElement('div');
+ rootContainer.setAttribute('id', id);
+ return rootContainer;
+}
+
+/**
+ * Appends element as last child of body.
+ */
+function addRootElement(rootElem: Element): void {
+ document.body.insertBefore(
+ rootElem,
+ document.body.lastElementChild!.nextElementSibling,
+ );
+}
+
+/**
+ * Hook to create a React Portal.
+ *
+ * Automatically handles creating and tearing-down the root elements (no SRR
+ * makes this trivial), so there is no need to ensure the parent target already
+ * exists.
+ *
+ * @example
+ * const target = usePortal(id, [id]);
+ * return createPortal(children, target);
+ *
+ * @param id The id of the target container, e.g 'modal' or 'spotlight'
+ * @returns The DOM node to use as the Portal target.
+ */
+export function usePortal(id: string): HTMLElement {
+ const rootElemRef = useRef(null);
+
+ useEffect(
+ function setupElement() {
+ // Look for existing target dom element to append to
+ const existingParent = document.querySelector(`#${id}`);
+ // Parent is either a new root or the existing dom element
+ const parentElem = existingParent || createRootElement(id);
+
+ // If there is no existing DOM element, add a new one.
+ if (!existingParent) {
+ addRootElement(parentElem);
+ }
+
+ // Add the detached element to the parent
+ parentElem.appendChild(rootElemRef.current!);
+
+ return function removeElement() {
+ rootElemRef.current!.remove();
+ if (parentElem.childNodes.length === -1) {
+ parentElem.remove();
+ }
+ };
+ },
+ [id],
+ );
+
+ /**
+ * It's important we evaluate this lazily:
+ * - We need first render to contain the DOM element, so it shouldn't happen
+ * in useEffect. We would normally put this in the constructor().
+ * - We can't do 'const rootElemRef = useRef(document.createElement('div))',
+ * since this will run every single render (that's a lot).
+ * - We want the ref to consistently point to the same DOM element and only
+ * ever run once.
+ * @link https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily
+ */
+ function getRootElem() {
+ if (!rootElemRef.current) {
+ rootElemRef.current = document.createElement('div');
+ }
+ return rootElemRef.current;
+ }
+
+ return getRootElem();
+}
+
+export default usePortal;
diff --git a/packages/components/src/components/FeatureDiscovery/lib/useShowCallout.ts b/packages/components/src/components/FeatureDiscovery/lib/useShowCallout.ts
new file mode 100644
index 0000000000..0bbcf3b8ec
--- /dev/null
+++ b/packages/components/src/components/FeatureDiscovery/lib/useShowCallout.ts
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useCallback, useState } from 'react';
+
+const STATES_LOCAL_STORAGE_KEY = 'core.calloutSeen';
+
+function useCalloutStates(): {
+ states: Record;
+ setState: (key: string, value: boolean) => void;
+} {
+ const [states, setStates] = useState>(() => {
+ const raw = localStorage.getItem(STATES_LOCAL_STORAGE_KEY);
+ return raw ? JSON.parse(raw) : {};
+ });
+
+ const setState = useCallback((key: string, value: boolean) => {
+ const raw = localStorage.getItem(STATES_LOCAL_STORAGE_KEY);
+ const oldStates = raw ? JSON.parse(raw) : {};
+ const newStates = { ...oldStates, [key]: value };
+ setStates(newStates);
+ localStorage.setItem(STATES_LOCAL_STORAGE_KEY, JSON.stringify(newStates));
+ }, []);
+
+ return { states, setState };
+}
+
+function useCalloutHasBeenSeen(
+ featureId: string,
+): { seen: boolean | undefined; markSeen: () => void } {
+ const { states, setState } = useCalloutStates();
+
+ const markSeen = useCallback(() => {
+ setState(featureId, true);
+ }, [setState, featureId]);
+
+ return { seen: states[featureId] === true, markSeen };
+}
+
+export function useShowCallout(
+ featureId: string,
+): { show: boolean; hide: () => void } {
+ const { seen, markSeen } = useCalloutHasBeenSeen(featureId);
+ return { show: seen === false, hide: markSeen };
+}
diff --git a/packages/components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx b/packages/components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx
new file mode 100644
index 0000000000..a2f86d133b
--- /dev/null
+++ b/packages/components/src/components/HeaderIconLinkRow/HeaderIconLinkRow.tsx
@@ -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 React from 'react';
+import { IconLinkVertical, IconLinkVerticalProps } from './IconLinkVertical';
+import { makeStyles } from '@material-ui/core';
+
+const useStyles = makeStyles(theme => ({
+ links: {
+ margin: theme.spacing(2, 0),
+ display: 'grid',
+ gridAutoFlow: 'column',
+ gridAutoColumns: 'min-content',
+ gridGap: theme.spacing(3),
+ },
+}));
+
+type Props = {
+ links: IconLinkVerticalProps[];
+};
+
+export const HeaderIconLinkRow = ({ links }: Props) => {
+ const classes = useStyles();
+ return (
+
+ {links.map((link, index) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx b/packages/components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx
new file mode 100644
index 0000000000..b3e665820a
--- /dev/null
+++ b/packages/components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx
@@ -0,0 +1,95 @@
+/*
+ * 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 classnames from 'classnames';
+import { makeStyles, Link } from '@material-ui/core';
+import LinkIcon from '@material-ui/icons/Link';
+import { Link as RouterLink } from '../Link';
+
+export type IconLinkVerticalProps = {
+ color?: 'primary' | 'secondary';
+ disabled?: boolean;
+ href?: string;
+ icon?: React.ReactNode;
+ label: string;
+ onClick?: React.MouseEventHandler;
+ title?: string;
+};
+
+const useIconStyles = makeStyles(theme => ({
+ link: {
+ display: 'grid',
+ justifyItems: 'center',
+ gridGap: 4,
+ textAlign: 'center',
+ '&:active': {
+ cursor: 'grabbing',
+ },
+ },
+ disabled: {
+ color: 'gray',
+ },
+ primary: {
+ color: theme.palette.primary.main,
+ },
+ secondary: {
+ color: theme.palette.secondary.main,
+ },
+ label: {
+ fontSize: '0.7rem',
+ textTransform: 'uppercase',
+ fontWeight: 600,
+ letterSpacing: 1.2,
+ },
+}));
+
+export function IconLinkVertical({
+ color = 'primary',
+ disabled = false,
+ href = '#',
+ icon = ,
+ label,
+ onClick,
+ title,
+}: IconLinkVerticalProps) {
+ const classes = useIconStyles();
+
+ if (disabled) {
+ return (
+
+ {icon}
+ {label}
+
+ );
+ }
+
+ return (
+
+ {icon}
+ {label}
+
+ );
+}
diff --git a/packages/components/src/components/HeaderIconLinkRow/index.ts b/packages/components/src/components/HeaderIconLinkRow/index.ts
new file mode 100644
index 0000000000..fb25f9b7ed
--- /dev/null
+++ b/packages/components/src/components/HeaderIconLinkRow/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { HeaderIconLinkRow } from './HeaderIconLinkRow';
+
+export type { IconLinkVerticalProps } from './IconLinkVertical';
diff --git a/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx b/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx
new file mode 100644
index 0000000000..9718393427
--- /dev/null
+++ b/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.stories.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { HorizontalScrollGrid } from './HorizontalScrollGrid';
+
+const cardContentStyle = { height: 0, padding: 150, margin: 20 };
+const containerStyle = { width: 800, height: 400, margin: 20 };
+const opacityArray = [0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0];
+
+export default {
+ title: 'Layout/HorizontalScrollGrid',
+ component: HorizontalScrollGrid,
+};
+
+export const Default = () => (
+
+
+ {opacityArray.map(element => {
+ const style = { backgroundColor: `rgba(0, 185, 151, ${element})` };
+ return
;
+ })}
+
+
+);
diff --git a/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.tsx b/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.tsx
new file mode 100644
index 0000000000..0d07d8ec84
--- /dev/null
+++ b/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.tsx
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { fireEvent } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { HorizontalScrollGrid } from './HorizontalScrollGrid';
+import { Grid } from '@material-ui/core';
+
+describe(' ', () => {
+ beforeEach(() => {
+ jest.spyOn(window.performance, 'now').mockReturnValue(5);
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
+ cb(20);
+ return 1;
+ });
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp(
+
+ item1
+ item2
+ ,
+ );
+ rendered.getByText('item1');
+ rendered.getByText('item2');
+ expect(rendered.queryByLabelText('Scroll Left')).toBeNull();
+ expect(rendered.queryByLabelText('Scroll Right')).toBeNull();
+ });
+
+ it('should show scroll buttons', async () => {
+ jest
+ .spyOn(HTMLElement.prototype, 'scrollLeft', 'get')
+ .mockImplementation(() => 5);
+ jest
+ .spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
+ .mockImplementation(() => 10);
+ jest
+ .spyOn(HTMLElement.prototype, 'scrollWidth', 'get')
+ .mockImplementation(() => 20);
+
+ let lastScroll = 0;
+ const scrollBy = HTMLElement.prototype.scrollBy;
+ HTMLElement.prototype.scrollBy = (({ left }: ScrollToOptions): void => {
+ lastScroll = left || 0;
+ }) as any;
+
+ const rendered = await renderInTestApp(
+
+
+ item1
+
+
+ item2
+
+ ,
+ );
+
+ rendered.getByTitle('Scroll Left');
+ rendered.getByTitle('Scroll Right');
+ expect(lastScroll).toBe(0);
+ fireEvent.click(rendered.getByTitle('Scroll Right'));
+ expect(lastScroll).toBeGreaterThan(0);
+ fireEvent.click(rendered.getByTitle('Scroll Left'));
+ expect(lastScroll).toBeLessThan(0);
+
+ HTMLElement.prototype.scrollBy = scrollBy;
+ });
+});
diff --git a/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx b/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx
new file mode 100644
index 0000000000..073fb960e1
--- /dev/null
+++ b/packages/components/src/components/HorizontalScrollGrid/HorizontalScrollGrid.tsx
@@ -0,0 +1,247 @@
+/*
+ * 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, { PropsWithChildren } from 'react';
+import classNames from 'classnames';
+import ChevronLeftIcon from '@material-ui/icons/ChevronLeft';
+import ChevronRightIcon from '@material-ui/icons/ChevronRight';
+import { Grid, IconButton, makeStyles, Theme } from '@material-ui/core';
+
+const generateGradientStops = (themeType: 'dark' | 'light') => {
+ // 97% corresponds to the theme.palette.background.default for the light theme
+ // 16% for the dark theme
+ const luminance = themeType === 'dark' ? '16%' : '97%';
+ // Generated with https://larsenwork.com/easing-gradients/
+ return `
+ hsl(0, 0%, ${luminance}) 0%,
+ hsla(0, 0%, ${luminance}, 0.987) 8.1%,
+ hsla(0, 0%, ${luminance}, 0.951) 15.5%,
+ hsla(0, 0%, ${luminance}, 0.896) 22.5%,
+ hsla(0, 0%, ${luminance}, 0.825) 29%,
+ hsla(0, 0%, ${luminance}, 0.741) 35.3%,
+ hsla(0, 0%, ${luminance}, 0.648) 41.2%,
+ hsla(0, 0%, ${luminance}, 0.55) 47.1%,
+ hsla(0, 0%, ${luminance}, 0.45) 52.9%,
+ hsla(0, 0%, ${luminance}, 0.352) 58.8%,
+ hsla(0, 0%, ${luminance}, 0.259) 64.7%,
+ hsla(0, 0%, ${luminance}, 0.175) 71%,
+ hsla(0, 0%, ${luminance}, 0.104) 77.5%,
+ hsla(0, 0%, ${luminance}, 0.049) 84.5%,
+ hsla(0, 0%, ${luminance}, 0.013) 91.9%,
+ hsla(0, 0%, ${luminance}, 0) 100%
+ `;
+};
+
+const fadeSize = 100;
+const fadePadding = 10;
+
+type Props = {
+ scrollStep?: number;
+ scrollSpeed?: number; // lower is faster
+ minScrollDistance?: number; // limits how small steps the scroll can take in px
+};
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ position: 'relative',
+ display: 'flex',
+ flexFlow: 'row nowrap',
+ alignItems: 'center',
+ },
+ container: {
+ overflow: 'auto',
+ scrollbarWidth: 0 as any, // hide in FF
+ '&::-webkit-scrollbar': {
+ display: 'none', // hide in Chrome
+ },
+ },
+ fade: {
+ position: 'absolute',
+ width: fadeSize,
+ height: `calc(100% + ${fadePadding}px)`,
+ transition: 'opacity 300ms',
+ pointerEvents: 'none',
+ },
+ fadeLeft: {
+ left: -fadePadding,
+ background: `linear-gradient(90deg, ${generateGradientStops(
+ theme.palette.type,
+ )})`,
+ },
+ fadeRight: {
+ right: -fadePadding,
+ background: `linear-gradient(270deg, ${generateGradientStops(
+ theme.palette.type,
+ )})`,
+ },
+ fadeHidden: {
+ opacity: 0,
+ },
+ button: {
+ position: 'absolute',
+ },
+ buttonLeft: {
+ left: -theme.spacing(2),
+ },
+ buttonRight: {
+ right: -theme.spacing(2),
+ },
+}));
+
+// Returns scroll distance from left and right
+function useScrollDistance(
+ ref: React.MutableRefObject,
+): [number, number] {
+ const [[scrollLeft, scrollRight], setScroll] = React.useState<
+ [number, number]
+ >([0, 0]);
+
+ React.useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) {
+ setScroll([0, 0]);
+ return;
+ }
+
+ const handleUpdate = () => {
+ const left = el.scrollLeft;
+ const right = el.scrollWidth - el.offsetWidth - el.scrollLeft;
+ setScroll([left, right]);
+ };
+
+ handleUpdate();
+
+ el.addEventListener('scroll', handleUpdate);
+ window.addEventListener('resize', handleUpdate);
+ // TODO(freben): Remove this eslint exception later
+ // It's here because @types/react-router-dom v5 pulls in @types/react that have the wrong signature
+ // eslint-disable-next-line consistent-return
+ return () => {
+ el.removeEventListener('scroll', handleUpdate);
+ window.removeEventListener('resize', handleUpdate);
+ };
+ }, [ref]);
+
+ return [scrollLeft, scrollRight];
+}
+
+// Used to animate scrolling. Returns a single setScrollTarger function, when called with e.g. 200,
+// the element pointer to by the ref will be scrolled 200px forwards over time.
+function useSmoothScroll(
+ ref: React.MutableRefObject,
+ speed: number,
+ minDistance: number,
+) {
+ const [scrollTarget, setScrollTarget] = React.useState(0);
+
+ React.useLayoutEffect(() => {
+ if (scrollTarget === 0) {
+ return;
+ }
+
+ const startTime = performance.now();
+ const id = requestAnimationFrame(frameTime => {
+ if (!ref.current) {
+ return;
+ }
+ const frameDuration = frameTime - startTime;
+ const scrollDistance = (Math.abs(scrollTarget) * frameDuration) / speed;
+ const cappedScrollDistance = Math.max(minDistance, scrollDistance);
+ const scrollAmount = cappedScrollDistance * Math.sign(scrollTarget);
+
+ ref.current.scrollBy({ left: scrollAmount });
+
+ const newScrollTarget = scrollTarget - scrollAmount;
+ if (Math.sign(scrollTarget) !== Math.sign(newScrollTarget)) {
+ setScrollTarget(0);
+ } else {
+ setScrollTarget(newScrollTarget);
+ }
+ });
+
+ // TODO(freben): Remove this eslint exception later
+ // It's here because @types/react-router-dom v5 pulls in @types/react that have the wrong signature
+ // eslint-disable-next-line consistent-return
+ return () => cancelAnimationFrame(id);
+ }, [ref, scrollTarget, speed, minDistance]);
+
+ return setScrollTarget;
+}
+
+export const HorizontalScrollGrid = (props: PropsWithChildren) => {
+ const {
+ scrollStep = 100,
+ scrollSpeed = 50,
+ minScrollDistance = 5,
+ children,
+ ...otherProps
+ } = props;
+ const classes = useStyles(props);
+ const ref = React.useRef();
+
+ const [scrollLeft, scrollRight] = useScrollDistance(ref);
+ const setScrollTarget = useSmoothScroll(ref, scrollSpeed, minScrollDistance);
+
+ const handleScrollClick = (forwards: boolean) => {
+ const el = ref.current;
+ if (!el) {
+ return;
+ }
+ setScrollTarget(forwards ? scrollStep : -scrollStep);
+ };
+
+ return (
+
+
+ {children}
+
+
+
+ {scrollLeft > 0 && (
+
handleScrollClick(false)}
+ className={classNames(classes.button, classes.buttonLeft, {})}
+ >
+
+
+ )}
+ {scrollRight > 0 && (
+
handleScrollClick(true)}
+ className={classNames(classes.button, classes.buttonRight, {})}
+ >
+
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/components/HorizontalScrollGrid/index.tsx b/packages/components/src/components/HorizontalScrollGrid/index.tsx
new file mode 100644
index 0000000000..cb1253cae2
--- /dev/null
+++ b/packages/components/src/components/HorizontalScrollGrid/index.tsx
@@ -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 { HorizontalScrollGrid } from './HorizontalScrollGrid';
diff --git a/packages/components/src/components/Lifecycle/Lifecycle.stories.tsx b/packages/components/src/components/Lifecycle/Lifecycle.stories.tsx
new file mode 100644
index 0000000000..ccce7fba25
--- /dev/null
+++ b/packages/components/src/components/Lifecycle/Lifecycle.stories.tsx
@@ -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 React from 'react';
+import { Lifecycle } from './Lifecycle';
+
+export default {
+ title: 'Feedback/Lifecycle',
+ component: Lifecycle,
+};
+
+export const AlphaDefault = () => (
+ <>
+ This feature is in
+ >
+);
+export const AlphaShorthand = () => (
+ <>
+ This feature is in
+ >
+);
+export const BetaDefault = () => (
+ <>
+ This feature is in
+ >
+);
+export const BetaShorthand = () => (
+ <>
+ This feature is in
+ >
+);
diff --git a/packages/components/src/components/Lifecycle/Lifecycle.test.tsx b/packages/components/src/components/Lifecycle/Lifecycle.test.tsx
new file mode 100644
index 0000000000..b5567b2b5a
--- /dev/null
+++ b/packages/components/src/components/Lifecycle/Lifecycle.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { Lifecycle } from './Lifecycle';
+
+describe(' ', () => {
+ it('renders Alpha with shorthand', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText('α')).toBeInTheDocument();
+ });
+
+ it('renders Alpha without shorthand', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText('Alpha')).toBeInTheDocument();
+ });
+
+ it('renders Beta with shorthand', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText('β')).toBeInTheDocument();
+ });
+
+ it('renders Beta without shorthand', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText('Beta')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Lifecycle/Lifecycle.tsx b/packages/components/src/components/Lifecycle/Lifecycle.tsx
new file mode 100644
index 0000000000..d388452cdc
--- /dev/null
+++ b/packages/components/src/components/Lifecycle/Lifecycle.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import CSS from 'csstype';
+import { makeStyles } from '@material-ui/core';
+
+type Props = CSS.Properties & {
+ shorthand?: boolean;
+ alpha?: boolean;
+};
+
+const useStyles = makeStyles({
+ alpha: {
+ color: '#d00150',
+ fontFamily: 'serif',
+ fontWeight: 'normal',
+ fontStyle: 'italic',
+ },
+ beta: {
+ color: '#4d65cc',
+ fontFamily: 'serif',
+ fontWeight: 'normal',
+ fontStyle: 'italic',
+ },
+});
+
+export const Lifecycle = (props: Props) => {
+ const classes = useStyles(props);
+ const { shorthand, alpha } = props;
+ return shorthand ? (
+
+ {alpha ? <>α> : <>β>}
+
+ ) : (
+
+ {alpha ? 'Alpha' : 'Beta'}
+
+ );
+};
diff --git a/packages/components/src/components/Lifecycle/index.ts b/packages/components/src/components/Lifecycle/index.ts
new file mode 100644
index 0000000000..8854c04396
--- /dev/null
+++ b/packages/components/src/components/Lifecycle/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 { Lifecycle } from './Lifecycle';
diff --git a/packages/components/src/components/Link/Link.stories.tsx b/packages/components/src/components/Link/Link.stories.tsx
new file mode 100644
index 0000000000..852d51970f
--- /dev/null
+++ b/packages/components/src/components/Link/Link.stories.tsx
@@ -0,0 +1,92 @@
+/*
+ * 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, { FunctionComponentFactory } from 'react';
+import { Link } from './Link';
+import {
+ MemoryRouter,
+ Route,
+ useLocation,
+ NavLink as RouterNavLink,
+} from 'react-router-dom';
+import { createRouteRef } from '@backstage/core-api';
+
+const Location = () => {
+ const location = useLocation();
+ return Current location: {location.pathname} ;
+};
+
+export default {
+ title: 'Navigation/Link',
+ component: Link,
+ decorators: [
+ (storyFn: FunctionComponentFactory<{}>) => (
+
+
+
+ ),
+ ],
+};
+
+export const Default = () => {
+ const routeRef = createRouteRef({
+ path: '/hello',
+ title: 'Hi there!',
+ });
+
+ return (
+ <>
+ This link will utilise the
+ react-router MemoryRouter's navigation
+
+ {routeRef.title}
+
+ >
+ );
+};
+
+export const PassProps = () => {
+ const routeRef = createRouteRef({
+ path: '/hello',
+ title: 'Hi there!',
+ });
+
+ return (
+ <>
+
+ This link
+
+ has props for both material-ui's component as well as for
+ react-router-dom's
+
+ {routeRef.title}
+
+ >
+ );
+};
+PassProps.story = {
+ name: `Accepts material-ui Link's and react-router-dom Link's props`,
+};
diff --git a/packages/components/src/components/Link/Link.test.tsx b/packages/components/src/components/Link/Link.test.tsx
new file mode 100644
index 0000000000..23d2728bf6
--- /dev/null
+++ b/packages/components/src/components/Link/Link.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 } from '@testing-library/react';
+import { wrapInTestApp } from '@backstage/test-utils';
+import { Link } from './Link';
+import { Route, Routes } from 'react-router';
+import { act } from 'react-dom/test-utils';
+
+describe(' ', () => {
+ it('navigates using react-router', async () => {
+ const testString = 'This is test string';
+ const linkText = 'Navigate!';
+ const { getByText } = render(
+ wrapInTestApp(
+
+ {linkText}
+ {testString}} />
+ ,
+ ),
+ );
+ expect(() => getByText(testString)).toThrow();
+ await act(async () => {
+ fireEvent.click(getByText(linkText));
+ });
+ expect(getByText(testString)).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Link/Link.tsx b/packages/components/src/components/Link/Link.tsx
new file mode 100644
index 0000000000..cd74b681db
--- /dev/null
+++ b/packages/components/src/components/Link/Link.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 {
+ Link as MaterialLink,
+ LinkProps as MaterialLinkProps,
+} from '@material-ui/core';
+import React, { ElementType } from 'react';
+import {
+ Link as RouterLink,
+ LinkProps as RouterLinkProps,
+} from 'react-router-dom';
+
+export type LinkProps = MaterialLinkProps &
+ RouterLinkProps & {
+ component?: ElementType;
+ };
+
+/**
+ * Thin wrapper on top of material-ui's Link component
+ * Makes the Link to utilise react-router
+ */
+export const Link = React.forwardRef((props, ref) => {
+ const to = String(props.to);
+ return /^https?:\/\//.test(to) ? (
+ // External links
+
+ ) : (
+ // Interact with React Router for internal links
+
+ );
+});
diff --git a/packages/components/src/components/Link/index.ts b/packages/components/src/components/Link/index.ts
new file mode 100644
index 0000000000..9be779feb7
--- /dev/null
+++ b/packages/components/src/components/Link/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 { Link } from './Link';
+export type { LinkProps } from './Link';
diff --git a/packages/components/src/components/MarkdownContent/MarkdownContent.stories.tsx b/packages/components/src/components/MarkdownContent/MarkdownContent.stories.tsx
new file mode 100644
index 0000000000..eb15aa92d3
--- /dev/null
+++ b/packages/components/src/components/MarkdownContent/MarkdownContent.stories.tsx
@@ -0,0 +1,120 @@
+/*
+ * 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 { MarkdownContent } from './MarkdownContent';
+
+export default {
+ title: 'Data Display/MarkdownContent',
+ component: MarkdownContent,
+};
+
+const markdownGithubFlavored =
+ '# GFM\n' +
+ '\n' +
+ '## Autolink literals\n' +
+ '\n' +
+ 'www.example.com, https://example.com, and contact@example.com.\n' +
+ '\n' +
+ '## Strikethrough\n' +
+ '\n' +
+ '~one~ or ~~two~~ tildes.\n' +
+ '\n' +
+ '## Table\n' +
+ '\n' +
+ '| foo | bar |\n' +
+ '| --- | --- |\n' +
+ '| baz | bim |\n' +
+ '| buz | bum |\n' +
+ '| biz | bim |\n' +
+ '\n' +
+ '## Tasklist\n' +
+ '\n' +
+ '* [ ] to do\n' +
+ '* [x] done';
+
+const markdown =
+ '# Choreas Iovis\n' +
+ '\n' +
+ '## Incedere retenta\n' +
+ '\n' +
+ 'Lorem markdownum velamina [nupta amici aequoreis](http://est-quae.org/sic)\n' +
+ 'desertum factum premunt: falcato parvos nihil. Facietque vulnus tum dumque\n' +
+ 'reserato Maeandros insignia solidis, tot longi causa et nimium arcuerat altera\n' +
+ 'unus, in quis.\n' +
+ '\n' +
+ '1. Est qui dixere nullus\n' +
+ '2. Fuit obicit\n' +
+ '3. Vim patrem portae materiem ulla quod crater\n' +
+ '4. Rigido est magis raptor quid crepitante aequa\n' +
+ '5. Imago quis ignis tamen\n' +
+ '\n' +
+ '## Vix posse vestem\n' +
+ '\n' +
+ 'Nec deos robora visa pater toris remittit *crimina* utque, ora ego lacerae quae\n' +
+ 'laboris laturus silvas audax terrae. Qua fuisse patrio inlaesas [sine\n' +
+ 'seque](http://ambitvictore.org/), nondum et tamen annis, nec. Poscimur magnum,\n' +
+ 'Hesperium dedisti, ait ipse et fides terras scalas.\n' +
+ '\n' +
+ '- Quas superis satyri adloquitur natura hausimus\n' +
+ '- Dux suspicere siccare\n' +
+ '- Cape huc quid videor\n' +
+ '- Foret vivit concolor\n' +
+ '- Occupat morte oblectamina minuunt quaeque placidis nate\n' +
+ '- Non posset' +
+ '\n' +
+ 'Licet movitque dederat potest in sorores in sola pendere luce pro quod, sit.\n' +
+ 'Inpia ut in opibus flores uno quam quo multifidasque fera anhelitus retorsit.\n' +
+ 'Sustinui premebat puppe somnos. Dicit genu sic qualia excussit facunde parvae in\n' +
+ 'robur, Ianthe Interea. Superis victorque ponat puta cum: est enim.\n' +
+ '\n' +
+ '## Tacetve est in nullis Cerberus silvani luminibus\n' +
+ '\n' +
+ 'Divulsere *summissoque esse manes*; artus ausus conatoque utque: illo\n' +
+ 'Phaestiadas quod pascat et referentem, nec. In seris, iubebat iam nomina:\n' +
+ 'tergoque occidit ingenii.\n' +
+ '\n' +
+ ' mouseDdl(tablet_definition * phishing_icann_mamp);\n' +
+ ' vector += 20 + key_ram.source_isa(hard_tunneling_zone(w_wireless_page));\n' +
+ ' if (rate_client_direct) {\n' +
+ ' textXDpi += sql_cloud_class.sdk.speakers_wired_warm(pcZettabyteGis(\n' +
+ ' market_bezel, 1), 1);\n' +
+ ' tag_scraping = format_ppi;\n' +
+ ' }\n' +
+ '\n' +
+ 'Per quem, nec formosior qui cum Peliden me interea **ornatos**! Te facit\n' +
+ 'instimulat sequentia in flumina exilium te vulnere, sola. Coetum nec amnes.\n' +
+ 'Protinus nam Caras cava, *a* vocantem dicta inevitabile, nata nulla.\n' +
+ '\n' +
+ '## Piscem Iunoni maius\n' +
+ '\n' +
+ 'Prece fallere arduus, *ad Athamantis laticem* simillima in ante Temesesque opus!\n' +
+ 'Ausim quoslibet crede Tyria: Medusa [muneris Aeneaden\n' +
+ 'tutaque](http://cragon-aequoribus.io/) genitor fistula cogeris abstrahere nati,\n' +
+ 'relevare videri *non*.\n' +
+ '\n' +
+ '> Promissas ulterius senectae Desinet his ait pedum! Libet *sublime* vibrantia\n' +
+ '> si *dicta quod* pectora cupidine hastam dominoque.\n' +
+ '\n' +
+ 'Pedis hic, est bis quod, adhaeret et reditum. Fixa sic vel pugnare **forte est**\n' +
+ 'parte in quaerite generisque repugnat; de quod, creatos.';
+export const MarkdownContentCommonMark = () => (
+
+);
+
+export const MarkdownContentGithubFlavoredCommonMark = () => (
+
+);
diff --git a/packages/components/src/components/MarkdownContent/MarkdownContent.test.tsx b/packages/components/src/components/MarkdownContent/MarkdownContent.test.tsx
new file mode 100644
index 0000000000..dcbe421dcb
--- /dev/null
+++ b/packages/components/src/components/MarkdownContent/MarkdownContent.test.tsx
@@ -0,0 +1,65 @@
+/*
+ * 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 { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
+import { MarkdownContent } from './MarkdownContent';
+
+describe(' ', () => {
+ it('render MarkdownContent component', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+ ,
+ ),
+ );
+ expect(rendered.getByText('H1', { selector: 'h1' })).toBeInTheDocument();
+ expect(rendered.getByText('H2', { selector: 'h2' })).toBeInTheDocument();
+ expect(rendered.getByText('H3', { selector: 'h3' })).toBeInTheDocument();
+ });
+
+ it('render MarkdownContent component with GitHub flavored Markdown dialect', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+ expect(
+ rendered.getByText('https://example.com', { selector: 'a' }),
+ ).toBeInTheDocument();
+ });
+
+ it('Render MarkdownContent component with common mark dialect', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp(
+ ,
+ ),
+ );
+ expect(
+ rendered.getByText('https://example.com', { selector: 'p' }),
+ ).toBeInTheDocument();
+ });
+
+ it('render MarkdownContent component with CodeSnippet for code blocks', async () => {
+ const rendered = await renderWithEffects(
+ wrapInTestApp( ),
+ );
+ const fp1 = rendered.getByText('jest', { selector: 'span' });
+ expect(fp1).toBeInTheDocument();
+ expect(fp1.className).toEqual('hljs-function');
+ const fp2 = rendered.getByText('(test: string)', { selector: 'span' });
+ expect(fp2).toBeInTheDocument();
+ expect(fp2.className).toEqual('hljs-function');
+ expect(rendered.getByText(';', { selector: 'span' })).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/MarkdownContent/MarkdownContent.tsx b/packages/components/src/components/MarkdownContent/MarkdownContent.tsx
new file mode 100644
index 0000000000..0000e54a5f
--- /dev/null
+++ b/packages/components/src/components/MarkdownContent/MarkdownContent.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 { makeStyles } from '@material-ui/core';
+import ReactMarkdown from 'react-markdown';
+import gfm from 'remark-gfm';
+import React from 'react';
+import { BackstageTheme } from '@backstage/theme';
+import { CodeSnippet } from '../CodeSnippet';
+
+const useStyles = makeStyles(theme => ({
+ markdown: {
+ '& table': {
+ borderCollapse: 'collapse',
+ border: `1px solid ${theme.palette.border}`,
+ },
+ '& th, & td': {
+ border: `1px solid ${theme.palette.border}`,
+ padding: theme.spacing(1),
+ },
+ '& td': {
+ wordBreak: 'break-word',
+ overflow: 'hidden',
+ verticalAlign: 'middle',
+ lineHeight: '1',
+ margin: 0,
+ padding: theme.spacing(3, 2, 3, 2.5),
+ borderBottom: 0,
+ },
+ '& th': {
+ backgroundColor: theme.palette.background.paper,
+ },
+ '& tr': {
+ backgroundColor: theme.palette.background.paper,
+ },
+ '& tr:nth-child(odd)': {
+ backgroundColor: theme.palette.background.default,
+ },
+
+ '& a': {
+ color: theme.palette.link,
+ },
+ '& img': {
+ maxWidth: '100%',
+ },
+ },
+}));
+
+type Props = {
+ content: string;
+ dialect?: 'gfm' | 'common-mark';
+};
+
+const renderers = {
+ code: ({ language, value }: { language: string; value: string }) => {
+ return ;
+ },
+};
+
+/**
+ * MarkdownContent
+ * --
+ * Renders markdown with the default dialect [gfm - GitHub flavored Markdown](https://github.github.com/gfm/) to backstage theme styled HTML.
+ * If you just want to render to plain [CommonMark](https://commonmark.org/), set the dialect to `'common-mark'`
+ */
+export const MarkdownContent = ({ content, dialect = 'gfm' }: Props) => {
+ const classes = useStyles();
+ return (
+
+ );
+};
diff --git a/packages/components/src/components/MarkdownContent/index.ts b/packages/components/src/components/MarkdownContent/index.ts
new file mode 100644
index 0000000000..7267ff191c
--- /dev/null
+++ b/packages/components/src/components/MarkdownContent/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 { MarkdownContent } from './MarkdownContent';
diff --git a/packages/components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx
new file mode 100644
index 0000000000..cc1df3b529
--- /dev/null
+++ b/packages/components/src/components/OAuthRequestDialog/LoginRequestListItem.tsx
@@ -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 {
+ ListItem,
+ ListItemAvatar,
+ ListItemText,
+ makeStyles,
+ Typography,
+ Theme,
+} from '@material-ui/core';
+import React, { useState } from 'react';
+import { PendingAuthRequest } from '@backstage/core-api';
+
+const useItemStyles = makeStyles(theme => ({
+ root: {
+ paddingLeft: theme.spacing(3),
+ },
+}));
+
+type RowProps = {
+ request: PendingAuthRequest;
+ busy: boolean;
+ setBusy: (busy: boolean) => void;
+};
+
+const LoginRequestListItem = ({ request, busy, setBusy }: RowProps) => {
+ const classes = useItemStyles();
+ const [error, setError] = useState();
+
+ const handleContinue = async () => {
+ setBusy(true);
+ try {
+ await request.trigger();
+ } catch (e) {
+ setError(e);
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const IconComponent = request.provider.icon;
+
+ return (
+
+
+
+
+
+ {error.message || 'An unspecified error occurred'}
+
+ )
+ }
+ />
+
+ );
+};
+
+export default LoginRequestListItem;
diff --git a/packages/components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx
new file mode 100644
index 0000000000..06b53332dc
--- /dev/null
+++ b/packages/components/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx
@@ -0,0 +1,86 @@
+/*
+ * 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 {
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ List,
+ makeStyles,
+ Theme,
+ Button,
+} from '@material-ui/core';
+import React, { useMemo, useState } from 'react';
+import { useObservable } from 'react-use';
+import LoginRequestListItem from './LoginRequestListItem';
+import { useApi, oauthRequestApiRef } from '@backstage/core-api';
+
+const useStyles = makeStyles(theme => ({
+ dialog: {
+ paddingTop: theme.spacing(1),
+ },
+ title: {
+ minWidth: 0,
+ },
+ contentList: {
+ padding: 0,
+ },
+}));
+
+export const OAuthRequestDialog = () => {
+ const classes = useStyles();
+ const [busy, setBusy] = useState(false);
+ const oauthRequestApi = useApi(oauthRequestApiRef);
+ const requests = useObservable(
+ useMemo(() => oauthRequestApi.authRequest$(), [oauthRequestApi]),
+ [],
+ );
+
+ const handleRejectAll = () => {
+ requests.forEach(request => request.reject());
+ };
+
+ return (
+
+
+ Login Required
+
+
+
+
+ {requests.map(request => (
+
+ ))}
+
+
+
+
+ Reject All
+
+
+ );
+};
diff --git a/packages/components/src/components/OAuthRequestDialog/index.ts b/packages/components/src/components/OAuthRequestDialog/index.ts
new file mode 100644
index 0000000000..45f87ece1d
--- /dev/null
+++ b/packages/components/src/components/OAuthRequestDialog/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 { OAuthRequestDialog } from './OAuthRequestDialog';
diff --git a/packages/components/src/components/OverflowTooltip/OverflowTooltip.stories.tsx b/packages/components/src/components/OverflowTooltip/OverflowTooltip.stories.tsx
new file mode 100644
index 0000000000..e0cbe7c814
--- /dev/null
+++ b/packages/components/src/components/OverflowTooltip/OverflowTooltip.stories.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Box } from '@material-ui/core';
+import React from 'react';
+import { OverflowTooltip } from './OverflowTooltip';
+
+export default {
+ title: 'Data Display/OverflowTooltip',
+ component: OverflowTooltip,
+};
+
+const text =
+ 'Lorem Ipsum is simply dummy text of the printing and typesetting industry.';
+
+export const Default = () => (
+
+
+
+);
+
+export const MultiLine = () => (
+
+
+
+);
+
+export const DifferentTitle = () => (
+
+
+
+);
diff --git a/packages/components/src/components/OverflowTooltip/OverflowTooltip.tsx b/packages/components/src/components/OverflowTooltip/OverflowTooltip.tsx
new file mode 100644
index 0000000000..faf064f9f4
--- /dev/null
+++ b/packages/components/src/components/OverflowTooltip/OverflowTooltip.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { makeStyles, Tooltip, TooltipProps } from '@material-ui/core';
+import React, { useState } from 'react';
+import TextTruncate, { TextTruncateProps } from 'react-text-truncate';
+
+type Props = {
+ text: TextTruncateProps['text'];
+ line?: TextTruncateProps['line'];
+ element?: TextTruncateProps['element'];
+ title?: TooltipProps['title'];
+ placement?: TooltipProps['placement'];
+};
+
+const useStyles = makeStyles({
+ container: {
+ overflow: 'visible !important',
+ },
+});
+
+export const OverflowTooltip = (props: Props) => {
+ const [hover, setHover] = useState(false);
+ const classes = useStyles();
+
+ const handleToggled = (truncated: boolean) => {
+ setHover(truncated);
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/components/src/components/OverflowTooltip/index.ts b/packages/components/src/components/OverflowTooltip/index.ts
new file mode 100644
index 0000000000..fe51e8267f
--- /dev/null
+++ b/packages/components/src/components/OverflowTooltip/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { OverflowTooltip } from './OverflowTooltip';
diff --git a/packages/components/src/components/Progress/Progress.stories.tsx b/packages/components/src/components/Progress/Progress.stories.tsx
new file mode 100644
index 0000000000..cd17dce8b4
--- /dev/null
+++ b/packages/components/src/components/Progress/Progress.stories.tsx
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Progress } from '.';
+
+export default {
+ title: 'Feedback/Progress',
+ component: Progress,
+};
+
+export const progress = () => ;
diff --git a/packages/components/src/components/Progress/Progress.test.tsx b/packages/components/src/components/Progress/Progress.test.tsx
new file mode 100644
index 0000000000..46a162e8b3
--- /dev/null
+++ b/packages/components/src/components/Progress/Progress.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 React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { act } from 'react-dom/test-utils';
+
+import { Progress } from './Progress';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ jest.useFakeTimers();
+ const { getByTestId, queryByTestId } = await renderInTestApp( );
+ expect(queryByTestId('progress')).not.toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(250);
+ });
+ expect(getByTestId('progress')).toBeInTheDocument();
+ jest.useRealTimers();
+ });
+});
diff --git a/packages/components/src/components/Progress/Progress.tsx b/packages/components/src/components/Progress/Progress.tsx
new file mode 100644
index 0000000000..aacdf8821b
--- /dev/null
+++ b/packages/components/src/components/Progress/Progress.tsx
@@ -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.
+ */
+
+import React, { useState, useEffect, PropsWithChildren } from 'react';
+import { LinearProgress, LinearProgressProps } from '@material-ui/core';
+
+export const Progress = (props: PropsWithChildren) => {
+ const [isVisible, setIsVisible] = useState(false);
+
+ useEffect(() => {
+ const handle = setTimeout(() => setIsVisible(true), 250);
+ return () => clearTimeout(handle);
+ }, []);
+
+ return isVisible ? (
+
+ ) : (
+
+ );
+};
diff --git a/packages/components/src/components/Progress/index.ts b/packages/components/src/components/Progress/index.ts
new file mode 100644
index 0000000000..6598103ab1
--- /dev/null
+++ b/packages/components/src/components/Progress/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 { Progress } from './Progress';
diff --git a/packages/components/src/components/ProgressBars/Gauge.stories.tsx b/packages/components/src/components/ProgressBars/Gauge.stories.tsx
new file mode 100644
index 0000000000..ab9c263f05
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/Gauge.stories.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Gauge } from './Gauge';
+
+const containerStyle = { width: 300 };
+
+export default {
+ title: 'Data Display/Gauge',
+ component: Gauge,
+};
+
+export const Default = () => (
+
+
+
+);
+
+export const MediumProgress = () => (
+
+
+
+);
+
+export const LowProgress = () => (
+
+
+
+);
+
+export const InverseLowProgress = () => (
+
+
+
+);
+
+export const AbsoluteProgress = () => (
+
+
+
+);
diff --git a/packages/components/src/components/ProgressBars/Gauge.test.tsx b/packages/components/src/components/ProgressBars/Gauge.test.tsx
new file mode 100644
index 0000000000..00cf0fd009
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/Gauge.test.tsx
@@ -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 React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { Gauge, getProgressColor } from './Gauge';
+import * as theme from '@backstage/theme';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ getByText('10%');
+ });
+ it('handles fractional prop', async () => {
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ getByText('10%');
+ });
+
+ it('handles max prop', async () => {
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ getByText('1%');
+ });
+
+ it('handles unit prop', async () => {
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ getByText('10m');
+ });
+
+ const ok = '#111';
+ const warning = '#222';
+ const error = '#333';
+ const palette = {
+ ...theme.lightTheme.palette,
+ status: { ...theme.lightTheme.palette.status, ok, warning, error },
+ };
+
+ it('colors the progress correctly', () => {
+ expect(getProgressColor(palette, 'Not a Number' as any)).toBe('#ddd');
+ expect(getProgressColor(palette, 10)).toBe(error);
+ expect(getProgressColor(palette, 50)).toBe(warning);
+ expect(getProgressColor(palette, 90)).toBe(ok);
+ });
+
+ it('colors the inverse progress correctly', () => {
+ expect(getProgressColor(palette, 'Not a Number' as any)).toBe('#ddd');
+ expect(getProgressColor(palette, 10, true)).toBe(ok);
+ expect(getProgressColor(palette, 50, true)).toBe(warning);
+ expect(getProgressColor(palette, 90, true)).toBe(error);
+ });
+});
diff --git a/packages/components/src/components/ProgressBars/Gauge.tsx b/packages/components/src/components/ProgressBars/Gauge.tsx
new file mode 100644
index 0000000000..ca6a3a66ab
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/Gauge.tsx
@@ -0,0 +1,106 @@
+/*
+ * 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 { makeStyles, useTheme } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+import { Circle } from 'rc-progress';
+import React from 'react';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ position: 'relative',
+ lineHeight: 0,
+ },
+ overlay: {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -60%)',
+ fontSize: 45,
+ fontWeight: 'bold',
+ color: theme.palette.textContrast,
+ },
+ circle: {
+ width: '80%',
+ transform: 'translate(10%, 0)',
+ },
+ colorUnknown: {},
+}));
+
+type Props = {
+ value: number;
+ fractional?: boolean;
+ inverse?: boolean;
+ unit?: string;
+ max?: number;
+};
+
+const defaultProps = {
+ fractional: true,
+ inverse: false,
+ unit: '%',
+ max: 100,
+};
+
+export function getProgressColor(
+ palette: BackstageTheme['palette'],
+ value: number,
+ inverse?: boolean,
+ max?: number,
+) {
+ if (isNaN(value)) {
+ return '#ddd';
+ }
+
+ const actualMax = max ? max : defaultProps.max;
+ const actualValue = inverse ? actualMax - value : value;
+
+ if (actualValue < actualMax / 3) {
+ return palette.status.error;
+ } else if (actualValue < actualMax * (2 / 3)) {
+ return palette.status.warning;
+ }
+
+ return palette.status.ok;
+}
+
+export const Gauge = (props: Props) => {
+ const classes = useStyles(props);
+ const theme = useTheme();
+ const { value, fractional, inverse, unit, max } = {
+ ...defaultProps,
+ ...props,
+ };
+
+ const asPercentage = fractional ? Math.round(value * max) : value;
+ const asActual = max !== 100 ? Math.round(value) : asPercentage;
+
+ return (
+
+
+
+ {isNaN(value) ? 'N/A' : `${asActual}${unit}`}
+
+
+ );
+};
diff --git a/packages/components/src/components/ProgressBars/GaugeCard.stories.tsx b/packages/components/src/components/ProgressBars/GaugeCard.stories.tsx
new file mode 100644
index 0000000000..982999f8d6
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/GaugeCard.stories.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { PropsWithChildren } from 'react';
+import { GaugeCard } from './GaugeCard';
+import { Grid } from '@material-ui/core';
+import { MemoryRouter } from 'react-router';
+
+const linkInfo = { title: 'Go to XYZ Location', link: '#' };
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Data Display/Progress Card',
+ component: GaugeCard,
+};
+
+export const Default = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const Subhead = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const LinkInFooter = () => (
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/components/src/components/ProgressBars/GaugeCard.test.tsx b/packages/components/src/components/ProgressBars/GaugeCard.test.tsx
new file mode 100644
index 0000000000..db112fa2ab
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/GaugeCard.test.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+
+import { GaugeCard } from './GaugeCard';
+
+const minProps = { title: 'Tingle upgrade', progress: 0.12 };
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText(/Tingle.*/)).toBeInTheDocument();
+ });
+
+ it('renders progress and title', async () => {
+ const { getByText } = await renderInTestApp( );
+ expect(getByText(/Tingle.*/)).toBeInTheDocument();
+ expect(getByText(/12%.*/)).toBeInTheDocument();
+ });
+
+ it('does not render deepLink', async () => {
+ const { queryByText } = await renderInTestApp( );
+ expect(queryByText('View more')).not.toBeInTheDocument();
+ });
+
+ it('handles invalid numbers', async () => {
+ const badProps = { title: 'Tingle upgrade', progress: 'hejjo' } as any;
+ const { getByText } = await renderInTestApp( );
+ expect(getByText(/N\/A.*/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/ProgressBars/GaugeCard.tsx b/packages/components/src/components/ProgressBars/GaugeCard.tsx
new file mode 100644
index 0000000000..2154619b01
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/GaugeCard.tsx
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { makeStyles } from '@material-ui/core';
+import { InfoCard, InfoCardVariants } from '../../layout/InfoCard';
+import { BottomLinkProps } from '../../layout/BottomLink';
+import { Gauge } from './Gauge';
+
+type Props = {
+ title: string;
+ subheader?: string;
+ variant?: InfoCardVariants;
+ /** Progress in % specified as decimal, e.g. "0.23" */
+ progress: number;
+ deepLink?: BottomLinkProps;
+};
+
+const useStyles = makeStyles({
+ root: {
+ height: '100%',
+ width: 250,
+ },
+});
+
+export const GaugeCard = (props: Props) => {
+ const classes = useStyles(props);
+ const { title, subheader, progress, deepLink, variant } = props;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/components/src/components/ProgressBars/LinearGauge.stories.tsx b/packages/components/src/components/ProgressBars/LinearGauge.stories.tsx
new file mode 100644
index 0000000000..fa3c7c00f0
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/LinearGauge.stories.tsx
@@ -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 React from 'react';
+import { LinearGauge } from './LinearGauge';
+
+const containerStyle = { width: 300 };
+
+export default {
+ title: 'Data Display/LinearGauge',
+ component: LinearGauge,
+};
+
+export const Default = () => (
+
+
+
+);
+
+export const MediumProgress = () => (
+
+
+
+);
+
+export const LowProgress = () => (
+
+
+
+);
diff --git a/packages/components/src/components/ProgressBars/LinearGauge.test.tsx b/packages/components/src/components/ProgressBars/LinearGauge.test.tsx
new file mode 100644
index 0000000000..fc04b642fb
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/LinearGauge.test.tsx
@@ -0,0 +1,37 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+
+import { LinearGauge } from './LinearGauge';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByTitle } = await renderInTestApp( );
+ expect(getByTitle('50%')).toBeInTheDocument();
+ });
+
+ it('renders progress and title', async () => {
+ const { container } = await renderInTestApp( );
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders with 100 as max value', async () => {
+ const { getByTitle } = await renderInTestApp( );
+ expect(getByTitle('100%')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/ProgressBars/LinearGauge.tsx b/packages/components/src/components/ProgressBars/LinearGauge.tsx
new file mode 100644
index 0000000000..9bb7b34c09
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/LinearGauge.tsx
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Tooltip, useTheme } from '@material-ui/core';
+// @ts-ignore
+import { Line } from 'rc-progress';
+import { BackstageTheme } from '@backstage/theme';
+import { getProgressColor } from './Gauge';
+
+type Props = {
+ /**
+ * Progress value between 0.0 - 1.0.
+ */
+ value: number;
+};
+
+export const LinearGauge = ({ value }: Props) => {
+ const theme = useTheme();
+ if (isNaN(value)) {
+ return null;
+ }
+ let percent = Math.round(value * 100 * 100) / 100;
+ if (percent > 100) {
+ percent = 100;
+ }
+ const strokeColor = getProgressColor(theme.palette, percent, false, 100);
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/components/src/components/ProgressBars/index.ts b/packages/components/src/components/ProgressBars/index.ts
new file mode 100644
index 0000000000..4463aea29b
--- /dev/null
+++ b/packages/components/src/components/ProgressBars/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { GaugeCard } from './GaugeCard';
+export { Gauge } from './Gauge';
+export { LinearGauge } from './LinearGauge';
diff --git a/packages/components/src/components/Select/Select.stories.tsx b/packages/components/src/components/Select/Select.stories.tsx
new file mode 100644
index 0000000000..833f3bc5f4
--- /dev/null
+++ b/packages/components/src/components/Select/Select.stories.tsx
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Select } from '.';
+
+export default {
+ title: 'Inputs/Select',
+ component: Select,
+};
+
+const SELECT_ITEMS = [
+ {
+ label: 'test 1',
+ value: 'test_1',
+ },
+ {
+ label: 'test 2',
+ value: 'test_2',
+ },
+ {
+ label: 'test 3',
+ value: 'test_3',
+ },
+];
+
+export const Default = () => (
+ {}}
+ placeholder="All results"
+ label="Default"
+ items={SELECT_ITEMS}
+ />
+);
+
+export const Multiple = () => (
+ {}}
+ />
+);
diff --git a/packages/components/src/components/Select/Select.test.tsx b/packages/components/src/components/Select/Select.test.tsx
new file mode 100644
index 0000000000..9063804573
--- /dev/null
+++ b/packages/components/src/components/Select/Select.test.tsx
@@ -0,0 +1,58 @@
+/*
+ * 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 { Select } from '.';
+
+const SELECT_ITEMS = [
+ {
+ label: 'test 1',
+ value: 'test_1',
+ },
+ {
+ label: 'test 2',
+ value: 'test_2',
+ },
+];
+
+const minProps = {
+ onChange: jest.fn(),
+ label: 'Default',
+ placeholder: 'All results',
+ items: SELECT_ITEMS,
+};
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByText, getByTestId } = render( );
+
+ expect(getByText('Default')).toBeInTheDocument();
+ const input = await getByTestId('select');
+ expect(input.textContent).toBe('All results');
+
+ // Simulate click on input
+ fireEvent.click(input);
+
+ expect(getByText('test 1')).toBeInTheDocument();
+ const option = getByText('test 1');
+
+ // Simulate click on option
+ fireEvent.click(option);
+ expect(input.textContent).toBe('test 1');
+ });
+});
diff --git a/packages/components/src/components/Select/Select.tsx b/packages/components/src/components/Select/Select.tsx
new file mode 100644
index 0000000000..1e6dba76e0
--- /dev/null
+++ b/packages/components/src/components/Select/Select.tsx
@@ -0,0 +1,231 @@
+/*
+ * 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 {
+ Checkbox,
+ Chip,
+ ClickAwayListener,
+ FormControl,
+ InputBase,
+ MenuItem,
+ Select,
+ Typography,
+} from '@material-ui/core';
+import {
+ createStyles,
+ makeStyles,
+ Theme,
+ withStyles,
+} from '@material-ui/core/styles';
+import React, { useEffect, useState } from 'react';
+import ClosedDropdown from './static/ClosedDropdown';
+import OpenedDropdown from './static/OpenedDropdown';
+
+const BootstrapInput = withStyles((theme: Theme) =>
+ createStyles({
+ root: {
+ 'label + &': {
+ marginTop: theme.spacing(3),
+ },
+ },
+ input: {
+ borderRadius: 4,
+ position: 'relative',
+ backgroundColor: theme.palette.background.paper,
+ border: '1px solid #ced4da',
+ fontSize: 16,
+ padding: '10px 26px 10px 12px',
+ transition: theme.transitions.create(['border-color', 'box-shadow']),
+ fontFamily: 'Helvetica Neue',
+ '&:focus': {
+ background: theme.palette.background.paper,
+ borderRadius: 4,
+ },
+ },
+ }),
+)(InputBase);
+
+const useStyles = makeStyles((theme: Theme) =>
+ createStyles({
+ formControl: {
+ margin: `${theme.spacing(1)} 0px`,
+ maxWidth: 300,
+ },
+ label: {
+ transform: 'initial',
+ fontWeight: 'bold',
+ fontSize: 14,
+ fontFamily: theme.typography.fontFamily,
+ color: theme.palette.text.primary,
+ '&.Mui-focused': {
+ color: theme.palette.text.primary,
+ },
+ },
+ chips: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ },
+ chip: {
+ margin: 2,
+ },
+ checkbox: {},
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ }),
+);
+
+type Item = {
+ label: string;
+ value: string | number;
+};
+
+type Selection = string | string[] | number | number[];
+
+export type SelectProps = {
+ multiple?: boolean;
+ items: Item[];
+ label: string;
+ placeholder?: string;
+ selected?: Selection;
+ onChange: (arg: Selection) => void;
+ triggerReset?: boolean;
+};
+
+export const SelectComponent = ({
+ multiple,
+ items,
+ label,
+ placeholder,
+ selected,
+ onChange,
+ triggerReset,
+}: SelectProps) => {
+ const classes = useStyles();
+ const [value, setValue] = useState(
+ selected || (multiple ? [] : ''),
+ );
+ const [isOpen, setOpen] = useState(false);
+
+ useEffect(() => {
+ setValue(multiple ? [] : '');
+ }, [triggerReset, multiple]);
+
+ useEffect(() => {
+ if (selected !== undefined) {
+ setValue(selected);
+ }
+ }, [selected]);
+
+ const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => {
+ setValue(event.target.value as Selection);
+ onChange(event.target.value as Selection);
+ };
+
+ const handleClick = (event: React.ChangeEvent) => {
+ setOpen(previous => {
+ if (multiple && !(event.target instanceof HTMLElement)) {
+ return true;
+ }
+ return !previous;
+ });
+ };
+
+ const handleClickAway = () => {
+ setOpen(false);
+ };
+
+ const handleDelete = (selectedValue: string | number) => () => {
+ const newValue = (value as any[]).filter(chip => chip !== selectedValue);
+ setValue(newValue);
+ onChange(newValue);
+ };
+
+ return (
+
+
{label}
+
+
+ }
+ renderValue={s =>
+ multiple && (value as any[]).length !== 0 ? (
+
+ {(s as string[]).map(selectedValue => (
+ el.value === selectedValue)?.value}
+ label={
+ items.find(el => el.value === selectedValue)?.label
+ }
+ clickable
+ onDelete={handleDelete(selectedValue)}
+ className={classes.chip}
+ />
+ ))}
+
+ ) : (
+
+ {(value as any[]).length === 0
+ ? placeholder || ''
+ : items.find(el => el.value === s)?.label}
+
+ )
+ }
+ IconComponent={() =>
+ !isOpen ? :
+ }
+ MenuProps={{
+ anchorOrigin: {
+ vertical: 'bottom',
+ horizontal: 'left',
+ },
+ transformOrigin: {
+ vertical: 'top',
+ horizontal: 'left',
+ },
+ getContentAnchorEl: null,
+ }}
+ >
+ {placeholder && !multiple && (
+ {placeholder}
+ )}
+ {items &&
+ items.map(item => (
+
+ {multiple && (
+
+ )}
+ {item.label}
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/components/src/components/Select/index.tsx b/packages/components/src/components/Select/index.tsx
new file mode 100644
index 0000000000..977ebc88eb
--- /dev/null
+++ b/packages/components/src/components/Select/index.tsx
@@ -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 { SelectComponent as Select } from './Select';
diff --git a/packages/components/src/components/Select/static/ClosedDropdown.tsx b/packages/components/src/components/Select/static/ClosedDropdown.tsx
new file mode 100644
index 0000000000..235a91963b
--- /dev/null
+++ b/packages/components/src/components/Select/static/ClosedDropdown.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { SvgIcon, makeStyles, createStyles } from '@material-ui/core';
+
+const useStyles = makeStyles(() =>
+ createStyles({
+ icon: {
+ position: 'absolute',
+ right: '4px',
+ pointerEvents: 'none',
+ },
+ }),
+);
+
+const ClosedDropdown = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+export default ClosedDropdown;
diff --git a/packages/components/src/components/Select/static/OpenedDropdown.tsx b/packages/components/src/components/Select/static/OpenedDropdown.tsx
new file mode 100644
index 0000000000..e4a8021017
--- /dev/null
+++ b/packages/components/src/components/Select/static/OpenedDropdown.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { SvgIcon, makeStyles, createStyles } from '@material-ui/core';
+
+const useStyles = makeStyles(() =>
+ createStyles({
+ icon: {
+ position: 'absolute',
+ right: '4px',
+ pointerEvents: 'none',
+ },
+ }),
+);
+
+const OpenedDropdown = () => {
+ const classes = useStyles();
+ return (
+
+
+
+ );
+};
+
+export default OpenedDropdown;
diff --git a/packages/components/src/components/SimpleStepper/SimpleStepper.stories.tsx b/packages/components/src/components/SimpleStepper/SimpleStepper.stories.tsx
new file mode 100644
index 0000000000..e7163ea533
--- /dev/null
+++ b/packages/components/src/components/SimpleStepper/SimpleStepper.stories.tsx
@@ -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 React, { useState } from 'react';
+import { TextField } from '@material-ui/core';
+import { SimpleStepper, SimpleStepperStep } from '.';
+
+export default {
+ title: 'Navigation/SimpleStepper',
+ component: SimpleStepper,
+};
+
+export const Default = () => (
+
+
+ This is the content for step 1
+
+
+ This is the content for step 2
+
+
+ This is the content for step 3
+
+
+);
+
+export const ConditionalButtons = () => {
+ const [required, setRequired] = useState(false);
+
+ return (
+
+ required,
+ }}
+ >
+ setRequired(!!e.target.value)}
+ />
+
+
+ This is the content for step 2
+
+
+ This is the content for step 3
+
+
+ );
+};
+
+export const CompletionStep = () => {
+ return (
+
+
+ This is the content for step 1
+
+
+ This is the content for step 2
+
+
+ You've completed the Stepper
+
+
+ );
+};
diff --git a/packages/components/src/components/SimpleStepper/SimpleStepper.test.tsx b/packages/components/src/components/SimpleStepper/SimpleStepper.test.tsx
new file mode 100644
index 0000000000..527160c4ff
--- /dev/null
+++ b/packages/components/src/components/SimpleStepper/SimpleStepper.test.tsx
@@ -0,0 +1,148 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { fireEvent, within } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { SimpleStepper as Stepper } from './SimpleStepper';
+import { SimpleStepperStep as Step } from './SimpleStepperStep';
+
+const getTextInSlide = (rendered: any, index: number) =>
+ within(rendered.getByTestId(`step${index}`)).getByText;
+
+describe('Stepper', () => {
+ it('Maintains state history', async () => {
+ const rendered = await renderInTestApp(
+
+
+ step0
+
+
+ step1
+
+
+ step2
+
+ ,
+ );
+
+ fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Back') as Node);
+ expect(rendered.getByText('step0')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+ });
+
+ it('Handles nextStep property', async () => {
+ const rendered = await renderInTestApp(
+
+
+ step0
+
+ 3 }}
+ data-testid="step1"
+ >
+ step1
+
+
+ step2
+
+
+ step3
+
+ ,
+ );
+
+ fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Next') as Node);
+ expect(rendered.getByText('step3')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 3)('Back') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+ });
+
+ it('Shows controls and content when going back to first step', async () => {
+ const rendered = await renderInTestApp(
+
+
+ step0
+
+ 3 }}
+ data-testid="step1"
+ >
+ step1
+
+
+ step2
+
+
+ step3
+
+ ,
+ );
+
+ fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Next') as Node);
+ expect(rendered.getByText('step3')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 3)('Back') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+ expect(getTextInSlide(rendered, 1)('Next')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Back') as Node);
+ expect(rendered.getByText('step0')).toBeInTheDocument();
+ expect(getTextInSlide(rendered, 0)('Next')).toBeInTheDocument();
+ });
+
+ it('uses nextText if specified in all steps', async () => {
+ const rendered = await renderInTestApp(
+
+
+
+ step0
+
+
+ final step
+
+
+ ,
+ );
+ expect(rendered.getByText('Step0Next')).toBeInTheDocument();
+ fireEvent.click(rendered.getByText('Step0Next'));
+
+ expect(rendered.getByText('FinalStepNext')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/SimpleStepper/SimpleStepper.tsx b/packages/components/src/components/SimpleStepper/SimpleStepper.tsx
new file mode 100644
index 0000000000..ff65534843
--- /dev/null
+++ b/packages/components/src/components/SimpleStepper/SimpleStepper.tsx
@@ -0,0 +1,98 @@
+/*
+ * 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, {
+ Children,
+ isValidElement,
+ useState,
+ useEffect,
+ PropsWithChildren,
+} from 'react';
+import { Stepper as MuiStepper } from '@material-ui/core';
+
+type InternalState = {
+ stepperLength: number;
+ stepIndex: number;
+ setStepIndex: any;
+ stepHistory: number[];
+ setStepHistory: any;
+ onStepChange?: (prevIndex: number, nextIndex: number) => void;
+};
+
+const noop = () => {};
+export const VerticalStepperContext = React.createContext({
+ stepperLength: 0,
+ stepIndex: 0,
+ setStepIndex: noop,
+ stepHistory: [],
+ setStepHistory: noop,
+ onStepChange: noop,
+});
+
+export interface StepperProps {
+ elevated?: boolean;
+ onStepChange?: (prevIndex: number, nextIndex: number) => void;
+ activeStep?: number;
+}
+
+export const SimpleStepper = ({
+ children,
+ elevated,
+ onStepChange,
+ activeStep = 0,
+}: PropsWithChildren) => {
+ const [stepIndex, setStepIndex] = useState(activeStep);
+ const [stepHistory, setStepHistory] = useState([0]);
+
+ useEffect(() => {
+ setStepIndex(activeStep);
+ }, [activeStep]);
+
+ const steps: React.ReactNode[] = [];
+ let endStep;
+ Children.forEach(children, child => {
+ if (isValidElement(child)) {
+ if (child.props.end) {
+ endStep = child;
+ } else {
+ steps.push(child);
+ }
+ }
+ });
+
+ return (
+ <>
+
+
+ {steps}
+
+
+ {stepIndex >= Children.count(children) - 1 && endStep}
+ >
+ );
+};
diff --git a/packages/components/src/components/SimpleStepper/SimpleStepperFooter.tsx b/packages/components/src/components/SimpleStepper/SimpleStepperFooter.tsx
new file mode 100644
index 0000000000..a49e20913c
--- /dev/null
+++ b/packages/components/src/components/SimpleStepper/SimpleStepperFooter.tsx
@@ -0,0 +1,156 @@
+/*
+ * 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, { useContext, ReactNode, PropsWithChildren } from 'react';
+import { Button, makeStyles } from '@material-ui/core';
+import { StepActions } from './SimpleStepperStep';
+import { VerticalStepperContext } from './SimpleStepper';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ marginTop: theme.spacing(3),
+ '& button': {
+ marginRight: theme.spacing(1),
+ },
+ },
+}));
+
+interface CommonBtnProps {
+ text?: string;
+ handleClick?: () => void;
+ stepIndex: number;
+}
+interface RestartBtnProps extends CommonBtnProps {}
+
+interface NextBtnProps extends CommonBtnProps {
+ disabled?: boolean;
+ last?: boolean;
+ stepIndex: number;
+}
+interface BackBtnProps extends CommonBtnProps {
+ disabled?: boolean;
+ stepIndex: number;
+}
+export const RestartBtn = ({ text, handleClick }: RestartBtnProps) => (
+ {text || 'Reset'}
+);
+
+const NextBtn = ({
+ text,
+ handleClick,
+ disabled,
+ last,
+ stepIndex,
+}: NextBtnProps) => (
+
+ {text || (last ? 'Finish' : 'Next')}
+
+);
+
+const BackBtn = ({ text, handleClick, disabled, stepIndex }: BackBtnProps) => (
+
+ {text || 'Back'}
+
+);
+
+export type SimpleStepperFooterProps = {
+ actions?: StepActions;
+ children?: ReactNode;
+};
+
+export const SimpleStepperFooter = ({
+ actions = {},
+ children,
+}: PropsWithChildren) => {
+ const classes = useStyles();
+ const {
+ stepperLength,
+ stepIndex,
+ setStepIndex,
+ stepHistory,
+ setStepHistory,
+ onStepChange,
+ } = useContext(VerticalStepperContext);
+
+ const onChange = (newIndex: number, callback?: () => void) => {
+ if (callback) {
+ callback();
+ }
+ if (onStepChange) {
+ onStepChange(stepIndex, newIndex);
+ }
+
+ setStepIndex(newIndex);
+ };
+
+ const handleNext = () => {
+ const newIndex = actions.nextStep
+ ? actions.nextStep(stepIndex, stepperLength - 1)
+ : stepIndex + 1;
+ onChange(newIndex, actions.onNext);
+ setStepHistory([...stepHistory, newIndex]);
+ };
+ const handleBack = () => {
+ stepHistory.pop();
+ onChange(stepHistory[stepHistory.length - 1], actions.onBack);
+ setStepHistory([...stepHistory]);
+ };
+ const handleRestart = () => {
+ onChange(0, actions.onRestart);
+ setStepHistory([0]);
+ };
+
+ return (
+
+ {[undefined, true].includes(actions.showBack) && stepIndex !== 0 && (
+
+ )}
+ {[undefined, true].includes(actions.showNext) && (
+ = stepperLength) ||
+ (!!actions.canNext && !actions.canNext())
+ }
+ stepIndex={stepIndex}
+ />
+ )}
+ {actions.showRestart && stepIndex !== 0 && (
+
+ )}
+ {children}
+
+ );
+};
diff --git a/packages/components/src/components/SimpleStepper/SimpleStepperStep.tsx b/packages/components/src/components/SimpleStepper/SimpleStepperStep.tsx
new file mode 100644
index 0000000000..da467a5981
--- /dev/null
+++ b/packages/components/src/components/SimpleStepper/SimpleStepperStep.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 React, { PropsWithChildren } from 'react';
+import {
+ Step as MuiStep,
+ StepContent,
+ StepLabel,
+ Typography,
+ makeStyles,
+} from '@material-ui/core';
+import { SimpleStepperFooter } from './SimpleStepperFooter';
+
+const useStyles = makeStyles(theme => ({
+ end: {
+ padding: theme.spacing(3),
+ },
+}));
+
+export type StepActions = {
+ showNext?: boolean;
+ canNext?: () => boolean;
+ onNext?: () => void;
+ nextStep?: (current: number, last: number) => number;
+ nextText?: string;
+
+ showBack?: boolean;
+ backText?: string;
+ onBack?: () => void;
+
+ showRestart?: boolean;
+ canRestart?: () => boolean;
+ onRestart?: () => void;
+ restartText?: string;
+};
+
+export type StepProps = {
+ title: string;
+ children: React.ReactElement;
+ end?: boolean;
+ actions?: StepActions;
+};
+
+export const SimpleStepperStep = ({
+ title,
+ children,
+ end,
+ actions,
+ ...muiProps
+}: PropsWithChildren) => {
+ const classes = useStyles();
+
+ // The end step is not a part of the stepper
+ // It simply is the final screen with an option to have buttons such as reset or back
+ return end ? (
+
+ {title}
+ {children}
+
+
+ ) : (
+
+
+ {title}
+
+
+ {children}
+
+
+
+ );
+};
diff --git a/packages/components/src/components/SimpleStepper/index.ts b/packages/components/src/components/SimpleStepper/index.ts
new file mode 100644
index 0000000000..392a66affa
--- /dev/null
+++ b/packages/components/src/components/SimpleStepper/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 { SimpleStepper } from './SimpleStepper';
+export { SimpleStepperStep } from './SimpleStepperStep';
diff --git a/packages/components/src/components/Status/Status.stories.tsx b/packages/components/src/components/Status/Status.stories.tsx
new file mode 100644
index 0000000000..205645e1ec
--- /dev/null
+++ b/packages/components/src/components/Status/Status.stories.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ StatusError,
+ StatusAborted,
+ StatusOK,
+ StatusPending,
+ StatusRunning,
+ StatusWarning,
+} from './Status';
+import { Table } from '../Table';
+import { InfoCard } from '../../layout/InfoCard';
+
+export default {
+ title: 'Data Display/Status',
+ component: StatusOK,
+};
+
+const data = [
+ {
+ status: OK ,
+ label: 'OK',
+ usage: 'Deployment successful',
+ },
+ {
+ status: Warning ,
+ usage: 'CPU utilization at 90%',
+ },
+ {
+ status: Error ,
+ usage: 'Service could not be created',
+ },
+ {
+ status: Aborted ,
+ usage: 'Build for PR #34 aborted',
+ },
+ {
+ status: Pending ,
+ usage: 'Job is waiting',
+ },
+ {
+ status: Running ,
+ usage: 'Job is running',
+ },
+];
+
+const columns = [
+ { field: 'status', title: 'Status' },
+ { field: 'usage', title: 'Example usage' },
+];
+
+const containerStyle = { width: 600 };
+
+export const Default = () => (
+
+);
+
+export const statusOK = () => ;
+export const statusWarning = () => ;
+export const statusError = () => ;
+export const statusAborted = () => ;
+export const statusPending = () => ;
+export const statusRunning = () => ;
diff --git a/packages/components/src/components/Status/Status.test.tsx b/packages/components/src/components/Status/Status.test.tsx
new file mode 100644
index 0000000000..9a8daecd33
--- /dev/null
+++ b/packages/components/src/components/Status/Status.test.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+
+import {
+ StatusOK,
+ StatusWarning,
+ StatusError,
+ StatusPending,
+ StatusRunning,
+ StatusAborted,
+} from './Status';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByLabelText } = await renderInTestApp( );
+ expect(getByLabelText('Status ok')).toBeInTheDocument();
+ });
+});
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByLabelText } = await renderInTestApp( );
+ expect(getByLabelText('Status warning')).toBeInTheDocument();
+ });
+});
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByLabelText } = await renderInTestApp( );
+ expect(getByLabelText('Status error')).toBeInTheDocument();
+ });
+});
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByLabelText } = await renderInTestApp( );
+ expect(getByLabelText('Status pending')).toBeInTheDocument();
+ });
+});
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByLabelText } = await renderInTestApp( );
+ expect(getByLabelText('Status running')).toBeInTheDocument();
+ });
+});
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const { getByLabelText } = await renderInTestApp( );
+ expect(getByLabelText('Status aborted')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Status/Status.tsx b/packages/components/src/components/Status/Status.tsx
new file mode 100644
index 0000000000..f12f886b68
--- /dev/null
+++ b/packages/components/src/components/Status/Status.tsx
@@ -0,0 +1,130 @@
+/*
+ * 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 { makeStyles } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+import classNames from 'classnames';
+import React, { PropsWithChildren } from 'react';
+
+const useStyles = makeStyles(theme => ({
+ status: {
+ fontWeight: 500,
+ '&::before': {
+ width: '0.7em',
+ height: '0.7em',
+ display: 'inline-block',
+ marginRight: 8,
+ borderRadius: '50%',
+ content: '""',
+ },
+ },
+ ok: {
+ '&::before': {
+ backgroundColor: theme.palette.status.ok,
+ },
+ },
+ warning: {
+ '&::before': {
+ backgroundColor: theme.palette.status.warning,
+ },
+ },
+ error: {
+ '&::before': {
+ backgroundColor: theme.palette.status.error,
+ },
+ },
+ pending: {
+ '&::before': {
+ backgroundColor: theme.palette.status.pending,
+ },
+ },
+ running: {
+ '&::before': {
+ backgroundColor: theme.palette.status.running,
+ },
+ },
+ aborted: {
+ '&::before': {
+ backgroundColor: theme.palette.status.aborted,
+ },
+ },
+}));
+
+export const StatusOK = (props: PropsWithChildren<{}>) => {
+ const classes = useStyles(props);
+ return (
+
+ );
+};
+
+export const StatusWarning = (props: PropsWithChildren<{}>) => {
+ const classes = useStyles(props);
+ return (
+
+ );
+};
+
+export const StatusError = (props: PropsWithChildren<{}>) => {
+ const classes = useStyles(props);
+ return (
+
+ );
+};
+
+export const StatusPending = (props: PropsWithChildren<{}>) => {
+ const classes = useStyles(props);
+ return (
+
+ );
+};
+
+export const StatusRunning = (props: PropsWithChildren<{}>) => {
+ const classes = useStyles(props);
+ return (
+
+ );
+};
+
+export const StatusAborted = (props: PropsWithChildren<{}>) => {
+ const classes = useStyles(props);
+ return (
+
+ );
+};
diff --git a/packages/components/src/components/Status/index.ts b/packages/components/src/components/Status/index.ts
new file mode 100644
index 0000000000..4c0fd6322b
--- /dev/null
+++ b/packages/components/src/components/Status/index.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 {
+ StatusError,
+ StatusAborted,
+ StatusOK,
+ StatusPending,
+ StatusRunning,
+ StatusWarning,
+} from './Status';
diff --git a/packages/components/src/components/StructuredMetadataTable/MetadataTable.tsx b/packages/components/src/components/StructuredMetadataTable/MetadataTable.tsx
new file mode 100644
index 0000000000..b34e535a00
--- /dev/null
+++ b/packages/components/src/components/StructuredMetadataTable/MetadataTable.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 from 'react';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableRow,
+ withStyles,
+ createStyles,
+ WithStyles,
+ Theme,
+} from '@material-ui/core';
+
+const tableTitleCellStyles = (theme: Theme) =>
+ createStyles({
+ root: {
+ fontWeight: 'bolder',
+ whiteSpace: 'nowrap',
+ paddingRight: theme.spacing(4),
+ border: '0',
+ verticalAlign: 'top',
+ },
+ });
+
+const tableContentCellStyles = {
+ root: {
+ border: '0',
+ verticalAlign: 'top',
+ },
+};
+
+const listStyles = (theme: Theme) =>
+ createStyles({
+ root: {
+ listStyle: 'none',
+ margin: theme.spacing(0, 0, -1, 0),
+ padding: '0',
+ },
+ });
+
+const listItemStyles = (theme: Theme) =>
+ createStyles({
+ root: {
+ padding: theme.spacing(0, 0, 1, 0),
+ },
+ random: {},
+ });
+
+const TitleCell = withStyles(tableTitleCellStyles)(TableCell);
+const ContentCell = withStyles(tableContentCellStyles)(TableCell);
+
+export const MetadataTable = ({
+ dense,
+ children,
+}: {
+ dense?: boolean;
+ children: React.ReactNode;
+}) => (
+
+);
+
+export const MetadataTableItem = ({
+ title,
+ children,
+ ...rest
+}: {
+ title: string;
+ children: React.ReactNode;
+}) => (
+
+ {title && {title} }
+
+ {children}
+
+
+);
+
+interface StyleProps extends WithStyles {
+ children?: React.ReactNode;
+}
+
+export const MetadataList = withStyles(
+ listStyles,
+)(({ classes, children }: StyleProps) => (
+
+));
+
+export const MetadataListItem = withStyles(
+ listItemStyles,
+)(({ classes, children }: StyleProps) => (
+ {children}
+));
diff --git a/packages/components/src/components/StructuredMetadataTable/README.md b/packages/components/src/components/StructuredMetadataTable/README.md
new file mode 100644
index 0000000000..60952e5e34
--- /dev/null
+++ b/packages/components/src/components/StructuredMetadataTable/README.md
@@ -0,0 +1,62 @@
+# Structured MetadataTable
+
+The `Strucuted MetadataTable` staple is a staple component for displaying basic JSON metadata.
+
+# API
+
+There is a very lightweight API around this component
+
+| property | value |
+| :------- | :---------: |
+| metadata | object/JSON |
+| dense | bool |
+
+## Metadata
+
+The Metadata property takes in JSON and iterates over it to display the tabled information.
+
+The component itself only handles the display area, so you can use standard JS to construct an object that fits your desired outcome. No need to configure deeper within the staple.
+
+```
+
+```
+
+This will step through each of the keys and based on their types display them in a logical way.
+
+### Primitives
+
+Any non complex value will be displayed using `{value}` which will just output the value as text.
+
+### Objects/Maps
+
+JSON / Maps are displayed in a `` with its values as formatted key/value pairs.
+
+### Arrays
+
+Arrays are displayed similarly to objects, its values in a ``.
+
+### Custom
+
+If you want to customize the rendering of your value you can just replace it with a React Element.
+
+```
+{
+ contact: me@email.com
+}
+```
+
+Would display as contact me@email.com
+
+but if you wanted this to be a mailto you could inject that react into your map:
+
+```
+{
+ contact: me@email.com
+}
+```
+
+Then it would be displayed using the react element.
+
+# Usage
+
+For best usage drop this component inside another card. It can be used similarly to the `` and exposes the `dense` for when that is necessary.
diff --git a/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx b/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.tsx
new file mode 100644
index 0000000000..7f07018f6f
--- /dev/null
+++ b/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.stories.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 React, { PropsWithChildren } from 'react';
+import { InfoCard } from '../../layout/InfoCard';
+import { Grid } from '@material-ui/core';
+import { StructuredMetadataTable } from './StructuredMetadataTable';
+
+const cardContentStyle = { heightX: 200, width: 500 };
+
+const metadata = {
+ description:
+ 'This is a long description of what this is doing (and some additional info too). \n It has new lines and extra text to make it especially annoying to render. But it just ignores them.',
+ something: 'Yes',
+ 'true value': true,
+ 'false value': false,
+ owner: 'squad',
+ 'longer key name': ['v1', 'v2', 'v3'],
+ rules: {
+ 'permit missing partitions': 'No',
+ 'max partition finish time': '19 hours',
+ Support: {
+ 'office hours': 'Contact goalie',
+ 'after hours': 'trigger PD alert',
+ },
+ },
+};
+
+export default {
+ title: 'Data Display/Structured Metadata Table',
+ component: StructuredMetadataTable,
+};
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+ {children}
+
+);
+
+export const Default = () => (
+
+
+
+
+
+
+
+);
+
+export const NotDenseTable = () => (
+
+
+
+
+
+
+
+);
diff --git a/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx b/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.test.tsx
new file mode 100644
index 0000000000..0844710876
--- /dev/null
+++ b/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.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 { render, within } from '@testing-library/react';
+import { startCase } from 'lodash';
+import React from 'react';
+import { StructuredMetadataTable } from './StructuredMetadataTable';
+
+describe(' ', () => {
+ it('renders without exploding', () => {
+ const metadata = { hello: 'world' };
+ const { getByText } = render(
+ ,
+ );
+ expect(getByText(metadata.hello)).toBeInTheDocument();
+ });
+
+ describe('Item Mappings', () => {
+ it('Iterates over and displays every field in the map', () => {
+ const metadata = {
+ field1: 'one',
+ field2: 'two',
+ field3: 'three',
+ } as const;
+ const { getByText } = render(
+ ,
+ );
+ for (const [key, value] of Object.entries(metadata)) {
+ expect(getByText(startCase(key))).toBeInTheDocument();
+ expect(getByText(value)).toBeInTheDocument();
+ }
+ });
+
+ it('Supports primitive value fields', () => {
+ const metadata = { strField: 'my field', intField: 1 } as const;
+ const { getByText } = render(
+ ,
+ );
+
+ for (const [key, value] of Object.entries(metadata)) {
+ expect(getByText(startCase(key))).toBeInTheDocument();
+ expect(getByText(value.toString())).toBeInTheDocument();
+ }
+ });
+
+ it('Supports array fields', () => {
+ const metadata = { arrayField: ['arrVal1', 'arrVal2'] } as const;
+ const { getByText } = render(
+ ,
+ );
+ const keys = Object.keys(metadata);
+ keys.forEach(value => {
+ expect(getByText(startCase(value))).toBeInTheDocument();
+ });
+ metadata.arrayField.forEach(value => {
+ expect(getByText(value)).toBeInTheDocument();
+ });
+ });
+
+ it('Supports boolean values', () => {
+ const metadata = { foo: true, bar: false };
+ const expectedValues = [
+ ['Foo', '✅'],
+ ['Bar', '❌'],
+ ];
+
+ const { getAllByRole } = render(
+ ,
+ );
+
+ getAllByRole('row').forEach((row, index) => {
+ const [firstCell, secondCell] = within(row).getAllByRole('cell');
+ const [expectedKey, expectedValue] = expectedValues[index];
+
+ expect(firstCell).toHaveTextContent(expectedKey);
+ expect(secondCell).toHaveTextContent(expectedValue);
+ });
+ });
+
+ it('Supports react elements', () => {
+ const metadata = { react: field
};
+ const { getByText } = render(
+ ,
+ );
+
+ expect(getByText('field')).toBeInTheDocument();
+ });
+
+ it('Supports object elements', () => {
+ const metadata = {
+ config: { a: 1, b: 2 },
+ } as const;
+ const { getByText } = render(
+ ,
+ );
+
+ for (const [key, value] of Object.entries(metadata.config)) {
+ expect(getByText(startCase(key), { exact: false })).toBeInTheDocument();
+ expect(
+ getByText(value.toString(), { exact: false }),
+ ).toBeInTheDocument();
+ }
+ });
+ });
+});
diff --git a/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx b/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx
new file mode 100644
index 0000000000..916259042c
--- /dev/null
+++ b/packages/components/src/components/StructuredMetadataTable/StructuredMetadataTable.tsx
@@ -0,0 +1,163 @@
+/*
+ * 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, { Fragment, ReactElement } from 'react';
+import { withStyles, createStyles, WithStyles, Theme } from '@material-ui/core';
+import startCase from 'lodash/startCase';
+
+import {
+ MetadataTable,
+ MetadataTableItem,
+ MetadataList,
+ MetadataListItem,
+} from './MetadataTable';
+
+const listStyle = createStyles({
+ root: {
+ margin: '0 0',
+ listStyleType: 'none',
+ },
+});
+
+const nestedListStyle = (theme: Theme) =>
+ createStyles({
+ root: {
+ ...listStyle.root,
+ paddingLeft: theme.spacing(1),
+ },
+ });
+
+interface StyleProps extends WithStyles {
+ children?: React.ReactNode;
+}
+// Sub Components
+const StyledList = withStyles(
+ listStyle,
+)(({ classes, children }: StyleProps) => (
+ {children}
+));
+const StyledNestedList = withStyles(
+ nestedListStyle,
+)(({ classes, children }: StyleProps) => (
+ {children}
+));
+
+function renderList(list: Array, nested?: boolean) {
+ const values = list.map((item: any, index: number) => (
+ {toValue(item)}
+ ));
+ return nested ? (
+ {values}
+ ) : (
+ {values}
+ );
+}
+
+function renderMap(
+ map: { [key: string]: any },
+ nested?: boolean,
+ options?: any,
+) {
+ const values = Object.keys(map).map(key => {
+ const value = toValue(map[key], true);
+ const fmtKey =
+ options && options.titleFormat
+ ? options.titleFormat(key)
+ : startCase(key);
+ return (
+
+ {`${fmtKey}: `}
+ {value}
+
+ );
+ });
+
+ return nested ? (
+ {values}
+ ) : (
+ {values}
+ );
+}
+
+function toValue(
+ value: ReactElement | object | Array | boolean,
+ options?: any,
+ nested?: boolean,
+) {
+ if (React.isValidElement(value)) {
+ return {value} ;
+ }
+
+ if (typeof value === 'object' && !Array.isArray(value)) {
+ return renderMap(value, options, nested);
+ }
+
+ if (Array.isArray(value)) {
+ return renderList(value, nested);
+ }
+
+ if (typeof value === 'boolean') {
+ return {value ? '✅' : '❌'} ;
+ }
+
+ return {value} ;
+}
+const ItemValue = ({ value, options }: { value: any; options: any }) => (
+ {toValue(value, options)}
+);
+
+const TableItem = ({
+ title,
+ value,
+ options,
+}: {
+ title: string;
+ value: any;
+ options: any;
+}) => {
+ return (
+
+
+
+ );
+};
+
+function mapToItems(info: { [key: string]: string }, options: any) {
+ return Object.keys(info).map(key => (
+
+ ));
+}
+
+type Props = {
+ metadata: { [key: string]: any };
+ dense?: boolean;
+ options?: any;
+};
+
+export const StructuredMetadataTable = ({
+ metadata,
+ dense = true,
+ options,
+}: Props) => {
+ const metadataItems = mapToItems(metadata, options || {});
+ return {metadataItems} ;
+};
diff --git a/packages/components/src/components/StructuredMetadataTable/index.tsx b/packages/components/src/components/StructuredMetadataTable/index.tsx
new file mode 100644
index 0000000000..628f3395db
--- /dev/null
+++ b/packages/components/src/components/StructuredMetadataTable/index.tsx
@@ -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 { StructuredMetadataTable } from './StructuredMetadataTable';
diff --git a/packages/components/src/components/SupportButton/SupportButton.test.tsx b/packages/components/src/components/SupportButton/SupportButton.test.tsx
new file mode 100644
index 0000000000..6d6b9fd477
--- /dev/null
+++ b/packages/components/src/components/SupportButton/SupportButton.test.tsx
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ render,
+ act,
+ RenderResult,
+ waitFor,
+ fireEvent,
+} from '@testing-library/react';
+import { wrapInTestApp } from '@backstage/test-utils';
+import { SupportButton } from './SupportButton';
+
+const SUPPORT_BUTTON_ID = 'support-button';
+const POPOVER_ID = 'support-button-popover';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ let renderResult: RenderResult;
+
+ await act(async () => {
+ renderResult = render(wrapInTestApp( ));
+ });
+
+ await waitFor(() =>
+ expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument(),
+ );
+ });
+
+ it('shows popover on click', async () => {
+ let renderResult: RenderResult;
+
+ await act(async () => {
+ renderResult = render(wrapInTestApp( ));
+ });
+
+ let button: HTMLElement;
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId(SUPPORT_BUTTON_ID)).toBeInTheDocument();
+ button = renderResult.getByTestId(SUPPORT_BUTTON_ID);
+ });
+
+ await act(async () => {
+ fireEvent.click(button);
+ });
+
+ await waitFor(() => {
+ expect(renderResult.getByTestId(POPOVER_ID)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/components/src/components/SupportButton/SupportButton.tsx b/packages/components/src/components/SupportButton/SupportButton.tsx
new file mode 100644
index 0000000000..eea2b000ca
--- /dev/null
+++ b/packages/components/src/components/SupportButton/SupportButton.tsx
@@ -0,0 +1,133 @@
+/*
+ * 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 { HelpIcon, useApp } from '@backstage/core-api';
+import {
+ Button,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ makeStyles,
+ Popover,
+} from '@material-ui/core';
+import React, {
+ Fragment,
+ MouseEventHandler,
+ PropsWithChildren,
+ useState,
+} from 'react';
+import { SupportItem, SupportItemLink, useSupportConfig } from '../../hooks';
+import { Link } from '../Link';
+
+type Props = {};
+
+const useStyles = makeStyles(theme => ({
+ leftIcon: {
+ marginRight: theme.spacing(1),
+ },
+ popoverList: {
+ minWidth: 260,
+ maxWidth: 400,
+ },
+}));
+
+const SupportIcon = ({ icon }: { icon: string | undefined }) => {
+ const app = useApp();
+ const Icon = icon ? app.getSystemIcon(icon) ?? HelpIcon : HelpIcon;
+ return ;
+};
+
+const SupportLink = ({ link }: { link: SupportItemLink }) => (
+
+ {link.title ?? link.url}
+
+);
+
+const SupportListItem = ({ item }: { item: SupportItem }) => {
+ return (
+
+
+
+
+ (
+ (prev, link, idx) => [
+ ...prev,
+ idx > 0 && ,
+ ,
+ ],
+ [],
+ )}
+ />
+
+ );
+};
+
+export const SupportButton = ({ children }: PropsWithChildren) => {
+ const { items } = useSupportConfig();
+
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [anchorEl, setAnchorEl] = useState(null);
+ const classes = useStyles();
+
+ const onClickHandler: MouseEventHandler = event => {
+ setAnchorEl(event.currentTarget);
+ setPopoverOpen(true);
+ };
+
+ const popoverCloseHandler = () => {
+ setPopoverOpen(false);
+ };
+
+ return (
+
+
+
+ Support
+
+
+
+ {React.Children.map(children, (child, i) => (
+
+ {child}
+
+ ))}
+ {items &&
+ items.map((item, i) => )}
+
+
+
+ );
+};
diff --git a/packages/components/src/components/SupportButton/index.ts b/packages/components/src/components/SupportButton/index.ts
new file mode 100644
index 0000000000..e133900d0a
--- /dev/null
+++ b/packages/components/src/components/SupportButton/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 { SupportButton } from './SupportButton';
diff --git a/packages/components/src/components/TabbedLayout/RoutedTabs.test.tsx b/packages/components/src/components/TabbedLayout/RoutedTabs.test.tsx
new file mode 100644
index 0000000000..549aa55eff
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/RoutedTabs.test.tsx
@@ -0,0 +1,150 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route, Routes } from 'react-router';
+import { RoutedTabs } from './RoutedTabs';
+
+const testRoute1 = {
+ path: '',
+ title: 'tabbed-test-title',
+ children: tabbed-test-content
,
+};
+const testRoute2 = {
+ title: 'tabbed-test-title-2',
+ path: '/some-other-path',
+ children: tabbed-test-content-2
,
+};
+
+describe('RoutedTabs', () => {
+ it('renders simplest case', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
+ });
+
+ it('navigates when user clicks different tab', async () => {
+ const rendered = await renderInTestApp(
+
+ }
+ />
+ ,
+ );
+
+ const secondTab = rendered.queryAllByRole('tab')[1];
+ act(() => {
+ fireEvent.click(secondTab);
+ });
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
+
+ expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ });
+
+ describe('correctly delegates nested links', () => {
+ const renderRoute = (route: string) =>
+ renderInTestApp(
+
+
+ tabbed-test-content-2
+
+ tabbed-test-nested-content-2}
+ />
+
+
+ ),
+ },
+ ]}
+ />
+ }
+ />
+ ,
+ { routeEntries: [route] },
+ );
+
+ it('works for nested content', async () => {
+ const rendered = await renderRoute('/some-other-path/nested');
+
+ expect(
+ rendered.queryByText('tabbed-test-content'),
+ ).not.toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ expect(
+ rendered.queryByText('tabbed-test-nested-content-2'),
+ ).toBeInTheDocument();
+ });
+
+ it('works for non-nested content', async () => {
+ const rendered = await renderRoute('/some-other-path/');
+
+ expect(
+ rendered.queryByText('tabbed-test-content'),
+ ).not.toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ expect(
+ rendered.queryByText('tabbed-test-nested-content-2'),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows only one tab contents at a time', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ { routeEntries: ['/some-other-path'] },
+ );
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
+
+ expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
+ expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ });
+
+ it('redirects to the top level when no route is matching the url', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ { routeEntries: ['/non-existing-path'] },
+ );
+
+ expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
+ expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
+
+ expect(
+ rendered.queryByText('tabbed-test-content-2'),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/TabbedLayout/RoutedTabs.tsx b/packages/components/src/components/TabbedLayout/RoutedTabs.tsx
new file mode 100644
index 0000000000..b17c8afb3b
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/RoutedTabs.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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, { useMemo } from 'react';
+import { Helmet } from 'react-helmet';
+import { matchRoutes, useNavigate, useParams, useRoutes } from 'react-router';
+import { Content, HeaderTabs } from '../../layout';
+import { SubRoute } from './types';
+
+export function useSelectedSubRoute(
+ subRoutes: SubRoute[],
+): { index: number; route: SubRoute; element: JSX.Element } {
+ const params = useParams();
+
+ const routes = subRoutes.map(({ path, children }) => ({
+ caseSensitive: false,
+ path: `${path}/*`,
+ element: children,
+ }));
+
+ const element = useRoutes(routes) ?? subRoutes[0].children;
+
+ const [matchedRoute] = matchRoutes(routes, `/${params['*']}`) ?? [];
+ const foundIndex = matchedRoute
+ ? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path)
+ : 0;
+
+ return {
+ index: foundIndex === -1 ? 0 : foundIndex,
+ element,
+ route: subRoutes[foundIndex] ?? subRoutes[0],
+ };
+}
+
+export const RoutedTabs = ({ routes }: { routes: SubRoute[] }) => {
+ const navigate = useNavigate();
+ const { index, route, element } = useSelectedSubRoute(routes);
+ const headerTabs = useMemo(
+ () => routes.map(t => ({ id: t.path, label: t.title })),
+ [routes],
+ );
+
+ const onTabChange = (tabIndex: number) =>
+ // Remove trailing /*
+ // And remove leading / for relative navigation
+ // Note! route resolves relative to the position in the React tree,
+ // not relative to current location
+ navigate(routes[tabIndex].path.replace(/\/\*$/, '').replace(/^\//, ''));
+
+ return (
+ <>
+
+
+
+ {element}
+
+ >
+ );
+};
diff --git a/packages/components/src/components/TabbedLayout/TabbedLayout.stories.tsx b/packages/components/src/components/TabbedLayout/TabbedLayout.stories.tsx
new file mode 100644
index 0000000000..bf6175a9a2
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/TabbedLayout.stories.tsx
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { PropsWithChildren } from 'react';
+import { MemoryRouter, Route, Routes } from 'react-router';
+import { TabbedLayout } from './TabbedLayout';
+
+export default {
+ title: 'Navigation/TabbedLayout',
+ component: TabbedLayout,
+};
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+
+ {children}>} />
+
+
+);
+
+export const Default = () => (
+
+
+
+ tabbed-test-content
+
+
+ tabbed-test-content-2
+
+
+
+);
diff --git a/packages/components/src/components/TabbedLayout/TabbedLayout.test.tsx b/packages/components/src/components/TabbedLayout/TabbedLayout.test.tsx
new file mode 100644
index 0000000000..77230ab6cd
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/TabbedLayout.test.tsx
@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { act } from 'react-dom/test-utils';
+import { Route, Routes } from 'react-router';
+import { TabbedLayout } from './TabbedLayout';
+
+describe('TabbedLayout', () => {
+ it('renders simplest case', async () => {
+ const { getByText } = await renderInTestApp(
+
+
+ tabbed-test-content
+
+ ,
+ );
+
+ expect(getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(getByText('tabbed-test-content')).toBeInTheDocument();
+ });
+
+ it('throws if any other component is a child of TabbedLayout', async () => {
+ const { error } = await withLogCollector(async () => {
+ await expect(
+ renderInTestApp(
+
+
+ tabbed-test-content
+
+ This will cause app to throw
+ ,
+ ),
+ ).rejects.toThrow(/Child of TabbedLayout must be an TabbedLayout.Route/);
+ });
+
+ expect(error).toEqual([
+ expect.stringMatching(
+ /Child of TabbedLayout must be an TabbedLayout.Route/,
+ ),
+ expect.stringMatching(
+ /The above error occurred in the component/,
+ ),
+ ]);
+ });
+
+ it('navigates when user clicks different tab', async () => {
+ const { getByText, queryByText, queryAllByRole } = await renderInTestApp(
+
+
+
+ tabbed-test-content
+
+
+ tabbed-test-content-2
+
+
+ }
+ />
+ ,
+ );
+
+ const secondTab = queryAllByRole('tab')[1];
+ act(() => {
+ fireEvent.click(secondTab);
+ });
+
+ expect(getByText('tabbed-test-title')).toBeInTheDocument();
+ expect(queryByText('tabbed-test-content')).not.toBeInTheDocument();
+
+ expect(getByText('tabbed-test-title-2')).toBeInTheDocument();
+ expect(queryByText('tabbed-test-content-2')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/TabbedLayout/TabbedLayout.tsx b/packages/components/src/components/TabbedLayout/TabbedLayout.tsx
new file mode 100644
index 0000000000..f181ee5980
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/TabbedLayout.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 { attachComponentData } from '@backstage/core-api';
+import React, {
+ Children,
+ Fragment,
+ isValidElement,
+ PropsWithChildren,
+ ReactNode,
+} from 'react';
+import { RoutedTabs } from './RoutedTabs';
+
+type SubRoute = {
+ path: string;
+ title: string;
+ children: JSX.Element;
+};
+
+const Route: (props: SubRoute) => null = () => null;
+
+// This causes all mount points that are discovered within this route to use the path of the route itself
+attachComponentData(Route, 'core.gatherMountPoints', true);
+
+export function createSubRoutesFromChildren(
+ childrenProps: ReactNode,
+): SubRoute[] {
+ // Directly comparing child.type with Route will not work with in
+ // combination with react-hot-loader in storybook
+ // https://github.com/gaearon/react-hot-loader/issues/304
+ const routeType = (
+
+
+
+ ).type;
+
+ return Children.toArray(childrenProps).flatMap(child => {
+ if (!isValidElement(child)) {
+ return [];
+ }
+
+ if (child.type === Fragment) {
+ return createSubRoutesFromChildren(child.props.children);
+ }
+
+ if (child.type !== routeType) {
+ throw new Error('Child of TabbedLayout must be an TabbedLayout.Route');
+ }
+
+ const { path, title, children } = child.props;
+ return [{ path, title, children }];
+ });
+}
+
+/**
+ * TabbedLayout is a compound component, which allows you to define a layout for
+ * pages using a sub-navigation mechanism.
+ *
+ * Consists of two parts: TabbedLayout and TabbedLayout.Route
+ *
+ * @example
+ * ```jsx
+ *
+ *
+ * This is rendered under /example/anything-here route
+ *
+ *
+ * ```
+ */
+export const TabbedLayout = ({ children }: PropsWithChildren<{}>) => {
+ const routes = createSubRoutesFromChildren(children);
+
+ return ;
+};
+
+TabbedLayout.Route = Route;
diff --git a/packages/components/src/components/TabbedLayout/index.ts b/packages/components/src/components/TabbedLayout/index.ts
new file mode 100644
index 0000000000..744b56959e
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { TabbedLayout } from './TabbedLayout';
diff --git a/packages/components/src/components/TabbedLayout/types.ts b/packages/components/src/components/TabbedLayout/types.ts
new file mode 100644
index 0000000000..29ade88dc0
--- /dev/null
+++ b/packages/components/src/components/TabbedLayout/types.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 SubRoute = {
+ path: string;
+ title: string;
+ children: JSX.Element;
+};
diff --git a/packages/components/src/components/Table/Filters.tsx b/packages/components/src/components/Table/Filters.tsx
new file mode 100644
index 0000000000..138fec4736
--- /dev/null
+++ b/packages/components/src/components/Table/Filters.tsx
@@ -0,0 +1,157 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import { BackstageTheme } from '@backstage/theme';
+import { Button, makeStyles } from '@material-ui/core';
+import { Select } from '../Select';
+import { CheckboxTree } from '../CheckboxTree';
+import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
+import { SelectProps } from '../Select/Select';
+
+const useSubvalueCellStyles = makeStyles(theme => ({
+ root: {
+ height: '100%',
+ width: '315px',
+ display: 'flex',
+ flexDirection: 'column',
+ marginRight: theme.spacing(3),
+ },
+ value: {
+ fontWeight: 'bold',
+ fontSize: 18,
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'center',
+ height: '60px',
+ justifyContent: 'space-between',
+ borderBottom: `1px solid ${theme.palette.grey[500]}`,
+ },
+ filters: {
+ display: 'flex',
+ flexDirection: 'column',
+ '& > *': {
+ marginTop: theme.spacing(2),
+ },
+ },
+}));
+
+export type Without = Pick>;
+
+export type Filter = {
+ type: 'select' | 'checkbox-tree' | 'multiple-select';
+ element:
+ | Without
+ | Without;
+};
+
+export type SelectedFilters = {
+ [key: string]: string | string[];
+};
+
+type Props = {
+ filters: Filter[];
+ selectedFilters?: SelectedFilters;
+ onChangeFilters: (arg: any) => any;
+};
+
+export const Filters = (props: Props) => {
+ const classes = useSubvalueCellStyles();
+
+ const { onChangeFilters } = props;
+
+ const [selectedFilters, setSelectedFilters] = useState({
+ ...props.selectedFilters,
+ });
+ const [reset, triggerReset] = useState(false);
+
+ // Trigger re-rendering
+ const handleClick = () => {
+ setSelectedFilters({});
+ triggerReset(el => !el);
+ };
+
+ useEffect(() => {
+ onChangeFilters(selectedFilters);
+ }, [selectedFilters, onChangeFilters]);
+
+ // As material table doesn't provide a way to add a column filter tab we will make our own filter logic
+ return (
+
+
+
Filters
+
+ Clear all
+
+
+
+ {props.filters?.length &&
+ props.filters.map(filter =>
+ filter.type === 'checkbox-tree' ? (
+ ({
+ category: s,
+ }),
+ )
+ : undefined
+ }
+ onChange={el =>
+ setSelectedFilters({
+ ...selectedFilters,
+ [filter.element.label]: el
+ .filter(
+ (checkboxFilter: any) =>
+ checkboxFilter.category ||
+ checkboxFilter.selectedChildren.length,
+ )
+ .map((checkboxFilter: any) =>
+ checkboxFilter.category
+ ? [
+ ...checkboxFilter.selectedChildren,
+ checkboxFilter.category,
+ ]
+ : checkboxFilter.selectedChildren,
+ )
+ .flat(),
+ })
+ }
+ />
+ ) : (
+
+ setSelectedFilters({
+ ...selectedFilters,
+ [filter.element.label]: el as any,
+ })
+ }
+ />
+ ),
+ )}
+
+
+ );
+};
diff --git a/packages/components/src/components/Table/SubvalueCell.tsx b/packages/components/src/components/Table/SubvalueCell.tsx
new file mode 100644
index 0000000000..944195a2db
--- /dev/null
+++ b/packages/components/src/components/Table/SubvalueCell.tsx
@@ -0,0 +1,45 @@
+/*
+ * 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 { BackstageTheme } from '@backstage/theme';
+import { makeStyles } from '@material-ui/core';
+
+const useSubvalueCellStyles = makeStyles(theme => ({
+ value: {
+ marginBottom: '6px',
+ },
+ subvalue: {
+ color: theme.palette.textSubtle,
+ fontWeight: 'normal',
+ },
+}));
+
+type SubvalueCellProps = {
+ value: React.ReactNode;
+ subvalue: React.ReactNode;
+};
+
+export const SubvalueCell = ({ value, subvalue }: SubvalueCellProps) => {
+ const classes = useSubvalueCellStyles();
+
+ return (
+ <>
+ {value}
+ {subvalue}
+ >
+ );
+};
diff --git a/packages/components/src/components/Table/Table.stories.tsx b/packages/components/src/components/Table/Table.stories.tsx
new file mode 100644
index 0000000000..64ceb761f8
--- /dev/null
+++ b/packages/components/src/components/Table/Table.stories.tsx
@@ -0,0 +1,332 @@
+/*
+ * 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 { makeStyles } from '@material-ui/core';
+import React from 'react';
+import { Link } from '../Link';
+import { SubvalueCell, Table, TableColumn } from './';
+import { TableFilter } from './Table';
+
+export default {
+ title: 'Data Display/Table',
+ component: Table,
+};
+
+const useStyles = makeStyles(theme => ({
+ container: {
+ width: 850,
+ },
+ empty: {
+ padding: theme.spacing(2),
+ display: 'flex',
+ justifyContent: 'center',
+ },
+}));
+
+const generateTestData: (number: number) => Array<{}> = (rows = 10) => {
+ const data: Array<{}> = [];
+ while (data.length <= rows) {
+ data.push({
+ col1: `Some value ${data.length}`,
+ col2: `More data ${data.length}`,
+ subvalue: `Subvalue ${data.length}`,
+ number: Math.round(Math.abs(Math.sin(data.length)) * 1000),
+ date: new Date(Math.abs(Math.sin(data.length)) * 10000000000000),
+ });
+ }
+
+ return data;
+};
+
+const testData10 = generateTestData(10);
+
+export const DefaultTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export const EmptyTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ return (
+
+
+ No data was added yet,
+ learn how to add data.
+
+ }
+ title="Backstage Table"
+ />
+
+ );
+};
+
+export const SubtitleTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export const HiddenSearchTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export const SubvalueTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ customFilterAndSearch: (
+ query,
+ row: any, // Only needed if you want subvalue searchable
+ ) =>
+ `${row.col1} ${row.subvalue}`
+ .toUpperCase()
+ .includes(query.toUpperCase()),
+ field: 'col1',
+ highlight: true,
+ render: (row: any): React.ReactNode => (
+
+ ),
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export const DenseTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ return (
+
+ );
+};
+
+export const FilterTable = () => {
+ const classes = useStyles();
+ const columns: TableColumn[] = [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ highlight: true,
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ {
+ title: 'Numeric value',
+ field: 'number',
+ type: 'numeric',
+ },
+ {
+ title: 'A Date',
+ field: 'date',
+ type: 'date',
+ },
+ ];
+
+ const filters: TableFilter[] = [
+ {
+ column: 'Column 1',
+ type: 'select',
+ },
+ {
+ column: 'Column 2',
+ type: 'multiple-select',
+ },
+ {
+ column: 'Numeric value',
+ type: 'checkbox-tree',
+ },
+ ];
+
+ return (
+
+ );
+};
diff --git a/packages/components/src/components/Table/Table.test.tsx b/packages/components/src/components/Table/Table.test.tsx
new file mode 100644
index 0000000000..3ba1c12ebd
--- /dev/null
+++ b/packages/components/src/components/Table/Table.test.tsx
@@ -0,0 +1,68 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { Table } from './Table';
+
+const minProps = {
+ columns: [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ ],
+ data: [
+ {
+ col1: 'first value, first row',
+ col2: 'second value, first row',
+ },
+ {
+ col1: 'first value, second row',
+ col2: 'second value, second row',
+ },
+ ],
+};
+
+describe('', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp();
+ expect(rendered.getByText('second value, second row')).toBeInTheDocument();
+ });
+
+ it('renders with subtitle', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('subtitle')).toBeInTheDocument();
+ });
+
+ it('renders custom empty component if empty', async () => {
+ const rendered = await renderInTestApp(
+ EMPTY}
+ columns={minProps.columns}
+ data={[]}
+ />,
+ );
+ expect(rendered.getByText('EMPTY')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Table/Table.tsx b/packages/components/src/components/Table/Table.tsx
new file mode 100644
index 0000000000..3892d82bb3
--- /dev/null
+++ b/packages/components/src/components/Table/Table.tsx
@@ -0,0 +1,483 @@
+/*
+ * 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 { BackstageTheme } from '@backstage/theme';
+import {
+ IconButton,
+ makeStyles,
+ Typography,
+ useTheme,
+} from '@material-ui/core';
+// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
+import AddBox from '@material-ui/icons/AddBox';
+import ArrowUpward from '@material-ui/icons/ArrowUpward';
+import Check from '@material-ui/icons/Check';
+import ChevronLeft from '@material-ui/icons/ChevronLeft';
+import ChevronRight from '@material-ui/icons/ChevronRight';
+import Clear from '@material-ui/icons/Clear';
+import DeleteOutline from '@material-ui/icons/DeleteOutline';
+import Edit from '@material-ui/icons/Edit';
+import FilterList from '@material-ui/icons/FilterList';
+import FirstPage from '@material-ui/icons/FirstPage';
+import LastPage from '@material-ui/icons/LastPage';
+import Remove from '@material-ui/icons/Remove';
+import SaveAlt from '@material-ui/icons/SaveAlt';
+import Search from '@material-ui/icons/Search';
+import ViewColumn from '@material-ui/icons/ViewColumn';
+import { isEqual, transform } from 'lodash';
+import MTable, {
+ Column,
+ Icons,
+ MaterialTableProps,
+ MTableBody,
+ MTableHeader,
+ MTableToolbar,
+ Options,
+} from 'material-table';
+import React, {
+ forwardRef,
+ ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react';
+import { CheckboxTreeProps } from '../CheckboxTree/CheckboxTree';
+import { SelectProps } from '../Select/Select';
+import { Filter, Filters, SelectedFilters, Without } from './Filters';
+
+const tableIcons: Icons = {
+ Add: forwardRef((props, ref) => ),
+ Check: forwardRef((props, ref) => ),
+ Clear: forwardRef((props, ref) => ),
+ Delete: forwardRef((props, ref) => ),
+ DetailPanel: forwardRef((props, ref) => (
+
+ )),
+ Edit: forwardRef((props, ref) => ),
+ Export: forwardRef((props, ref) => ),
+ Filter: forwardRef((props, ref) => ),
+ FirstPage: forwardRef((props, ref) => ),
+ LastPage: forwardRef((props, ref) => ),
+ NextPage: forwardRef((props, ref) => ),
+ PreviousPage: forwardRef((props, ref) => (
+
+ )),
+ ResetSearch: forwardRef((props, ref) => ),
+ Search: forwardRef((props, ref) => ),
+ SortArrow: forwardRef((props, ref) => ),
+ ThirdStateCheck: forwardRef((props, ref) => ),
+ ViewColumn: forwardRef((props, ref) => ),
+};
+
+// TODO: Material table might already have such a function internally that we can use?
+function extractValueByField(data: any, field: string): any | undefined {
+ const path = field.split('.');
+ let value = data[path[0]];
+
+ for (let i = 1; i < path.length; ++i) {
+ if (value === undefined) {
+ return value;
+ }
+
+ const f = path[i];
+ value = value[f];
+ }
+
+ return value;
+}
+
+const useHeaderStyles = makeStyles(theme => ({
+ header: {
+ padding: theme.spacing(1, 2, 1, 2.5),
+ borderTop: `1px solid ${theme.palette.grey.A100}`,
+ borderBottom: `1px solid ${theme.palette.grey.A100}`,
+ color: theme.palette.textSubtle,
+ fontWeight: theme.typography.fontWeightBold,
+ position: 'static',
+ wordBreak: 'normal',
+ },
+}));
+
+const useToolbarStyles = makeStyles(theme => ({
+ root: {
+ padding: theme.spacing(3, 0, 2.5, 2.5),
+ },
+ title: {
+ '& > h6': {
+ fontWeight: 'bold',
+ },
+ },
+ searchField: {
+ paddingRight: theme.spacing(2),
+ },
+}));
+
+const useFilterStyles = makeStyles(() => ({
+ root: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ title: {
+ fontWeight: 'bold',
+ fontSize: 18,
+ whiteSpace: 'nowrap',
+ },
+}));
+
+const useTableStyles = makeStyles(() => ({
+ root: {
+ display: 'flex',
+ alignItems: 'start',
+ },
+}));
+
+function convertColumns(
+ columns: TableColumn[],
+ theme: BackstageTheme,
+): TableColumn[] {
+ return columns.map(column => {
+ const headerStyle: React.CSSProperties = {};
+ const cellStyle: React.CSSProperties =
+ typeof column.cellStyle === 'object' ? column.cellStyle : {};
+
+ if (column.highlight) {
+ headerStyle.color = theme.palette.textContrast;
+ cellStyle.fontWeight = theme.typography.fontWeightBold;
+ }
+
+ return {
+ ...column,
+ headerStyle,
+ cellStyle,
+ };
+ });
+}
+
+function removeDefaultValues(state: any, defaultState: any): any {
+ return transform(state, (result, value, key) => {
+ if (!isEqual(value, defaultState[key])) {
+ result[key] = value;
+ }
+ });
+}
+
+const defaultInitialState = {
+ search: '',
+ filtersOpen: false,
+ filters: {},
+};
+
+export interface TableColumn extends Column {
+ highlight?: boolean;
+ width?: string;
+}
+
+export type TableFilter = {
+ column: string;
+ type: 'select' | 'multiple-select' | 'checkbox-tree';
+};
+
+export type TableState = {
+ search?: string;
+ filtersOpen?: boolean;
+ filters?: SelectedFilters;
+};
+
+export interface TableProps
+ extends MaterialTableProps {
+ columns: TableColumn[];
+ subtitle?: string;
+ filters?: TableFilter[];
+ initialState?: TableState;
+ emptyContent?: ReactNode;
+ onStateChange?: (state: TableState) => any;
+}
+
+export function Table({
+ columns,
+ options,
+ title,
+ subtitle,
+ filters,
+ initialState,
+ emptyContent,
+ onStateChange,
+ ...props
+}: TableProps) {
+ const headerClasses = useHeaderStyles();
+ const toolbarClasses = useToolbarStyles();
+ const tableClasses = useTableStyles();
+ const filtersClasses = useFilterStyles();
+
+ const { data, ...propsWithoutData } = props;
+
+ const theme = useTheme();
+
+ const calculatedInitialState = { ...defaultInitialState, ...initialState };
+
+ const [filtersOpen, toggleFilters] = useState(
+ calculatedInitialState.filtersOpen,
+ );
+ const [selectedFiltersLength, setSelectedFiltersLength] = useState(0);
+ const [tableData, setTableData] = useState(data as any[]);
+ const [selectedFilters, setSelectedFilters] = useState(
+ calculatedInitialState.filters,
+ );
+
+ const MTColumns = convertColumns(columns, theme);
+
+ const [search, setSearch] = useState(calculatedInitialState.search);
+ const toolbarRef = useRef();
+
+ useEffect(() => {
+ if (toolbarRef.current) {
+ toolbarRef.current.onSearchChange(search);
+ }
+ }, [search, toolbarRef]);
+
+ useEffect(() => {
+ if (onStateChange) {
+ const state = removeDefaultValues(
+ {
+ search,
+ filtersOpen,
+ filters: selectedFilters,
+ },
+ defaultInitialState,
+ );
+
+ onStateChange(state);
+ }
+ }, [search, filtersOpen, selectedFilters, onStateChange]);
+
+ const defaultOptions: Options = {
+ headerStyle: {
+ textTransform: 'uppercase',
+ },
+ };
+
+ const getFieldByTitle = useCallback(
+ (titleValue: string | keyof T) =>
+ columns.find(el => el.title === titleValue)?.field,
+ [columns],
+ );
+
+ useEffect(() => {
+ if (!selectedFilters) {
+ setTableData(data as any[]);
+ return;
+ }
+
+ const selectedFiltersArray = Object.values(selectedFilters);
+ if (data && selectedFiltersArray.flat().length) {
+ const newData = (data as any[]).filter(
+ el =>
+ !!Object.entries(selectedFilters)
+ .filter(([, value]) => !!value.length)
+ .every(([key, filterValue]) => {
+ const fieldValue = extractValueByField(
+ el,
+ getFieldByTitle(key) as string,
+ );
+
+ if (Array.isArray(fieldValue) && Array.isArray(filterValue)) {
+ return fieldValue.some(v => filterValue.includes(v));
+ } else if (Array.isArray(fieldValue)) {
+ return fieldValue.includes(filterValue);
+ } else if (Array.isArray(filterValue)) {
+ return filterValue.includes(fieldValue);
+ }
+
+ return fieldValue === filterValue;
+ }),
+ );
+ setTableData(newData);
+ } else {
+ setTableData(data as any[]);
+ }
+ setSelectedFiltersLength(selectedFiltersArray.flat().length);
+ }, [data, selectedFilters, getFieldByTitle]);
+
+ const constructFilters = (
+ filterConfig: TableFilter[],
+ dataValue: any[] | undefined,
+ ): Filter[] => {
+ const extractDistinctValues = (field: string | keyof T): Set => {
+ const distinctValues = new Set();
+ const addValue = (value: any) => {
+ if (value !== undefined && value !== null) {
+ distinctValues.add(value);
+ }
+ };
+
+ if (dataValue) {
+ dataValue.forEach(el => {
+ const value = extractValueByField(
+ el,
+ getFieldByTitle(field) as string,
+ );
+
+ if (Array.isArray(value)) {
+ (value as []).forEach(addValue);
+ } else {
+ addValue(value);
+ }
+ });
+ }
+
+ return distinctValues;
+ };
+
+ const constructCheckboxTree = (
+ filter: TableFilter,
+ ): Without => ({
+ label: filter.column,
+ subCategories: [...extractDistinctValues(filter.column)].map(v => ({
+ label: v,
+ options: [],
+ })),
+ });
+
+ const constructSelect = (
+ filter: TableFilter,
+ ): Without => {
+ return {
+ placeholder: 'All results',
+ label: filter.column,
+ multiple: filter.type === 'multiple-select',
+ items: [...extractDistinctValues(filter.column)].map(value => ({
+ label: value,
+ value,
+ })),
+ };
+ };
+
+ return filterConfig.map(filter => ({
+ type: filter.type,
+ element:
+ filter.type === 'checkbox-tree'
+ ? constructCheckboxTree(filter)
+ : constructSelect(filter),
+ }));
+ };
+
+ const Toolbar = useCallback(
+ toolbarProps => {
+ const onSearchChanged = (searchText: string) => {
+ toolbarProps.onSearchChanged(searchText);
+ setSearch(searchText);
+ };
+
+ if (filters?.length) {
+ return (
+
+
+ toggleFilters(el => !el)}
+ aria-label="filter list"
+ >
+
+
+
+ Filters ({selectedFiltersLength})
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+ },
+ [
+ filters?.length,
+ selectedFiltersLength,
+ toggleFilters,
+ toolbarClasses,
+ filtersClasses,
+ setSearch,
+ toolbarRef,
+ ],
+ );
+
+ const Body = useCallback(
+ bodyProps => {
+ if (emptyContent && data.length === 0) {
+ return (
+
+
+ {emptyContent}
+
+
+ );
+ }
+
+ return ;
+ },
+ [data, emptyContent, columns],
+ );
+
+ return (
+
+ {filtersOpen && data && filters?.length && (
+
+ )}
+
+ components={{
+ Header: headerProps => (
+
+ ),
+ Toolbar,
+ Body,
+ }}
+ options={{ ...defaultOptions, ...options }}
+ columns={MTColumns}
+ icons={tableIcons}
+ title={
+ <>
+ {title}
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+ >
+ }
+ data={tableData}
+ style={{ width: '100%' }}
+ {...propsWithoutData}
+ />
+
+ );
+}
diff --git a/packages/components/src/components/Table/index.ts b/packages/components/src/components/Table/index.ts
new file mode 100644
index 0000000000..83432ffc70
--- /dev/null
+++ b/packages/components/src/components/Table/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { SubvalueCell } from './SubvalueCell';
+export { Table } from './Table';
+export type { TableColumn, TableFilter, TableProps, TableState } from './Table';
diff --git a/packages/components/src/components/Tabs/Tab.test.tsx b/packages/components/src/components/Tabs/Tab.test.tsx
new file mode 100644
index 0000000000..df8540ff14
--- /dev/null
+++ b/packages/components/src/components/Tabs/Tab.test.tsx
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { StyledTab } from './Tab';
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp( );
+ expect(rendered.getByText('test')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/Tabs/Tab.tsx b/packages/components/src/components/Tabs/Tab.tsx
new file mode 100644
index 0000000000..93243b2900
--- /dev/null
+++ b/packages/components/src/components/Tabs/Tab.tsx
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Tab, makeStyles } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+
+interface StyledTabProps {
+ label?: string;
+ icon?: any; // TODO: define type for material-ui icons
+ isFirstNav?: boolean;
+ isFirstIndex?: boolean;
+ value?: any;
+}
+
+const tabMarginLeft = (isFirstNav: boolean, isFirstIndex: boolean) => {
+ if (isFirstIndex) {
+ if (isFirstNav) {
+ return '20px';
+ }
+ return '0';
+ }
+ return '40px';
+};
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ textTransform: 'none',
+ height: '64px',
+ fontWeight: theme.typography.fontWeightBold,
+ fontSize: theme.typography.pxToRem(13),
+ color: theme.palette.textSubtle,
+ marginLeft: props =>
+ tabMarginLeft(props.isFirstNav as boolean, props.isFirstIndex as boolean),
+ width: '130px',
+ minWidth: '130px',
+ '&:hover': {
+ outline: 'none',
+ backgroundColor: 'transparent',
+ color: theme.palette.textSubtle,
+ },
+ },
+}));
+
+export const StyledTab = (props: StyledTabProps) => {
+ const classes = useStyles(props);
+ const { isFirstNav, isFirstIndex, ...rest } = props;
+ return ;
+};
diff --git a/packages/components/src/components/Tabs/TabBar.tsx b/packages/components/src/components/Tabs/TabBar.tsx
new file mode 100644
index 0000000000..c874200161
--- /dev/null
+++ b/packages/components/src/components/Tabs/TabBar.tsx
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { PropsWithChildren } from 'react';
+import { Tabs, makeStyles } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+
+interface StyledTabsProps {
+ value: number | boolean;
+ onChange: (event: React.ChangeEvent<{}>, newValue: number) => void;
+}
+
+const useStyles = makeStyles(theme => ({
+ indicator: {
+ display: 'flex',
+ justifyContent: 'center',
+ backgroundColor: theme.palette.tabbar.indicator,
+ height: '4px',
+ },
+ flexContainer: {
+ alignItems: 'center',
+ },
+ root: {
+ '&:last-child': {
+ marginLeft: 'auto',
+ },
+ },
+}));
+
+export const StyledTabs = (props: PropsWithChildren) => {
+ const classes = useStyles(props);
+ return (
+ }}
+ />
+ );
+};
diff --git a/packages/components/src/components/Tabs/TabIcon.tsx b/packages/components/src/components/Tabs/TabIcon.tsx
new file mode 100644
index 0000000000..ffb2e12cbd
--- /dev/null
+++ b/packages/components/src/components/Tabs/TabIcon.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 { IconButton, makeStyles } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+
+interface StyledIconProps {
+ ariaLabel: string;
+ children: any;
+ isNext?: boolean;
+ onClick: any;
+}
+
+const useStyles = makeStyles(() => ({
+ root: {
+ color: '#6E6E6E',
+ overflow: 'visible',
+ fontSize: '1.5rem',
+ textAlign: 'center',
+ borderRadius: '50%',
+ backgroundColor: '#E6E6E6',
+ marginLeft: props => (props.isNext ? 'auto' : '0'),
+ marginRight: props => (props.isNext ? '0' : '10px'),
+ '&:hover': {
+ backgroundColor: '#E6E6E6',
+ opacity: '1',
+ },
+ },
+}));
+
+export const StyledIcon = (props: StyledIconProps) => {
+ const classes = useStyles(props);
+ const { ariaLabel, onClick } = props;
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/packages/components/src/components/Tabs/TabPanel.tsx b/packages/components/src/components/Tabs/TabPanel.tsx
new file mode 100644
index 0000000000..360893b7c4
--- /dev/null
+++ b/packages/components/src/components/Tabs/TabPanel.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 React, { PropsWithChildren } from 'react';
+import Box from '@material-ui/core/Box';
+
+export interface TabPanelProps {
+ value?: any;
+ index?: number;
+}
+
+export const TabPanel = (props: PropsWithChildren) => {
+ const { children, value, index, ...other } = props;
+
+ return (
+
+ {value === index && {children} }
+
+ );
+};
diff --git a/packages/components/src/components/Tabs/Tabs.stories.tsx b/packages/components/src/components/Tabs/Tabs.stories.tsx
new file mode 100644
index 0000000000..00819c44f1
--- /dev/null
+++ b/packages/components/src/components/Tabs/Tabs.stories.tsx
@@ -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 React from 'react';
+import { Tabs } from './Tabs';
+import AccessAlarmIcon from '@material-ui/icons/AccessAlarm';
+
+export default {
+ title: 'Navigation/Tabs',
+ component: Tabs,
+};
+
+const containerStyle = {};
+
+export const Default = () => (
+
+
({
+ label: `ANOTHER TAB`,
+ content: Content {index}
,
+ }))}
+ />
+
+);
+
+export const Expandable = () => (
+
+
({
+ label: `ANOTHER TAB`,
+ content: Content {index}
,
+ }))}
+ />
+
+);
+
+export const Icons = () => (
+
+
({
+ icon: ,
+ content: Content {index}
,
+ }))}
+ />
+
+);
+
+export const IconsAndLabels = () => (
+
+
({
+ icon: ,
+ label: `ANOTHER TAB`,
+ content: Content {index}
,
+ }))}
+ />
+
+);
diff --git a/packages/components/src/components/Tabs/Tabs.tsx b/packages/components/src/components/Tabs/Tabs.tsx
new file mode 100644
index 0000000000..f34afc1350
--- /dev/null
+++ b/packages/components/src/components/Tabs/Tabs.tsx
@@ -0,0 +1,159 @@
+/*
+ * 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, { useRef, useEffect, MutableRefObject, useState } from 'react';
+import { BackstageTheme } from '@backstage/theme';
+import { AppBar } from '@material-ui/core';
+import { makeStyles } from '@material-ui/core/styles';
+import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore';
+import NavigateNextIcon from '@material-ui/icons/NavigateNext';
+import { chunkArray } from './utils';
+import { useWindowSize } from 'react-use';
+
+/* Import Components */
+
+import { TabPanel } from './TabPanel';
+import { StyledIcon } from './TabIcon';
+import { StyledTab } from './Tab';
+import { StyledTabs } from './TabBar';
+
+/* Props Types */
+
+export interface TabProps {
+ content: any;
+ label?: string;
+ icon?: any; // TODO: define type for material-ui icons
+}
+
+export interface TabsProps {
+ tabs: TabProps[];
+}
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ flexGrow: 1,
+ width: '100%',
+ },
+ styledTabs: {
+ backgroundColor: theme.palette.background.paper,
+ },
+ appbar: {
+ boxShadow: 'none',
+ backgroundColor: theme.palette.background.paper,
+ paddingLeft: '10px',
+ paddingRight: '10px',
+ },
+}));
+
+export const Tabs = ({ tabs }: TabsProps) => {
+ const classes = useStyles();
+ const [value, setValue] = useState([0, 0]); // [selectedChunkedNavIndex, selectedIndex]
+ const [navIndex, setNavIndex] = useState(0);
+ const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0);
+ const [chunkedTabs, setChunkedTabs] = useState([[]]);
+ const wrapper = useRef() as MutableRefObject;
+
+ const { width } = useWindowSize();
+
+ const handleChange = (_: React.ChangeEvent<{}>, newValue: number) => {
+ setValue([navIndex, newValue]);
+ };
+
+ const navigateToPrevChunk = () => {
+ setNavIndex(navIndex - 1);
+ };
+
+ const navigateToNextChunk = () => {
+ setNavIndex(navIndex + 1);
+ };
+
+ const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length;
+
+ useEffect(() => {
+ // Each time the window is resized we calculate how many tabs we can render given the window width
+ const padding = 20; // The AppBar padding
+
+ const numberOfTabIcons = navIndex === 0 ? 1 : 2;
+ const wrapperWidth =
+ wrapper.current.offsetWidth - padding - numberOfTabIcons * 30;
+ const flattenIndex = value[0] * numberOfChunkedElement + value[1];
+ const newChunkedElementSize = Math.floor(wrapperWidth / 170);
+
+ setNumberOfChunkedElement(newChunkedElementSize);
+ setChunkedTabs(chunkArray(tabs, newChunkedElementSize));
+ setValue([
+ Math.floor(flattenIndex / newChunkedElementSize),
+ flattenIndex % newChunkedElementSize,
+ ]);
+ // eslint-disable-next-line
+ }, [width, tabs]);
+
+ const currentIndex = navIndex === value[0] ? value[1] : false;
+
+ return (
+
+
+
+
+ {navIndex !== 0 && (
+
+
+
+ )}
+ {chunkedTabs[navIndex].map((tab, index) => (
+
+ ))}
+ {hasNextNavIndex() && (
+
+
+
+ )}
+
+
+
+ {currentIndex !== false ? (
+ chunkedTabs[navIndex].map((tab, index) => (
+
+ {tab.content}
+
+ ))
+ ) : (
+ // Render if the selected tab index is outside the current rendered chunked array
+
+ {chunkedTabs[value[0]][value[1]].content}
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/components/Tabs/index.ts b/packages/components/src/components/Tabs/index.ts
new file mode 100644
index 0000000000..76275f1d6b
--- /dev/null
+++ b/packages/components/src/components/Tabs/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 { Tabs } from './Tabs';
diff --git a/packages/components/src/components/Tabs/utils.ts b/packages/components/src/components/Tabs/utils.ts
new file mode 100644
index 0000000000..8d6a3be5f3
--- /dev/null
+++ b/packages/components/src/components/Tabs/utils.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 function chunkArray(array: T[], chunkSize: number): T[][] {
+ if (chunkSize <= 0) {
+ return [array];
+ }
+
+ const result: T[][] = [];
+ for (let i = 0; i < array.length; i += chunkSize) {
+ result.push(array.slice(i, i + chunkSize));
+ }
+
+ return result;
+}
diff --git a/packages/components/src/components/TrendLine/TrendLine.stories.tsx b/packages/components/src/components/TrendLine/TrendLine.stories.tsx
new file mode 100644
index 0000000000..6a5f8564f7
--- /dev/null
+++ b/packages/components/src/components/TrendLine/TrendLine.stories.tsx
@@ -0,0 +1,141 @@
+/*
+ * 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 { Table } from '../Table';
+import { TrendLine } from './TrendLine';
+import { InfoCard } from '../../layout/InfoCard';
+
+export default {
+ title: 'Data Display/TrendLine',
+ component: TrendLine,
+};
+
+const containerStyle = { width: 700 };
+
+const data = [
+ {
+ stock: 'A',
+ day: ,
+ week: ,
+ month: ,
+ year: ,
+ },
+ {
+ stock: 'B',
+ day: ,
+ week: ,
+ month: ,
+ year: ,
+ },
+ {
+ stock: 'C',
+ day: ,
+ week: ,
+ month: ,
+ year: ,
+ },
+ {
+ stock: 'D',
+ day: ,
+ week: ,
+ month: ,
+ year: ,
+ },
+ {
+ stock: 'E',
+ day: (
+
+ ),
+ week: (
+
+ ),
+ month: (
+
+ ),
+ year: (
+
+ ),
+ },
+];
+
+const columns = [
+ { field: 'stock', title: 'Stock' },
+ { field: 'day', title: 'Day' },
+ { field: 'week', title: 'Week' },
+ { field: 'month', title: 'Month' },
+ { field: 'year', title: 'Year' },
+];
+
+export const Default = () => (
+
+);
+
+export const TrendingMix = () => (
+
+
+
+);
+
+export const TrendingUp = () => (
+
+
+
+);
+
+export const TrendingDown = () => (
+
+
+
+);
+
+export const TrendingCustomColor = () => (
+
+
+
+);
diff --git a/packages/components/src/components/TrendLine/TrendLine.test.tsx b/packages/components/src/components/TrendLine/TrendLine.test.tsx
new file mode 100644
index 0000000000..f3d658265b
--- /dev/null
+++ b/packages/components/src/components/TrendLine/TrendLine.test.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/* eslint-disable jest/no-disabled-tests */
+import React from 'react';
+import { render } from '@testing-library/react';
+import { renderInTestApp, wrapInTestApp } from '@backstage/test-utils';
+
+import { TrendLine } from './TrendLine';
+
+describe('TrendLine', () => {
+ describe('when no data is present', () => {
+ it('renders null without throwing', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.queryByTitle('sparkline')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('when one datapoint is present', () => {
+ it('renders as a straight line', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
+ });
+ });
+
+ describe.skip('when the data finishes above the success threshold', () => {
+ it('renders with the correct color', () => {
+ const rendered = render(
+ wrapInTestApp( ),
+ );
+ expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
+ });
+ });
+
+ describe.skip('when the data finishes within the the warning threshold', () => {
+ it('renders with the correct color', () => {
+ const rendered = render(
+ wrapInTestApp( ),
+ );
+ expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
+ });
+ });
+
+ describe.skip('when the data finishes within the the error threshold', () => {
+ it('renders with the correct color', () => {
+ const rendered = render(
+ wrapInTestApp( ),
+ );
+ expect(rendered.getByTitle('sparkline')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/components/src/components/TrendLine/TrendLine.tsx b/packages/components/src/components/TrendLine/TrendLine.tsx
new file mode 100644
index 0000000000..84024634d3
--- /dev/null
+++ b/packages/components/src/components/TrendLine/TrendLine.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ Sparklines,
+ SparklinesLine,
+ SparklinesLineProps,
+ SparklinesProps,
+} from 'react-sparklines';
+import { useTheme } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+
+function color(data: number[], theme: BackstageTheme): string | undefined {
+ const lastNum = data[data.length - 1];
+ if (!lastNum) return undefined;
+ if (lastNum >= 0.9) return theme.palette.status.ok;
+ if (lastNum >= 0.5) return theme.palette.status.warning;
+ return theme.palette.status.error;
+}
+
+export const TrendLine = (
+ props: SparklinesProps &
+ Pick & { title?: string },
+) => {
+ const theme = useTheme();
+
+ if (!props.data) return null;
+ return (
+
+ {props.title && {props.title} }
+
+
+ );
+};
diff --git a/packages/components/src/components/TrendLine/index.ts b/packages/components/src/components/TrendLine/index.ts
new file mode 100644
index 0000000000..ed98e53389
--- /dev/null
+++ b/packages/components/src/components/TrendLine/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 { TrendLine } from './TrendLine';
diff --git a/packages/components/src/components/WarningPanel/WarningPanel.stories.tsx b/packages/components/src/components/WarningPanel/WarningPanel.stories.tsx
new file mode 100644
index 0000000000..ef99a0fce4
--- /dev/null
+++ b/packages/components/src/components/WarningPanel/WarningPanel.stories.tsx
@@ -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 React from 'react';
+import { WarningPanel } from './WarningPanel';
+import { Button, Link, Typography } from '@material-ui/core';
+
+export default {
+ title: 'Feedback/Warning Panel',
+ component: WarningPanel,
+};
+
+export const Default = () => (
+
+ This example entity is missing an annotation. If this is unexpected,
+ please make sure you have set up everything correctly by following{' '}
+ this guide.
+ >
+ }
+ />
+);
+
+export const Children = () => (
+
+
+ Supports custom children - for example these text elements. This can be
+ used to hide/expose stack traces for warnings, like this example:
+
+ SyntaxError: Error transforming
+ /home/user/github/backstage/packages/core/src/components/WarningPanel/WarningPanel.stories.tsx:
+ Unexpected token (42:16) at unexpected
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/util.js:83:15)
+ at tsParseMaybeAssignWithJSX
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1399:22)
+ at tsParseMaybeAssign
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/plugins/typescript.js:1373:12)
+ at parseMaybeAssign
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:118:43)
+ at parseExprListItem
+ (/home/user/github/backstage/node_modules/sucrase/dist/parser/traverser/expression.js:969:5)
+
+ Learn More
+
+);
+
+export const FullExample = () => (
+
+ HTTP 500 Bad Gateway response from
+ https://usefulservice.mycompany.com/api/entity?44433
+
+);
+
+export const TitleOnly = () => ;
diff --git a/packages/components/src/components/WarningPanel/WarningPanel.test.tsx b/packages/components/src/components/WarningPanel/WarningPanel.test.tsx
new file mode 100644
index 0000000000..38ba4bff9b
--- /dev/null
+++ b/packages/components/src/components/WarningPanel/WarningPanel.test.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { fireEvent, screen } from '@testing-library/react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { Typography } from '@material-ui/core';
+
+import { WarningPanel } from './WarningPanel';
+
+const propsTitle = { title: 'Mock title' };
+const propsTitleMessage = { title: 'Mock title', message: 'Some more info' };
+const propsMessage = { message: 'Some more info' };
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ await renderInTestApp( );
+ expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
+ });
+
+ it('renders title', async () => {
+ await renderInTestApp( );
+ const expandIcon = await screen.getByText('Warning: Mock title');
+ fireEvent.click(expandIcon);
+ expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
+ expect(screen.getByText('Some more info')).toBeInTheDocument();
+ });
+
+ it('renders title and children', async () => {
+ await renderInTestApp(
+
+ Java stacktrace
+ ,
+ );
+ expect(screen.getByText('Java stacktrace')).toBeInTheDocument();
+ });
+
+ it('renders message', async () => {
+ await renderInTestApp( );
+ expect(screen.getByText('Warning')).toBeInTheDocument();
+ expect(screen.getByText('Some more info')).toBeInTheDocument();
+ });
+
+ it('renders title, message, and children', async () => {
+ await renderInTestApp(
+
+ Java stacktrace
+ ,
+ );
+ expect(screen.getByText('Warning: Mock title')).toBeInTheDocument();
+ expect(screen.getByText('Some more info')).toBeInTheDocument();
+ expect(screen.getByText('Java stacktrace')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/components/WarningPanel/WarningPanel.tsx b/packages/components/src/components/WarningPanel/WarningPanel.tsx
new file mode 100644
index 0000000000..e82c49c49a
--- /dev/null
+++ b/packages/components/src/components/WarningPanel/WarningPanel.tsx
@@ -0,0 +1,136 @@
+/*
+ * 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 { BackstageTheme } from '@backstage/theme';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Grid,
+ makeStyles,
+ Typography,
+} from '@material-ui/core';
+import ErrorOutline from '@material-ui/icons/ErrorOutline';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import React from 'react';
+
+const useErrorOutlineStyles = makeStyles(theme => ({
+ root: {
+ marginRight: theme.spacing(1),
+ fill: theme.palette.warningText,
+ },
+}));
+const ErrorOutlineStyled = () => {
+ const classes = useErrorOutlineStyles();
+ return ;
+};
+const ExpandMoreIconStyled = () => {
+ const classes = useErrorOutlineStyles();
+ return ;
+};
+
+const useStyles = makeStyles(theme => ({
+ panel: {
+ backgroundColor: theme.palette.warningBackground,
+ color: theme.palette.warningText,
+ verticalAlign: 'middle',
+ },
+ summary: {
+ display: 'flex',
+ flexDirection: 'row',
+ },
+ summaryText: {
+ color: theme.palette.warningText,
+ fontWeight: 'bold',
+ },
+ message: {
+ width: '100%',
+ display: 'block',
+ color: theme.palette.warningText,
+ backgroundColor: theme.palette.warningBackground,
+ },
+ details: {
+ width: '100%',
+ display: 'block',
+ color: theme.palette.textContrast,
+ backgroundColor: theme.palette.background.default,
+ border: `1px solid ${theme.palette.border}`,
+ padding: theme.spacing(2.0),
+ fontFamily: 'sans-serif',
+ },
+}));
+
+type Props = {
+ title?: string;
+ severity?: 'warning' | 'error' | 'info';
+ message?: React.ReactNode;
+ children?: React.ReactNode;
+};
+
+const capitalize = (s: string) => {
+ return s.charAt(0).toUpperCase() + s.slice(1);
+};
+
+/**
+ * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel
+ * only shows the warning message to the user.
+ *
+ * @param {string} [severity=warning] Ability to change the severity of the alert. Not fully implemented. (error, warning, info)
+ * @param {string} [title] A title for the warning. If not supplied, "Warning" will be used.
+ * @param {Object} [message] Optional more detailed user-friendly message elaborating on the cause of the error.
+ * @param {Object} [children] Objects to provide context, such as a stack trace or detailed error reporting.
+ * Will be available inside an unfolded accordion.
+ */
+export const WarningPanel = (props: Props) => {
+ const classes = useStyles(props);
+ const { severity, title, message, children } = props;
+
+ // If no severity or title provided, the heading will read simply "Warning"
+ const subTitle =
+ (severity ? capitalize(severity) : 'Warning') + (title ? `: ${title}` : '');
+
+ return (
+
+ }
+ className={classes.summary}
+ >
+
+
+ {subTitle}
+
+
+ {(message || children) && (
+
+
+ {message && (
+
+
+ {message}
+
+
+ )}
+ {children && (
+
+ {children}
+
+ )}
+
+
+ )}
+
+ );
+};
diff --git a/packages/components/src/components/WarningPanel/index.ts b/packages/components/src/components/WarningPanel/index.ts
new file mode 100644
index 0000000000..5cc9b8bb65
--- /dev/null
+++ b/packages/components/src/components/WarningPanel/index.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+export { WarningPanel } from './WarningPanel';
diff --git a/packages/components/src/components/index.ts b/packages/components/src/components/index.ts
new file mode 100644
index 0000000000..58d0a8f569
--- /dev/null
+++ b/packages/components/src/components/index.ts
@@ -0,0 +1,44 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './AlertDisplay';
+export * from './Avatar';
+export * from './Button';
+export * from './CodeSnippet';
+export * from './CopyTextButton';
+export * from './DependencyGraph';
+export * from './DismissableBanner';
+export * from './EmptyState';
+export * from './FeatureDiscovery';
+export * from './HeaderIconLinkRow';
+export * from './HorizontalScrollGrid';
+export * from './Lifecycle';
+export * from './Link';
+export * from './MarkdownContent';
+export * from './OAuthRequestDialog';
+export * from './OverflowTooltip';
+export * from './Progress';
+export * from './ProgressBars';
+export * from './Select';
+export * from './SimpleStepper';
+export * from './Status';
+export * from './StructuredMetadataTable';
+export * from './SupportButton';
+export * from './TabbedLayout';
+export * from './Table';
+export * from './Tabs';
+export * from './TrendLine';
+export * from './WarningPanel';
diff --git a/packages/components/src/hooks/index.ts b/packages/components/src/hooks/index.ts
new file mode 100644
index 0000000000..6408790b8d
--- /dev/null
+++ b/packages/components/src/hooks/index.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { useQueryParamState } from './useQueryParamState';
+export { useSupportConfig } from './useSupportConfig';
+export type {
+ SupportConfig,
+ SupportItem,
+ SupportItemLink,
+} from './useSupportConfig';
diff --git a/packages/components/src/hooks/useQueryParamState.ts b/packages/components/src/hooks/useQueryParamState.ts
new file mode 100644
index 0000000000..dc2279cb53
--- /dev/null
+++ b/packages/components/src/hooks/useQueryParamState.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 { isEqual } from 'lodash';
+import qs from 'qs';
+import { useEffect, useState } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { useDebounce } from 'react-use';
+
+function stringify(queryParams: any): string {
+ // Even though these setting don't look nice (e.g. escaped brackets), we should keep
+ // them this way. The current syntax handles all cases, including variable types with
+ // arrays or strings.
+ return qs.stringify(queryParams, {
+ strictNullHandling: true,
+ });
+}
+
+function parse(queryString: string): any {
+ return qs.parse(queryString, {
+ ignoreQueryPrefix: true,
+ strictNullHandling: true,
+ });
+}
+
+function extractState(queryString: string, stateName: string): any | undefined {
+ const queryParams = parse(queryString);
+
+ return queryParams[stateName];
+}
+
+function joinQueryString(
+ queryString: string,
+ stateName: string,
+ state: any,
+): string {
+ const queryParams = {
+ ...parse(queryString),
+ [stateName]: state,
+ };
+ return stringify(queryParams);
+}
+
+type SetQueryParams = (params: T) => void;
+
+export function useQueryParamState(
+ stateName: string,
+): [T | undefined, SetQueryParams] {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const [queryParamState, setQueryParamState] = useState(
+ extractState(location.search, stateName),
+ );
+
+ useEffect(() => {
+ const newState = extractState(location.search, stateName);
+
+ setQueryParamState(oldState =>
+ isEqual(newState, oldState) ? oldState : newState,
+ );
+ }, [location, stateName]);
+
+ useDebounce(
+ () => {
+ const queryString = joinQueryString(
+ location.search,
+ stateName,
+ queryParamState,
+ );
+
+ if (location.search !== queryString) {
+ navigate({ ...location, search: `?${queryString}` }, { replace: true });
+ }
+ },
+ 100,
+ [queryParamState],
+ );
+
+ return [queryParamState, setQueryParamState];
+}
diff --git a/packages/components/src/hooks/useSupportConfig.ts b/packages/components/src/hooks/useSupportConfig.ts
new file mode 100644
index 0000000000..e77c64dd85
--- /dev/null
+++ b/packages/components/src/hooks/useSupportConfig.ts
@@ -0,0 +1,74 @@
+/*
+ * 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 { useApi, configApiRef } from '@backstage/core-api';
+
+export type SupportItemLink = {
+ url: string;
+ title: string;
+};
+
+export type SupportItem = {
+ title: string;
+ icon?: string;
+ links: SupportItemLink[];
+};
+
+export type SupportConfig = {
+ url: string;
+ items: SupportItem[];
+};
+
+const DEFAULT_SUPPORT_CONFIG: SupportConfig = {
+ url: 'https://github.com/backstage/backstage/issues',
+ items: [
+ {
+ title: 'Support Not Configured',
+ icon: 'warning',
+ links: [
+ {
+ // TODO: Update to dedicated support page on backstage.io/docs
+ title: 'Add `app.support` config key',
+ url:
+ 'https://github.com/andrewthauer/backstage/blob/master/app-config.yaml',
+ },
+ ],
+ },
+ ],
+};
+
+export function useSupportConfig(): SupportConfig {
+ const config = useApi(configApiRef);
+ const supportConfig = config.getOptionalConfig('app.support');
+
+ if (!supportConfig) {
+ return DEFAULT_SUPPORT_CONFIG;
+ }
+
+ return {
+ url: supportConfig.getString('url'),
+ items: supportConfig.getConfigArray('items').flatMap(itemConf => ({
+ title: itemConf.getString('title'),
+ icon: itemConf.getOptionalString('icon'),
+ links: (itemConf.getOptionalConfigArray('links') ?? []).flatMap(
+ linkConf => ({
+ url: linkConf.getString('url'),
+ title: linkConf.getString('title'),
+ }),
+ ),
+ })),
+ };
+}
diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts
new file mode 100644
index 0000000000..a966ff0d6c
--- /dev/null
+++ b/packages/components/src/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './components';
+export * from './layout';
+export * from './hooks';
diff --git a/packages/components/src/layout/BottomLink/BottomLink.test.tsx b/packages/components/src/layout/BottomLink/BottomLink.test.tsx
new file mode 100644
index 0000000000..8bea00e35d
--- /dev/null
+++ b/packages/components/src/layout/BottomLink/BottomLink.test.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 from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { BottomLink } from './BottomLink';
+
+const minProps = {
+ title: 'A deepLink title',
+ link: '/mocked',
+};
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp( );
+ expect(rendered.getByText('A deepLink title')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/layout/BottomLink/BottomLink.tsx b/packages/components/src/layout/BottomLink/BottomLink.tsx
new file mode 100644
index 0000000000..f412dee674
--- /dev/null
+++ b/packages/components/src/layout/BottomLink/BottomLink.tsx
@@ -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 React from 'react';
+import {
+ ListItem,
+ ListItemIcon,
+ Divider,
+ ListItemText,
+ makeStyles,
+} from '@material-ui/core';
+import ArrowIcon from '@material-ui/icons/ArrowForward';
+import { BackstageTheme } from '@backstage/theme';
+import Box from '@material-ui/core/Box';
+import { Link } from '../../components/Link';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ maxWidth: 'fit-content',
+ padding: theme.spacing(2, 2, 2, 2.5),
+ },
+ boxTitle: {
+ margin: 0,
+ color: theme.palette.textSubtle,
+ },
+ arrow: {
+ color: theme.palette.textSubtle,
+ },
+}));
+
+export type BottomLinkProps = {
+ link: string;
+ title: string;
+ onClick?: (event: React.MouseEvent) => void;
+};
+
+export const BottomLink = ({ link, title, onClick }: BottomLinkProps) => {
+ const classes = useStyles();
+
+ return (
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/BottomLink/index.ts b/packages/components/src/layout/BottomLink/index.ts
new file mode 100644
index 0000000000..18befcc9bf
--- /dev/null
+++ b/packages/components/src/layout/BottomLink/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 { BottomLink } from './BottomLink';
+export type { BottomLinkProps } from './BottomLink';
diff --git a/packages/components/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx b/packages/components/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx
new file mode 100644
index 0000000000..6f0ef0cb69
--- /dev/null
+++ b/packages/components/src/layout/Breadcrumbs/Breadcrumbs.stories.tsx
@@ -0,0 +1,137 @@
+/*
+ * 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 { Box, List, ListItem, Popover, Typography } from '@material-ui/core';
+import ExpandLessIcon from '@material-ui/icons/ExpandLess';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import React, { Fragment } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { Breadcrumbs } from '.';
+import { Header, Page } from '..';
+import { Link } from '../../components/Link';
+
+export default {
+ title: 'Layout/Breadcrumbs',
+ component: Breadcrumbs,
+};
+
+export const InHeader = () => (
+
+ Standard breadcrumbs
+
+ Underlined pages are links. This should show a hierarchical relationship.
+
+
+
+
+
+
+);
+
+export const OutsideOfHeader = () => {
+ const [anchorEl, setAnchorEl] = React.useState(
+ null,
+ );
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const open = Boolean(anchorEl);
+ return (
+
+
+ It might be the case that you want to keep your breadcrumbs outside of
+ the header. In that case, they should be positioned above the title of
+ the page.
+
+
+ Standard breadcrumbs
+
+ Underlined pages are links. This should show a hierarchical
+ relationship.
+
+
+
+
+
+ General Page
+ Second Page
+ Current page
+
+
+ Hidden breadcrumbs
+
+ Use this when you have more than three breadcrumbs. When user clicks on
+ ellipses, expand the breadcrumbs out.
+
+
+
+ General Page
+ Second Page
+ Third Page
+ Fourth Page
+ Current page
+
+
+ Layered breadcrumbs
+
+ Use this when you want to show alternative breadcrumbs on the same
+ hierarchical level.
+
+
+
+
+ General Page
+
+
+ Second Page
+ {open ? : }
+
+
+ Current page
+
+
+
+
+ Parallel second page
+
+
+ Another parallel second page
+
+
+ Yet another, parallel second page
+
+
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/Breadcrumbs/Breadcrumbs.test.tsx b/packages/components/src/layout/Breadcrumbs/Breadcrumbs.test.tsx
new file mode 100644
index 0000000000..0a490afb76
--- /dev/null
+++ b/packages/components/src/layout/Breadcrumbs/Breadcrumbs.test.tsx
@@ -0,0 +1,54 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { Typography } from '@material-ui/core';
+import { fireEvent } from '@testing-library/react';
+import React from 'react';
+import { Link } from '../..';
+import { Breadcrumbs } from './Breadcrumbs';
+
+describe(' ', () => {
+ it('should render', async () => {
+ const rendered = await renderInTestApp(
+
+ General Page
+ Current Page
+ ,
+ );
+ expect(rendered.getByLabelText('breadcrumb')).toBeVisible();
+ expect(rendered.getByText('General Page')).toBeVisible();
+ expect(rendered.getByText('Current Page')).toBeVisible();
+ });
+
+ it('should render hidden breadcrumbs', async () => {
+ const rendered = await renderInTestApp(
+
+ General Page
+ Second Page
+ Third Page
+ Fourth Page
+ Current page
+ ,
+ );
+ expect(rendered.getByText('...')).toBeVisible();
+ expect(rendered.queryByText('Third Page')).not.toBeInTheDocument();
+ expect(rendered.queryByText('Fourth Page')).not.toBeInTheDocument();
+ fireEvent.click(rendered.getByText('...'));
+ expect(rendered.getByText('Third Page')).toBeVisible();
+ expect(rendered.getByText('Fourth Page')).toBeVisible();
+ });
+});
diff --git a/packages/components/src/layout/Breadcrumbs/Breadcrumbs.tsx b/packages/components/src/layout/Breadcrumbs/Breadcrumbs.tsx
new file mode 100644
index 0000000000..a64b462377
--- /dev/null
+++ b/packages/components/src/layout/Breadcrumbs/Breadcrumbs.tsx
@@ -0,0 +1,99 @@
+/*
+ * 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 {
+ Box,
+ Breadcrumbs as MaterialBreadcrumbs,
+ List,
+ ListItem,
+ Popover,
+ Typography,
+ withStyles,
+} from '@material-ui/core';
+import React, { ComponentProps, Fragment } from 'react';
+
+type Props = ComponentProps;
+
+const ClickableText = withStyles({
+ root: {
+ textDecoration: 'underline',
+ cursor: 'pointer',
+ },
+})(Typography);
+
+const StyledBox = withStyles({
+ root: {
+ textDecoration: 'underline',
+ color: 'inherit',
+ },
+})(Box);
+
+export const Breadcrumbs = ({ children, ...props }: Props) => {
+ const [anchorEl, setAnchorEl] = React.useState(
+ null,
+ );
+
+ const childrenArray = React.Children.toArray(children);
+
+ const [firstPage, secondPage, ...expandablePages] = childrenArray;
+ const currentPage = expandablePages.length
+ ? expandablePages.pop()
+ : childrenArray[childrenArray.length - 1];
+ const hasHiddenBreadcrumbs = childrenArray.length > 3;
+
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const open = Boolean(anchorEl);
+ return (
+
+
+ {childrenArray.length > 1 && {firstPage} }
+ {childrenArray.length > 2 && {secondPage} }
+ {hasHiddenBreadcrumbs && (
+ ...
+ )}
+ {currentPage}
+
+
+
+ {expandablePages.map((pageLink, index) => (
+
+ {pageLink}
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/Breadcrumbs/index.ts b/packages/components/src/layout/Breadcrumbs/index.ts
new file mode 100644
index 0000000000..6c5c2539df
--- /dev/null
+++ b/packages/components/src/layout/Breadcrumbs/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 { Breadcrumbs } from './Breadcrumbs';
diff --git a/packages/components/src/layout/Content/Content.tsx b/packages/components/src/layout/Content/Content.tsx
new file mode 100644
index 0000000000..bf305deaea
--- /dev/null
+++ b/packages/components/src/layout/Content/Content.tsx
@@ -0,0 +1,64 @@
+/*
+ * 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, { PropsWithChildren } from 'react';
+import classNames from 'classnames';
+import { Theme, makeStyles } from '@material-ui/core';
+
+const useStyles = makeStyles((theme: Theme) => ({
+ root: {
+ gridArea: 'pageContent',
+ minWidth: 0,
+ paddingTop: theme.spacing(3),
+ paddingBottom: theme.spacing(3),
+ ...theme.mixins.gutters({}),
+ },
+ stretch: {
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ },
+ noPadding: {
+ padding: 0,
+ },
+}));
+
+type Props = {
+ stretch?: boolean;
+ noPadding?: boolean;
+ className?: string;
+};
+
+export const Content = ({
+ className,
+ stretch,
+ noPadding,
+ children,
+ ...props
+}: PropsWithChildren) => {
+ const classes = useStyles();
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/components/src/layout/Content/index.ts b/packages/components/src/layout/Content/index.ts
new file mode 100644
index 0000000000..d05ae0e787
--- /dev/null
+++ b/packages/components/src/layout/Content/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 { Content } from './Content';
diff --git a/packages/components/src/layout/ContentHeader/ContentHeader.test.tsx b/packages/components/src/layout/ContentHeader/ContentHeader.test.tsx
new file mode 100644
index 0000000000..b28ad614bf
--- /dev/null
+++ b/packages/components/src/layout/ContentHeader/ContentHeader.test.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { ContentHeader } from './ContentHeader';
+import { renderInTestApp } from '@backstage/test-utils';
+
+jest.mock('react-helmet', () => {
+ return {
+ Helmet: ({ defaultTitle }: any) => defaultTitle: {defaultTitle}
,
+ };
+});
+
+describe(' ', () => {
+ it('should render with title', async () => {
+ const rendered = await renderInTestApp( );
+ rendered.getByText('Title');
+ });
+
+ it('should render with titleComponent', async () => {
+ const title = 'Custom title';
+ const titleComponent = () => {title} ;
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getByText(title);
+ });
+
+ it('should render with description', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getByText('description');
+ });
+});
diff --git a/packages/components/src/layout/ContentHeader/ContentHeader.tsx b/packages/components/src/layout/ContentHeader/ContentHeader.tsx
new file mode 100644
index 0000000000..e9fba56358
--- /dev/null
+++ b/packages/components/src/layout/ContentHeader/ContentHeader.tsx
@@ -0,0 +1,111 @@
+/*
+ * 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.
+ */
+
+/**
+ * TODO favoriteable capability
+ */
+
+import React, { ComponentType, Fragment, PropsWithChildren } from 'react';
+import { Typography, makeStyles } from '@material-ui/core';
+import { Helmet } from 'react-helmet';
+
+const useStyles = (props: ContentHeaderProps) =>
+ makeStyles(theme => ({
+ container: {
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ marginBottom: theme.spacing(1),
+ textAlign: props.textAlign,
+ },
+ leftItemsBox: {
+ flex: '1 1 auto',
+ marginBottom: theme.spacing(1),
+ minWidth: 0,
+ overflow: 'visible',
+ },
+ rightItemsBox: {
+ flex: '0 1 auto',
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ marginLeft: theme.spacing(1),
+ marginBottom: theme.spacing(1),
+ minWidth: 0,
+ overflow: 'visible',
+ },
+ description: {},
+ title: {
+ display: 'inline-flex',
+ },
+ }));
+
+type DefaultTitleProps = {
+ title?: string;
+ className: string;
+};
+
+const DefaultTitle = ({
+ title = 'Unknown page',
+ className,
+}: DefaultTitleProps) => (
+
+ {title}
+
+);
+
+type ContentHeaderProps = {
+ title?: DefaultTitleProps['title'];
+ titleComponent?: ComponentType;
+ description?: string;
+ textAlign?: 'left' | 'right' | 'center';
+};
+
+export const ContentHeader = ({
+ description,
+ title,
+ titleComponent: TitleComponent = undefined,
+ children,
+ textAlign = 'left',
+}: PropsWithChildren) => {
+ const classes = useStyles({ textAlign })();
+
+ const renderedTitle = TitleComponent ? (
+
+ ) : (
+
+ );
+ return (
+
+
+
+
+ {renderedTitle}
+ {description && (
+
+ {description}
+
+ )}
+
+
{children}
+
+
+ );
+};
diff --git a/packages/components/src/layout/ContentHeader/index.ts b/packages/components/src/layout/ContentHeader/index.ts
new file mode 100644
index 0000000000..18d03228a3
--- /dev/null
+++ b/packages/components/src/layout/ContentHeader/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 { ContentHeader } from './ContentHeader';
diff --git a/packages/components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx b/packages/components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx
new file mode 100644
index 0000000000..4a3126c73c
--- /dev/null
+++ b/packages/components/src/layout/ErrorBoundary/ErrorBoundary.test.tsx
@@ -0,0 +1,71 @@
+/* eslint-disable no-console */
+/*
+ * 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 { ErrorBoundary } from './ErrorBoundary';
+import { renderInTestApp, withLogCollector } from '@backstage/test-utils';
+
+type BombProps = {
+ shouldThrow?: boolean;
+ children?: React.ReactNode;
+};
+
+const Bomb = ({ shouldThrow }: BombProps) => {
+ if (shouldThrow) {
+ throw new Error('Bomb');
+ } else {
+ return Working Component
;
+ }
+};
+
+describe(' ', () => {
+ it('should render error boundary with and without error', async () => {
+ const { error } = await withLogCollector(['error'], async () => {
+ const {
+ rerender,
+ queryByRole,
+ getByRole,
+ getByText,
+ } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(queryByRole('alert')).not.toBeInTheDocument();
+ expect(getByText(/working component/i)).toBeInTheDocument();
+
+ rerender(
+
+
+ ,
+ );
+
+ expect(getByRole('alert')).toBeInTheDocument();
+ expect(getByText(/something went wrong here/i)).toBeInTheDocument();
+ });
+
+ expect(error).toEqual([
+ expect.stringMatching(/^Error: Uncaught \[Error: Bomb\]/),
+ expect.stringMatching(
+ /^The above error occurred in the component:/,
+ ),
+ expect.stringMatching(/^ErrorBoundary/),
+ ]);
+ expect(error.length).toEqual(3);
+ });
+});
diff --git a/packages/components/src/layout/ErrorBoundary/ErrorBoundary.tsx b/packages/components/src/layout/ErrorBoundary/ErrorBoundary.tsx
new file mode 100644
index 0000000000..88784dc8b0
--- /dev/null
+++ b/packages/components/src/layout/ErrorBoundary/ErrorBoundary.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 React, { ComponentClass, Component, ErrorInfo } from 'react';
+
+type Props = {
+ slackChannel?: string;
+ onError?: (error: Error, errorInfo: string) => null;
+};
+
+type State = {
+ error?: Error;
+ errorInfo?: ErrorInfo;
+};
+
+type EProps = {
+ error?: Error;
+ slackChannel?: string;
+ children?: React.ReactNode;
+};
+
+const Error = ({ slackChannel }: EProps) => {
+ return (
+
+ Something went wrong here.{' '}
+ {slackChannel && <>Please contact {slackChannel} for help.>}
+
+ );
+};
+
+export const ErrorBoundary: ComponentClass<
+ Props,
+ State
+> = class ErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ error: undefined,
+ errorInfo: undefined,
+ };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ // eslint-disable-next-line no-console
+ console.error(`ErrorBoundary, error: ${error}, info: ${errorInfo}`);
+ this.setState({ error, errorInfo });
+ }
+
+ render() {
+ const { slackChannel } = this.props;
+ const { error, errorInfo } = this.state;
+
+ if (!errorInfo) {
+ return this.props.children;
+ }
+
+ return ;
+ }
+};
diff --git a/packages/components/src/layout/ErrorBoundary/index.ts b/packages/components/src/layout/ErrorBoundary/index.ts
new file mode 100644
index 0000000000..607634e89a
--- /dev/null
+++ b/packages/components/src/layout/ErrorBoundary/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 { ErrorBoundary } from './ErrorBoundary';
diff --git a/packages/components/src/layout/ErrorPage/ErrorPage.test.tsx b/packages/components/src/layout/ErrorPage/ErrorPage.test.tsx
new file mode 100644
index 0000000000..23b42c0a11
--- /dev/null
+++ b/packages/components/src/layout/ErrorPage/ErrorPage.test.tsx
@@ -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.
+ */
+
+import React from 'react';
+import { ErrorPage } from './ErrorPage';
+import { renderInTestApp } from '@backstage/test-utils';
+
+describe(' ', () => {
+ it('should render with status code, status message and go back link', async () => {
+ const { getByText, getByTestId } = await renderInTestApp(
+ ,
+ );
+ expect(getByText(/page not found/i)).toBeInTheDocument();
+ expect(getByText(/404/i)).toBeInTheDocument();
+ expect(
+ getByText(/looks like someone dropped the mic!/i),
+ ).toBeInTheDocument();
+ expect(getByTestId('go-back-link')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/layout/ErrorPage/ErrorPage.tsx b/packages/components/src/layout/ErrorPage/ErrorPage.tsx
new file mode 100644
index 0000000000..466c2f46b6
--- /dev/null
+++ b/packages/components/src/layout/ErrorPage/ErrorPage.tsx
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Typography, Link, Grid } from '@material-ui/core';
+import { makeStyles } from '@material-ui/core/styles';
+import { BackstageTheme } from '@backstage/theme';
+import { MicDrop } from './MicDrop';
+import { useNavigate } from 'react-router';
+import { useSupportConfig } from '../../hooks';
+
+interface IErrorPageProps {
+ status: string;
+ statusMessage: string;
+ additionalInfo?: string;
+}
+
+const useStyles = makeStyles(theme => ({
+ container: {
+ padding: theme.spacing(8),
+ [theme.breakpoints.down('xs')]: {
+ padding: theme.spacing(2),
+ },
+ },
+ title: {
+ paddingBottom: theme.spacing(5),
+ [theme.breakpoints.down('xs')]: {
+ paddingBottom: theme.spacing(4),
+ fontSize: 32,
+ },
+ },
+ subtitle: {
+ color: theme.palette.textSubtle,
+ },
+}));
+
+export const ErrorPage = ({
+ status,
+ statusMessage,
+ additionalInfo,
+}: IErrorPageProps) => {
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const support = useSupportConfig();
+
+ return (
+
+
+
+
+ ERROR {status}: {statusMessage}
+
+
+ {additionalInfo}
+
+
+ Looks like someone dropped the mic!
+
+
+ navigate(-1)}>
+ Go back
+
+ ... or please{' '}
+
+ contact support
+ {' '}
+ if you think this is a bug.
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/ErrorPage/MicDrop.tsx b/packages/components/src/layout/ErrorPage/MicDrop.tsx
new file mode 100644
index 0000000000..aa8aaaf5e7
--- /dev/null
+++ b/packages/components/src/layout/ErrorPage/MicDrop.tsx
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { makeStyles } from '@material-ui/core';
+import MicDropSvgUrl from './mic-drop.svg';
+
+const useStyles = makeStyles(theme => ({
+ micDrop: {
+ maxWidth: '60%',
+ position: 'absolute',
+ bottom: theme.spacing(2),
+ right: theme.spacing(2),
+ [theme.breakpoints.down('xs')]: {
+ maxWidth: '96%',
+ position: 'relative',
+ bottom: 'unset',
+ right: 'unset',
+ margin: `${theme.spacing(10)}px auto ${theme.spacing(4)}px`,
+ },
+ },
+}));
+
+export const MicDrop = () => {
+ const classes = useStyles();
+ return (
+
+ );
+};
diff --git a/packages/components/src/layout/ErrorPage/index.ts b/packages/components/src/layout/ErrorPage/index.ts
new file mode 100644
index 0000000000..506ed1f815
--- /dev/null
+++ b/packages/components/src/layout/ErrorPage/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 { ErrorPage } from './ErrorPage';
diff --git a/packages/components/src/layout/ErrorPage/mic-drop.svg b/packages/components/src/layout/ErrorPage/mic-drop.svg
new file mode 100644
index 0000000000..5a7d7d8759
--- /dev/null
+++ b/packages/components/src/layout/ErrorPage/mic-drop.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/components/src/layout/Header/Header.stories.tsx b/packages/components/src/layout/Header/Header.stories.tsx
new file mode 100644
index 0000000000..62e6e3e52c
--- /dev/null
+++ b/packages/components/src/layout/Header/Header.stories.tsx
@@ -0,0 +1,102 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { Header } from '.';
+import { HeaderLabel } from '../HeaderLabel';
+import { Page } from '../Page';
+
+export default {
+ title: 'Layout/Header',
+ component: Header,
+};
+
+const labels = (
+ <>
+
+
+
+ >
+);
+
+export const Home = () => (
+
+
+
+);
+
+export const HomeWithSubtitle = () => (
+
+);
+
+export const Tool = () => (
+
+
+
+);
+
+export const Service = () => (
+
+
+
+);
+
+export const Website = () => (
+
+
+
+);
+
+export const Library = () => (
+
+
+
+);
+
+export const App = () => (
+
+
+
+);
+
+export const Documentation = () => (
+
+
+
+);
+
+export const Other = () => (
+
+
+
+);
diff --git a/packages/components/src/layout/Header/Header.test.tsx b/packages/components/src/layout/Header/Header.test.tsx
new file mode 100644
index 0000000000..51468f6ab5
--- /dev/null
+++ b/packages/components/src/layout/Header/Header.test.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { Header } from './Header';
+
+jest.mock('react-helmet', () => {
+ return {
+ Helmet: ({ defaultTitle }: any) => defaultTitle: {defaultTitle}
,
+ };
+});
+
+describe('', () => {
+ it('should render with title', async () => {
+ const rendered = await renderInTestApp();
+ rendered.getByText('Title');
+ });
+
+ it('should set document title', async () => {
+ const rendered = await renderInTestApp();
+ rendered.getByText('Title1');
+ rendered.getByText('defaultTitle: Title1 | Backstage');
+ });
+
+ it('should override document title', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getByText('Title1');
+ rendered.getByText('defaultTitle: Title2 | Backstage');
+ });
+
+ it('should have subtitle', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getByText('Subtitle');
+ });
+
+ it('should have type rendered', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getByText('tool');
+ });
+
+ it('should have breadcrumb rendered', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ rendered.getAllByText('Title');
+ });
+});
diff --git a/packages/components/src/layout/Header/Header.tsx b/packages/components/src/layout/Header/Header.tsx
new file mode 100644
index 0000000000..73fa3066ea
--- /dev/null
+++ b/packages/components/src/layout/Header/Header.tsx
@@ -0,0 +1,213 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { ReactNode, CSSProperties, PropsWithChildren } from 'react';
+import { Helmet } from 'react-helmet';
+import { Typography, Tooltip, makeStyles } from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+import { Breadcrumbs } from '..';
+import { Link } from '../../components';
+
+const useStyles = makeStyles(theme => ({
+ header: {
+ gridArea: 'pageHeader',
+ padding: theme.spacing(3),
+ minHeight: 118,
+ width: '100%',
+ boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)',
+ position: 'relative',
+ zIndex: 100,
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ backgroundImage: theme.page.backgroundImage,
+ backgroundPosition: 'center',
+ backgroundSize: 'cover',
+ },
+ leftItemsBox: {
+ flex: '1 1 auto',
+ },
+ rightItemsBox: {
+ flex: '0 1 auto',
+ display: 'flex',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ alignItems: 'center',
+ marginRight: theme.spacing(1),
+ },
+ title: {
+ color: theme.palette.bursts.fontColor,
+ lineHeight: '1.0em',
+ wordBreak: 'break-all',
+ fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))',
+ marginBottom: theme.spacing(1),
+ },
+ subtitle: {
+ color: 'rgba(255, 255, 255, 0.8)',
+ lineHeight: '1.0em',
+ },
+ type: {
+ textTransform: 'uppercase',
+ fontSize: 11,
+ opacity: 0.8,
+ marginBottom: theme.spacing(1),
+ color: theme.palette.bursts.fontColor,
+ },
+ breadcrumb: {
+ fontSize: 'calc(15px + 1 * ((100vw - 320px) / 680))',
+ color: theme.palette.bursts.fontColor,
+ },
+ breadcrumbType: {
+ fontSize: 'inherit',
+ opacity: 0.7,
+ marginRight: -theme.spacing(0.3),
+ marginBottom: theme.spacing(0.3),
+ },
+ breadcrumbTitle: {
+ fontSize: 'inherit',
+ marginLeft: -theme.spacing(0.3),
+ marginBottom: theme.spacing(0.3),
+ },
+}));
+
+type HeaderStyles = ReturnType;
+
+type Props = {
+ component?: ReactNode;
+ pageTitleOverride?: string;
+ style?: CSSProperties;
+ subtitle?: ReactNode;
+ title: ReactNode;
+ tooltip?: string;
+ type?: string;
+ typeLink?: string;
+};
+
+type TypeFragmentProps = {
+ classes: HeaderStyles;
+ pageTitle: string | ReactNode;
+ type?: Props['type'];
+ typeLink?: Props['typeLink'];
+};
+
+type TitleFragmentProps = {
+ classes: HeaderStyles;
+ pageTitle: string | ReactNode;
+ tooltip?: Props['tooltip'];
+};
+
+type SubtitleFragmentProps = {
+ classes: HeaderStyles;
+ subtitle?: Props['subtitle'];
+};
+
+const TypeFragment = ({
+ type,
+ typeLink,
+ classes,
+ pageTitle,
+}: TypeFragmentProps) => {
+ if (!type) {
+ return null;
+ }
+
+ if (!typeLink) {
+ return {type} ;
+ }
+
+ return (
+
+ {type}
+ {pageTitle}
+
+ );
+};
+
+const TitleFragment = ({ pageTitle, classes, tooltip }: TitleFragmentProps) => {
+ const FinalTitle = (
+
+ {pageTitle}
+
+ );
+
+ if (!tooltip) {
+ return FinalTitle;
+ }
+
+ return (
+
+ {FinalTitle}
+
+ );
+};
+
+const SubtitleFragment = ({ classes, subtitle }: SubtitleFragmentProps) => {
+ if (!subtitle) {
+ return null;
+ }
+
+ if (typeof subtitle !== 'string') {
+ return <>{subtitle}>;
+ }
+
+ return (
+
+ {subtitle}
+
+ );
+};
+
+export const Header = ({
+ children,
+ pageTitleOverride,
+ style,
+ subtitle,
+ title,
+ tooltip,
+ type,
+ typeLink,
+}: PropsWithChildren) => {
+ const classes = useStyles();
+ const documentTitle = pageTitleOverride || title;
+ const pageTitle = title || pageTitleOverride;
+ const titleTemplate = `${documentTitle} | %s | Backstage`;
+ const defaultTitle = `${documentTitle} | Backstage`;
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/packages/components/src/layout/Header/index.ts b/packages/components/src/layout/Header/index.ts
new file mode 100644
index 0000000000..e0860413c9
--- /dev/null
+++ b/packages/components/src/layout/Header/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 { Header } from './Header';
diff --git a/packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx b/packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx
new file mode 100644
index 0000000000..76ede5398d
--- /dev/null
+++ b/packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.test.tsx
@@ -0,0 +1,104 @@
+/*
+ * 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 {
+ wrapInTestApp,
+ Keyboard,
+ renderInTestApp,
+} from '@backstage/test-utils';
+import { HeaderActionMenu } from './HeaderActionMenu';
+
+describe(' ', () => {
+ it('renders without any items and without exploding', async () => {
+ await renderInTestApp( );
+ });
+
+ it('can open the menu and click menu items', async () => {
+ const onClickFunction = jest.fn();
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.queryByText('Some label')).not.toBeInTheDocument();
+ expect(onClickFunction).not.toHaveBeenCalled();
+ fireEvent.click(rendered.getByTestId('header-action-menu'));
+ expect(onClickFunction).not.toHaveBeenCalled();
+ expect(rendered.getByTestId('header-action-item')).not.toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ fireEvent.click(rendered.queryByText('Some label') as Node);
+ expect(onClickFunction).toHaveBeenCalled();
+ // We do not expect the dropdown to disappear after click
+ expect(rendered.queryByText('Some label')).toBeInTheDocument();
+ });
+
+ it('Disabled', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+
+ fireEvent.click(rendered.getByTestId('header-action-menu'));
+ expect(rendered.getByTestId('header-action-item')).toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ });
+
+ it('Test wrapper, and secondary label', async () => {
+ const onClickFunction = jest.fn();
+ const rendered = await renderInTestApp(
+ (
+ {children}
+ ),
+ },
+ ]}
+ />,
+ );
+
+ expect(onClickFunction).not.toHaveBeenCalled();
+ fireEvent.click(rendered.getByTestId('header-action-menu'));
+ expect(onClickFunction).not.toHaveBeenCalled();
+ fireEvent.click(rendered.queryByText('Secondary label') as Node);
+ expect(onClickFunction).toHaveBeenCalled();
+ // We do not expect the dropdown to disappear after click
+ expect(rendered.queryByText('Some label')).toBeInTheDocument();
+ });
+
+ it('should close when hitting escape', async () => {
+ const rendered = render(
+ wrapInTestApp(
+ ,
+ ),
+ );
+
+ expect(rendered.container.getAttribute('aria-hidden')).toBeNull();
+ fireEvent.click(rendered.getByTestId('header-action-menu'));
+ expect(rendered.container.getAttribute('aria-hidden')).toBe('true');
+ await Keyboard.type(rendered, '');
+ expect(rendered.container.getAttribute('aria-hidden')).toBeNull();
+ });
+});
diff --git a/packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx b/packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx
new file mode 100644
index 0000000000..6f0b337b54
--- /dev/null
+++ b/packages/components/src/layout/HeaderActionMenu/HeaderActionMenu.tsx
@@ -0,0 +1,106 @@
+/*
+ * 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, { Fragment, ReactElement, ComponentType } from 'react';
+import {
+ IconButton,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+ Popover,
+ ListItemTextProps,
+} from '@material-ui/core';
+import { VerticalMenuIcon } from './VerticalMenuIcon';
+
+type ActionItemProps = {
+ label?: ListItemTextProps['primary'];
+ secondaryLabel?: ListItemTextProps['secondary'];
+ icon?: ReactElement;
+ disabled?: boolean;
+ onClick?: (event: React.MouseEvent) => void;
+ WrapperComponent?: ComponentType;
+};
+
+const ActionItem = ({
+ label,
+ secondaryLabel,
+ icon,
+ disabled = false,
+ onClick,
+ WrapperComponent = React.Fragment,
+}: ActionItemProps) => {
+ return (
+
+ {
+ if (onClick) {
+ onClick(event);
+ }
+ }}
+ >
+ {icon && {icon} }
+
+
+
+ );
+};
+
+export type HeaderActionMenuProps = {
+ actionItems: ActionItemProps[];
+};
+
+export const HeaderActionMenu = ({ actionItems }: HeaderActionMenuProps) => {
+ const [open, setOpen] = React.useState(false);
+ const anchorElRef = React.useRef(null);
+
+ return (
+
+ setOpen(true)}
+ data-testid="header-action-menu"
+ ref={anchorElRef}
+ style={{
+ color: 'white',
+ height: 56,
+ width: 56,
+ marginRight: -4,
+ padding: 0,
+ }}
+ >
+
+
+ setOpen(false)}
+ >
+
+ {actionItems.map((actionItem, i) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx b/packages/components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx
new file mode 100644
index 0000000000..c9fde8cea2
--- /dev/null
+++ b/packages/components/src/layout/HeaderActionMenu/VerticalMenuIcon.tsx
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import SvgIcon, { SvgIconProps } from '@material-ui/core/SvgIcon';
+
+export const VerticalMenuIcon = (props: SvgIconProps) =>
+ React.createElement(
+ SvgIcon,
+ props,
+ ,
+ );
diff --git a/packages/components/src/layout/HeaderActionMenu/index.ts b/packages/components/src/layout/HeaderActionMenu/index.ts
new file mode 100644
index 0000000000..bb7fa80d59
--- /dev/null
+++ b/packages/components/src/layout/HeaderActionMenu/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 { HeaderActionMenu } from './HeaderActionMenu';
diff --git a/packages/components/src/layout/HeaderLabel/HeaderLabel.test.tsx b/packages/components/src/layout/HeaderLabel/HeaderLabel.test.tsx
new file mode 100644
index 0000000000..acadb22525
--- /dev/null
+++ b/packages/components/src/layout/HeaderLabel/HeaderLabel.test.tsx
@@ -0,0 +1,54 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { HeaderLabel } from './HeaderLabel';
+
+describe(' ', () => {
+ it('should have a label', async () => {
+ const rendered = await renderInTestApp( );
+ expect(rendered.getByText('Label')).toBeInTheDocument();
+ });
+
+ it('should say unknown', async () => {
+ const rendered = await renderInTestApp( );
+ expect(rendered.getByText('')).toBeInTheDocument();
+ });
+
+ it('should say unknown when passing null as value prop', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('')).toBeInTheDocument();
+ });
+
+ it('should have value', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ expect(rendered.getByText('Value')).toBeInTheDocument();
+ });
+
+ it('should have a link', async () => {
+ const rendered = await renderInTestApp(
+ ,
+ );
+ const anchor = rendered.container.querySelector('a') as HTMLAnchorElement;
+ expect(rendered.getByText('Value')).toBeInTheDocument();
+ expect(anchor.href).toBe('http://localhost/test');
+ });
+});
diff --git a/packages/components/src/layout/HeaderLabel/HeaderLabel.tsx b/packages/components/src/layout/HeaderLabel/HeaderLabel.tsx
new file mode 100644
index 0000000000..914be06cf2
--- /dev/null
+++ b/packages/components/src/layout/HeaderLabel/HeaderLabel.tsx
@@ -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 { Link, makeStyles, Typography } from '@material-ui/core';
+import React from 'react';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ textAlign: 'left',
+ margin: theme.spacing(2),
+ display: 'inline-block',
+ },
+ label: {
+ color: '#FFFFFF',
+ fontWeight: 'bold',
+ lineHeight: '16px',
+ letterSpacing: 0,
+ fontSize: 14,
+ height: '16px',
+ marginBottom: 2,
+ },
+ value: {
+ color: 'rgba(255, 255, 255, 0.8)',
+ lineHeight: '16px',
+ fontSize: 14,
+ height: '16px',
+ },
+}));
+
+type HeaderLabelContentProps = {
+ value: React.ReactNode;
+ className: string;
+};
+
+const HeaderLabelContent = ({ value, className }: HeaderLabelContentProps) => (
+ {value}
+);
+
+type HeaderLabelProps = {
+ label: string;
+ value?: HeaderLabelContentProps['value'];
+ url?: string;
+};
+
+export const HeaderLabel = ({ label, value, url }: HeaderLabelProps) => {
+ const classes = useStyles();
+ const content = (
+ '}
+ />
+ );
+ return (
+
+ {label}
+ {url ? {content} : content}
+
+ );
+};
diff --git a/packages/components/src/layout/HeaderLabel/index.ts b/packages/components/src/layout/HeaderLabel/index.ts
new file mode 100644
index 0000000000..683ec59784
--- /dev/null
+++ b/packages/components/src/layout/HeaderLabel/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 { HeaderLabel } from './HeaderLabel';
diff --git a/packages/components/src/layout/HeaderTabs/HeaderTabs.test.tsx b/packages/components/src/layout/HeaderTabs/HeaderTabs.test.tsx
new file mode 100644
index 0000000000..81d2123049
--- /dev/null
+++ b/packages/components/src/layout/HeaderTabs/HeaderTabs.test.tsx
@@ -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 React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { HeaderTabs } from './';
+
+const mockTabs = [
+ { id: 'overview', label: 'Overview' },
+ { id: 'docs', label: 'Docs' },
+];
+
+describe(' ', () => {
+ it('should render tabs', async () => {
+ const rendered = await renderInTestApp( );
+
+ expect(rendered.getByText('Overview')).toBeInTheDocument();
+ expect(rendered.getByText('Docs')).toBeInTheDocument();
+ });
+
+ it('should render correct selected tab', async () => {
+ const rendered = await renderInTestApp( );
+
+ expect(rendered.getByText('Docs').parentElement).toHaveAttribute(
+ 'aria-selected',
+ 'false',
+ );
+
+ rendered.getByText('Docs').click();
+
+ expect(rendered.getByText('Docs').parentElement).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+ });
+});
diff --git a/packages/components/src/layout/HeaderTabs/HeaderTabs.tsx b/packages/components/src/layout/HeaderTabs/HeaderTabs.tsx
new file mode 100644
index 0000000000..718a103f00
--- /dev/null
+++ b/packages/components/src/layout/HeaderTabs/HeaderTabs.tsx
@@ -0,0 +1,95 @@
+/*
+ * 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.
+ */
+
+// TODO(blam): Remove this implementation when the Tabs are ready
+// This is just a temporary solution to implementing tabs for now
+
+import React, { useState, useEffect } from 'react';
+import { makeStyles, Tabs, Tab as TabUI } from '@material-ui/core';
+
+const useStyles = makeStyles(theme => ({
+ tabsWrapper: {
+ gridArea: 'pageSubheader',
+ backgroundColor: theme.palette.background.paper,
+ paddingLeft: theme.spacing(3),
+ },
+ defaultTab: {
+ padding: theme.spacing(3, 3),
+ ...theme.typography.caption,
+ textTransform: 'uppercase',
+ fontWeight: 'bold',
+ color: theme.palette.text.secondary,
+ },
+ selected: {
+ color: theme.palette.text.primary,
+ },
+}));
+
+export type Tab = {
+ id: string;
+ label: string;
+};
+
+type HeaderTabsProps = {
+ tabs: Tab[];
+ onChange?: (index: number) => void;
+ selectedIndex?: number;
+};
+export const HeaderTabs = ({
+ tabs,
+ onChange,
+ selectedIndex,
+}: HeaderTabsProps) => {
+ const [selectedTab, setSelectedTab] = useState(selectedIndex ?? 0);
+ const styles = useStyles();
+
+ const handleChange = (_: React.ChangeEvent<{}>, index: number) => {
+ if (selectedIndex === undefined) {
+ setSelectedTab(index);
+ }
+ if (onChange) onChange(index);
+ };
+
+ useEffect(() => {
+ if (selectedIndex !== undefined) {
+ setSelectedTab(selectedIndex);
+ }
+ }, [selectedIndex]);
+
+ return (
+
+
+ {tabs.map((tab, index) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/components/src/layout/HeaderTabs/index.tsx b/packages/components/src/layout/HeaderTabs/index.tsx
new file mode 100644
index 0000000000..1097698265
--- /dev/null
+++ b/packages/components/src/layout/HeaderTabs/index.tsx
@@ -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 type { Tab } from './HeaderTabs';
+export { HeaderTabs } from './HeaderTabs';
diff --git a/packages/components/src/layout/HomepageTimer/HomepageTimer.test.tsx b/packages/components/src/layout/HomepageTimer/HomepageTimer.test.tsx
new file mode 100644
index 0000000000..40b0c266e0
--- /dev/null
+++ b/packages/components/src/layout/HomepageTimer/HomepageTimer.test.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 { renderWithEffects } from '@backstage/test-utils';
+import { HomepageTimer } from './HomepageTimer';
+import React from 'react';
+import { lightTheme } from '@backstage/theme';
+import { ThemeProvider } from '@material-ui/core';
+
+import {
+ ApiProvider,
+ ApiRegistry,
+ ConfigReader,
+ ConfigApi,
+ configApiRef,
+} from '@backstage/core-api';
+
+it('changes default timezone to GMT', async () => {
+ const configApi: ConfigApi = new ConfigReader({
+ homepage: {
+ clocks: [
+ {
+ label: 'New York',
+ timezone: 'America/New_Pork',
+ },
+ ],
+ },
+ context: 'test',
+ });
+
+ const rendered = await renderWithEffects(
+
+
+
+
+ ,
+ );
+
+ expect(rendered.getByText('GMT')).toBeInTheDocument();
+});
diff --git a/packages/components/src/layout/HomepageTimer/HomepageTimer.tsx b/packages/components/src/layout/HomepageTimer/HomepageTimer.tsx
new file mode 100644
index 0000000000..2adbf8fbd5
--- /dev/null
+++ b/packages/components/src/layout/HomepageTimer/HomepageTimer.tsx
@@ -0,0 +1,99 @@
+/*
+ * 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 { HeaderLabel } from '../HeaderLabel';
+import { ConfigApi, useApi, configApiRef } from '@backstage/core-api';
+
+const timeFormat = { hour: '2-digit', minute: '2-digit' };
+
+type TimeObj = {
+ time: string;
+ label: string;
+};
+
+function getTimes(configApi: ConfigApi) {
+ const d = new Date();
+ const lang = window.navigator.language;
+
+ const clocks: TimeObj[] = [];
+
+ if (!configApi.has('homepage.clocks')) {
+ return clocks;
+ }
+
+ const clockConfigs = configApi.getConfigArray('homepage.clocks');
+
+ for (const clock of clockConfigs) {
+ if (clock.has('label') && clock.has('timezone')) {
+ let label = clock.getString('label');
+
+ const options = {
+ timeZone: clock.getString('timezone'),
+ ...timeFormat,
+ };
+
+ try {
+ new Date().toLocaleString(lang, options);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `The timezone ${options.timeZone} is invalid. Defaulting to GMT`,
+ );
+ options.timeZone = 'GMT';
+ label = 'GMT';
+ }
+
+ const time = d.toLocaleTimeString(lang, options);
+ clocks.push({ time, label });
+ }
+ }
+ return clocks;
+}
+
+export const HomepageTimer = () => {
+ const configApi = useApi(configApiRef);
+
+ const defaultTimes: TimeObj[] = [];
+ const [clocks, setTimes] = React.useState(defaultTimes);
+
+ React.useEffect(() => {
+ setTimes(getTimes(configApi));
+
+ const intervalId = setInterval(() => {
+ setTimes(getTimes(configApi));
+ }, 1000);
+
+ return () => {
+ clearInterval(intervalId);
+ };
+ }, [configApi]);
+
+ if (clocks.length !== 0) {
+ return (
+ <>
+ {clocks.map(clock => (
+
+ ))}
+ >
+ );
+ }
+ return null;
+};
diff --git a/packages/components/src/layout/HomepageTimer/index.ts b/packages/components/src/layout/HomepageTimer/index.ts
new file mode 100644
index 0000000000..facee1e982
--- /dev/null
+++ b/packages/components/src/layout/HomepageTimer/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 { HomepageTimer } from './HomepageTimer';
diff --git a/packages/components/src/layout/InfoCard/InfoCard.stories.tsx b/packages/components/src/layout/InfoCard/InfoCard.stories.tsx
new file mode 100644
index 0000000000..0c3c523ba0
--- /dev/null
+++ b/packages/components/src/layout/InfoCard/InfoCard.stories.tsx
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Grid, Typography } from '@material-ui/core';
+import React, { PropsWithChildren } from 'react';
+import { MemoryRouter } from 'react-router';
+import { InfoCard } from '.';
+
+const linkInfo = { title: 'Go to XYZ Location', link: '#' };
+
+export default {
+ title: 'Layout/Information Card',
+ component: InfoCard,
+};
+
+const text = (
+
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
+ tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
+ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
+ consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
+ cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
+ non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
+
+);
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+
+
+
+ {children}
+
+
+
+);
+
+export const Default = () => (
+
+ {text}
+
+);
+
+export const Subhead = () => (
+
+
+ {text}
+
+
+);
+
+export const LinkInFooter = () => (
+
+
+ {text}
+
+
+);
diff --git a/packages/components/src/layout/InfoCard/InfoCard.test.tsx b/packages/components/src/layout/InfoCard/InfoCard.test.tsx
new file mode 100644
index 0000000000..f82d51fe06
--- /dev/null
+++ b/packages/components/src/layout/InfoCard/InfoCard.test.tsx
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { renderInTestApp } from '@backstage/test-utils';
+import { InfoCard } from './InfoCard';
+
+const minProps = {
+ title: 'Some title',
+ deepLink: {
+ title: 'A deepLink title',
+ link: '/mocked',
+ },
+};
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp( );
+ expect(rendered.getByText('Some title')).toBeInTheDocument();
+ });
+
+ it('renders a deepLink when prop is set', async () => {
+ const rendered = await renderInTestApp( );
+ expect(rendered.getByText('A deepLink title')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/layout/InfoCard/InfoCard.tsx b/packages/components/src/layout/InfoCard/InfoCard.tsx
new file mode 100644
index 0000000000..cf06a5f0a1
--- /dev/null
+++ b/packages/components/src/layout/InfoCard/InfoCard.tsx
@@ -0,0 +1,208 @@
+/*
+ * 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, { ReactNode } from 'react';
+import {
+ Card,
+ CardActions,
+ CardContent,
+ CardHeader,
+ CardHeaderProps,
+ Divider,
+ withStyles,
+ makeStyles,
+} from '@material-ui/core';
+import classNames from 'classnames';
+import { ErrorBoundary } from '../ErrorBoundary';
+import { BottomLink, BottomLinkProps } from '../BottomLink';
+
+const useStyles = makeStyles(theme => ({
+ noPadding: {
+ padding: 0,
+ '&:last-child': {
+ paddingBottom: 0,
+ },
+ },
+ header: {
+ display: 'inline-block',
+ padding: theme.spacing(2, 2, 2, 2.5),
+ },
+ headerTitle: {
+ fontWeight: 700,
+ },
+ headerSubheader: {
+ paddingTop: theme.spacing(1),
+ },
+ headerAvatar: {},
+ headerAction: {},
+ headerContent: {},
+}));
+
+const CardActionsTopRight = withStyles(theme => ({
+ root: {
+ display: 'inline-block',
+ padding: theme.spacing(8, 8, 0, 0),
+ float: 'right',
+ },
+}))(CardActions);
+
+const VARIANT_STYLES = {
+ card: {
+ flex: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ fullHeight: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ },
+ gridItem: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: 'calc(100% - 10px)', // for pages without content header
+ marginBottom: '10px',
+ },
+ },
+ cardContent: {
+ fullHeight: {
+ flex: 1,
+ },
+ gridItem: {
+ flex: 1,
+ },
+ },
+};
+
+export type InfoCardVariants = 'flex' | 'fullHeight' | 'gridItem';
+
+/**
+ * InfoCard is used to display a paper-styled block on the screen, similar to a panel.
+ *
+ * You can custom style an InfoCard with the 'style' (outer container) and 'cardStyle' (inner container)
+ * styles.
+ *
+ * The InfoCard serves as an error boundary. As a result, if you provide a 'slackChannel' property this
+ * specifies the channel to display in the error component that is displayed if an error occurs
+ * in any descendent components.
+ *
+ * By default the InfoCard has no custom layout of its children, but is treated as a block element. A
+ * couple common variants are provided and can be specified via the variant property:
+ *
+ * When the InfoCard is displayed as a grid item within a grid, you may want items to have the same height for all items.
+ * Set to the 'gridItem' variant to display the InfoCard with full height suitable for Grid:
+ *
+ * ...
+ */
+type Props = {
+ title?: ReactNode;
+ subheader?: ReactNode;
+ divider?: boolean;
+ deepLink?: BottomLinkProps;
+ slackChannel?: string;
+ variant?: InfoCardVariants;
+ style?: object;
+ cardStyle?: object;
+ children?: ReactNode;
+ headerStyle?: object;
+ headerProps?: CardHeaderProps;
+ actionsClassName?: string;
+ actions?: ReactNode;
+ cardClassName?: string;
+ actionsTopRight?: ReactNode;
+ className?: string;
+ noPadding?: boolean;
+};
+
+export const InfoCard = ({
+ title,
+ subheader,
+ divider = true,
+ deepLink,
+ slackChannel = '#backstage',
+ variant,
+ children,
+ headerStyle,
+ headerProps,
+ actionsClassName,
+ actions,
+ cardClassName,
+ actionsTopRight,
+ className,
+ noPadding,
+}: Props): JSX.Element => {
+ const classes = useStyles();
+ /**
+ * If variant is specified, we build up styles for that particular variant for both
+ * the Card and the CardContent (since these need to be synced)
+ */
+ let calculatedStyle = {};
+ let calculatedCardStyle = {};
+ if (variant) {
+ const variants = variant.split(/[\s]+/g);
+ variants.forEach(name => {
+ calculatedStyle = {
+ ...calculatedStyle,
+ ...VARIANT_STYLES.card[name as keyof typeof VARIANT_STYLES['card']],
+ };
+ calculatedCardStyle = {
+ ...calculatedCardStyle,
+ ...VARIANT_STYLES.cardContent[
+ name as keyof typeof VARIANT_STYLES['cardContent']
+ ],
+ };
+ });
+ }
+
+ return (
+
+
+ {title && (
+
+ )}
+ {actionsTopRight && (
+ {actionsTopRight}
+ )}
+ {divider && }
+
+ {children}
+
+ {actions && (
+ {actions}
+ )}
+ {deepLink && }
+
+
+ );
+};
diff --git a/packages/components/src/layout/InfoCard/index.ts b/packages/components/src/layout/InfoCard/index.ts
new file mode 100644
index 0000000000..35829662d0
--- /dev/null
+++ b/packages/components/src/layout/InfoCard/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 { InfoCard } from './InfoCard';
+export type { InfoCardVariants } from './InfoCard';
diff --git a/packages/components/src/layout/ItemCard/ItemCard.stories.tsx b/packages/components/src/layout/ItemCard/ItemCard.stories.tsx
new file mode 100644
index 0000000000..56d2328454
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCard.stories.tsx
@@ -0,0 +1,113 @@
+/*
+ * 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 {
+ Card,
+ CardActions,
+ CardContent,
+ CardMedia,
+ makeStyles,
+ Typography,
+} from '@material-ui/core';
+import React from 'react';
+import { MemoryRouter } from 'react-router';
+import { Button } from '../../components';
+import { ItemCardGrid } from './ItemCardGrid';
+import { ItemCardHeader } from './ItemCardHeader';
+
+export default {
+ title: 'Layout/Item Cards',
+};
+
+const text =
+ 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.';
+
+const useStyles = makeStyles({
+ grid: {
+ gridTemplateColumns: 'repeat(auto-fill, 12em)',
+ },
+ header: {
+ color: 'black',
+ backgroundImage: 'linear-gradient(to bottom right, red, yellow)',
+ },
+});
+
+export const Default = () => (
+
+
+ The most basic setup is to place a bunch of cards into a large grid,
+ leaving styling to the defaults. Try to resize the window to see how they
+ rearrange themselves to fit the viewport.
+
+
+ {[...Array(10).keys()].map(index => (
+
+
+
+
+
+ {text
+ .split(' ')
+ .slice(0, 5 + Math.floor(Math.random() * 30))
+ .join(' ')}
+
+
+
+ Go There!
+
+
+
+ ))}
+
+
+);
+
+export const Styling = () => {
+ const classes = useStyles();
+ return (
+
+
+ Both the grid and the header can be styled, using the{' '}
+ classes property. This lets
+ you for example tweak the column sizes and the background of the header.
+
+
+ {[...Array(10).keys()].map(index => (
+
+
+
+
+
+ {text
+ .split(' ')
+ .slice(0, 5 + Math.floor(Math.random() * 30))
+ .join(' ')}
+
+
+
+ Go There!
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/packages/components/src/layout/ItemCard/ItemCard.test.tsx b/packages/components/src/layout/ItemCard/ItemCard.test.tsx
new file mode 100644
index 0000000000..30cf95fd85
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCard.test.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { renderInTestApp } from '@backstage/test-utils';
+import React from 'react';
+import { ItemCard } from './ItemCard';
+
+const minProps = {
+ description: 'This is the description of an Item Card',
+ label: 'Button',
+ title: 'Item Card',
+};
+
+describe(' ', () => {
+ it('renders default without exploding', async () => {
+ const { description, label, title } = minProps;
+ const { getByText } = await renderInTestApp( );
+ expect(getByText(description)).toBeInTheDocument();
+ expect(getByText(title)).toBeInTheDocument();
+ expect(getByText(label)).toBeInTheDocument();
+ });
+
+ it('renders with subtitle without exploding', async () => {
+ const { description, label, title } = minProps;
+ const subtitle = 'Pretitle';
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ expect(getByText(description)).toBeInTheDocument();
+ expect(getByText(title)).toBeInTheDocument();
+ expect(getByText(label)).toBeInTheDocument();
+ expect(getByText(subtitle)).toBeInTheDocument();
+ });
+
+ it('renders with tags without exploding', async () => {
+ const { description, label, title } = minProps;
+ const tags = ['tag one', 'tag two'];
+ const { getByText } = await renderInTestApp(
+ ,
+ );
+ expect(getByText(description)).toBeInTheDocument();
+ expect(getByText(title)).toBeInTheDocument();
+ expect(getByText(label)).toBeInTheDocument();
+ tags.forEach(tag => {
+ expect(getByText(tag)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/components/src/layout/ItemCard/ItemCard.tsx b/packages/components/src/layout/ItemCard/ItemCard.tsx
new file mode 100644
index 0000000000..8c26e6a9f1
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCard.tsx
@@ -0,0 +1,104 @@
+/*
+ * 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 {
+ Box,
+ Card,
+ CardActions,
+ CardContent,
+ CardMedia,
+ Chip,
+} from '@material-ui/core';
+import React, { ReactNode } from 'react';
+import { Button } from '../../components';
+import { ItemCardHeader } from './ItemCardHeader';
+
+type ItemCardProps = {
+ description?: string;
+ tags?: string[];
+ title: string;
+ /** @deprecated Use subtitle instead */
+ type?: string;
+ subtitle?: ReactNode;
+ label: string;
+ onClick?: () => void;
+ href?: string;
+};
+
+/**
+ * This card type has been deprecated. Instead use plain MUI Card and helpers
+ * where appropriate.
+ *
+ *
+ *
+ *
+ *
+ * @deprecated Use plain MUI and composable helpers instead.
+ * @see https://material-ui.com/components/cards/
+ */
+export const ItemCard = ({
+ description,
+ tags,
+ title,
+ type,
+ subtitle,
+ label,
+ onClick,
+ href,
+}: ItemCardProps) => {
+ return (
+
+
+
+
+
+ {tags?.length ? (
+
+ {tags.map((tag, i) => (
+
+ ))}
+
+ ) : null}
+ {description}
+
+
+ {!href && (
+
+ {label}
+
+ )}
+ {href && (
+
+ {label}
+
+ )}
+
+
+ );
+};
diff --git a/packages/components/src/layout/ItemCard/ItemCardGrid.test.tsx b/packages/components/src/layout/ItemCard/ItemCardGrid.test.tsx
new file mode 100644
index 0000000000..51ef8c544f
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCardGrid.test.tsx
@@ -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 { renderInTestApp } from '@backstage/test-utils';
+import { Card } from '@material-ui/core';
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { ItemCardGrid } from './ItemCardGrid';
+
+describe(' ', () => {
+ it('renders default without exploding', async () => {
+ await renderInTestApp(
+
+ Hello!
+ ,
+ );
+ expect(screen.getByRole('grid')).toBeInTheDocument();
+ expect(screen.getByText('Hello!')).toBeInTheDocument();
+ });
+
+ it('renders custom styles', async () => {
+ await renderInTestApp(
+ <>
+
+ Hello!
+
+
+ Goodbye!
+
+ >,
+ );
+ expect(screen.getAllByRole('grid')[0]).toHaveStyle({
+ gridTemplateColumns: 'repeat(auto-fill, minmax(22em, 1fr))',
+ });
+ expect(screen.getAllByRole('grid')[1]).toHaveClass('my-css-class');
+ });
+});
diff --git a/packages/components/src/layout/ItemCard/ItemCardGrid.tsx b/packages/components/src/layout/ItemCard/ItemCardGrid.tsx
new file mode 100644
index 0000000000..551c3c67d5
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCardGrid.tsx
@@ -0,0 +1,62 @@
+/*
+ * 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 { createStyles, makeStyles, Theme, WithStyles } from '@material-ui/core';
+import React from 'react';
+
+const styles = (theme: Theme) =>
+ createStyles({
+ root: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fill, minmax(22em, 1fr))',
+ gridAutoRows: '1fr',
+ gridGap: theme.spacing(2),
+ },
+ });
+
+const useStyles = makeStyles(styles);
+
+export type ItemCardGridProps = Partial> & {
+ /**
+ * The Card items of the grid.
+ */
+ children?: React.ReactNode;
+};
+
+/**
+ * A default grid to use when arranging "item cards" - cards that let users
+ * select among several options.
+ *
+ * The immediate children are expected to be MUI Card components.
+ *
+ * Styles for the grid can be overridden using the `classes` prop, e.g.:
+ *
+ *
+ *
+ *
+ *
+ * This can be useful for e.g. overriding gridTemplateColumns to adapt the
+ * minimum size of the cells to fit the content better.
+ */
+export const ItemCardGrid = (props: ItemCardGridProps) => {
+ const { children, ...otherProps } = props;
+ const classes = useStyles(otherProps);
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/components/src/layout/ItemCard/ItemCardHeader.test.tsx b/packages/components/src/layout/ItemCard/ItemCardHeader.test.tsx
new file mode 100644
index 0000000000..ec9af6436e
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCardHeader.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 { renderInTestApp } from '@backstage/test-utils';
+import { Card, CardMedia } from '@material-ui/core';
+import { screen } from '@testing-library/react';
+import React from 'react';
+import { ItemCardHeader } from './ItemCardHeader';
+
+describe(' ', () => {
+ it('renders default without exploding', async () => {
+ await renderInTestApp(
+
+
+
+
+ ,
+ );
+ expect(screen.getByText('My Title')).toBeInTheDocument();
+ expect(screen.getByText('My Subtitle')).toBeInTheDocument();
+ });
+
+ it('renders custom children', async () => {
+ await renderInTestApp(
+
+
+ My Custom Text
+
+ ,
+ );
+ expect(screen.getByText('My Title')).toBeInTheDocument();
+ expect(screen.getByText('My Custom Text')).toBeInTheDocument();
+ });
+
+ it('renders custom styles', async () => {
+ await renderInTestApp(
+
+
+
+ My Custom Text
+
+
+ ,
+ );
+ expect(screen.getByText('My Custom Text')).toHaveClass('my-css-class');
+ });
+});
diff --git a/packages/components/src/layout/ItemCard/ItemCardHeader.tsx b/packages/components/src/layout/ItemCard/ItemCardHeader.tsx
new file mode 100644
index 0000000000..17c4a19e58
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/ItemCardHeader.tsx
@@ -0,0 +1,85 @@
+/*
+ * 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 {
+ createStyles,
+ makeStyles,
+ Theme,
+ Typography,
+ WithStyles,
+} from '@material-ui/core';
+import React from 'react';
+
+const styles = (theme: Theme) =>
+ createStyles({
+ root: {
+ color: theme.palette.common.white,
+ padding: theme.spacing(2, 2, 3),
+ backgroundImage: 'linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)',
+ backgroundPosition: 0,
+ backgroundSize: 'inherit',
+ },
+ });
+
+const useStyles = makeStyles(styles);
+
+export type ItemCardHeaderProps = Partial> & {
+ /**
+ * A large title to show in the header, providing the main heading.
+ *
+ * Use this if you want to have the default styling and placement of a title.
+ */
+ title?: React.ReactNode;
+ /**
+ * A slightly smaller title to show in the header, providing additional
+ * details.
+ *
+ * Use this if you want to have the default styling and placement of a
+ * subtitle.
+ */
+ subtitle?: React.ReactNode;
+ /**
+ * Custom children to draw in the header.
+ *
+ * If the title and/or subtitle were specified, the children are drawn below
+ * those.
+ */
+ children?: React.ReactNode;
+};
+
+/**
+ * A simple card header, rendering a default look for "item cards" - cards that
+ * are arranged in a grid for users to select among several options.
+ *
+ * This component expects to be placed within a MUI .
+ *
+ * Styles for the header can be overridden using the `classes` prop, e.g.:
+ *
+ *
+ *
+ *
+ */
+export const ItemCardHeader = (props: ItemCardHeaderProps) => {
+ const { title, subtitle, children } = props;
+ const classes = useStyles(props);
+ return (
+
+ {subtitle && {subtitle} }
+ {title && {title} }
+ {children}
+
+ );
+};
diff --git a/packages/components/src/layout/ItemCard/index.ts b/packages/components/src/layout/ItemCard/index.ts
new file mode 100644
index 0000000000..da2c1dd546
--- /dev/null
+++ b/packages/components/src/layout/ItemCard/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 { ItemCard } from './ItemCard';
+export { ItemCardGrid } from './ItemCardGrid';
+export type { ItemCardGridProps } from './ItemCardGrid';
+export { ItemCardHeader } from './ItemCardHeader';
+export type { ItemCardHeaderProps } from './ItemCardHeader';
diff --git a/packages/components/src/layout/Page/Page.stories.tsx b/packages/components/src/layout/Page/Page.stories.tsx
new file mode 100644
index 0000000000..79d9fa5d72
--- /dev/null
+++ b/packages/components/src/layout/Page/Page.stories.tsx
@@ -0,0 +1,245 @@
+/*
+ * 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 { Box, Chip, Grid, Link, Typography } from '@material-ui/core';
+import React, { useState } from 'react';
+import { MemoryRouter } from 'react-router';
+import {
+ Content,
+ ContentHeader,
+ Header,
+ HeaderLabel,
+ HeaderTabs,
+ InfoCard,
+ Page,
+} from '../';
+import { createApp } from '../../api-wrappers';
+import {
+ GaugeCard,
+ StatusOK,
+ SupportButton,
+ Table,
+ TableColumn,
+ TrendLine,
+} from '../../components';
+
+export default {
+ title: 'Plugins/Examples',
+ component: Page,
+};
+
+interface TableData {
+ id: number;
+ branch: string;
+ hash: string;
+ status: string;
+}
+
+const generateTestData = (rows = 10) => {
+ const data: Array = [];
+ while (data.length <= rows) {
+ data.push({
+ id: data.length + 18534,
+ branch: 'techdocs: modify documentation header',
+ hash: 'techdocs/docs-header 5749c98e3f61f8bb116e5cb87b0e4e1 ',
+ status: 'Success',
+ });
+ }
+ return data;
+};
+
+const columns: TableColumn[] = [
+ {
+ title: 'ID',
+ field: 'id',
+ highlight: true,
+ type: 'numeric',
+ width: '80px',
+ },
+ {
+ title: 'Message/Source',
+ highlight: true,
+ render: (row: Partial) => (
+ <>
+ {row.branch}
+ {row.hash}
+ >
+ ),
+ },
+ {
+ title: 'Status',
+ render: (row: Partial) => (
+
+
+ {row.status}
+
+ ),
+ },
+ {
+ title: 'Tags',
+ render: () => ,
+ width: '10%',
+ },
+];
+
+const tabs = [
+ { label: 'Overview' },
+ { label: 'CI/CD' },
+ { label: 'Cost Efficiency' },
+ { label: 'Code Coverage' },
+ { label: 'Test' },
+ { label: 'Compliance Advisor' },
+];
+
+const DataGrid = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Rightsize GKE deployment
+
+ Services are considered underutilized in GKE when the average usage of
+ requested cores is less than 80%.
+
+ What can I do?
+
+ Review requested core and limit settings. Check HPA target scaling
+ settings in hpa.yaml. The recommended value for
+ targetCPUUtilizationPercentage is 80.
+
+
+ For single pods, there is of course no HPA. But it can also be useful
+ to think about a single pod out of a larger deployment, then modify
+ based on HPA requirements. Within a pod, each container has its own
+ CPU and memory requests and limits.
+
+ Definitions
+
+ A request is a minimum reserved value; a container will never have
+ less than this amount allocated to it, even if it doesn't actually use
+ it. Requests are used for determining what nodes to schedule pods on
+ (bin-packing). The tension here is between not allocating resources we
+ don't need, and having easy-enough access to enough resources to be
+ able to function.
+
+
+ Contact #cost-awareness for information and support.
+
+
+
+
+);
+
+const ExampleHeader = () => (
+
+);
+
+const ExampleContentHeader = ({ selectedTab }: { selectedTab?: number }) => (
+
+
+ This Plugin is an example. This text could provide useful information for
+ the user.
+
+
+);
+
+const app = createApp({ configLoader: async () => [] });
+const AppProvider = app.getProvider();
+
+export const PluginWithData = () => {
+ const [selectedTab, setSelectedTab] = useState(2);
+ return (
+
+
+
+
+
+ setSelectedTab(index)}
+ tabs={tabs.map(({ label }, index) => ({
+ id: index.toString(),
+ label,
+ }))}
+ />
+
+
+
+
+
+
+
+
+ );
+};
+
+export const PluginWithTable = () => {
+ return (
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/Page/Page.tsx b/packages/components/src/layout/Page/Page.tsx
new file mode 100644
index 0000000000..3f04948356
--- /dev/null
+++ b/packages/components/src/layout/Page/Page.tsx
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { PropsWithChildren } from 'react';
+import { BackstageTheme } from '@backstage/theme';
+import { makeStyles, ThemeProvider } from '@material-ui/core';
+
+const useStyles = makeStyles(() => ({
+ root: {
+ display: 'grid',
+ gridTemplateAreas:
+ "'pageHeader pageHeader pageHeader' 'pageSubheader pageSubheader pageSubheader' 'pageNav pageContent pageSidebar'",
+ gridTemplateRows: 'auto auto 1fr',
+ gridTemplateColumns: 'auto 1fr auto',
+ minHeight: '100vh',
+ },
+}));
+
+type Props = {
+ themeId: string;
+};
+
+export const Page = ({ themeId, children }: PropsWithChildren) => {
+ const classes = useStyles();
+ return (
+ ({
+ ...baseTheme,
+ page: baseTheme.getPageTheme({ themeId }),
+ })}
+ >
+ {children}
+
+ );
+};
diff --git a/packages/components/src/layout/Page/index.ts b/packages/components/src/layout/Page/index.ts
new file mode 100644
index 0000000000..987aee5cdb
--- /dev/null
+++ b/packages/components/src/layout/Page/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 { Page } from './Page';
diff --git a/packages/components/src/layout/Sidebar/Bar.tsx b/packages/components/src/layout/Sidebar/Bar.tsx
new file mode 100644
index 0000000000..260593de95
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/Bar.tsx
@@ -0,0 +1,150 @@
+/*
+ * 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 { makeStyles } from '@material-ui/core';
+import clsx from 'clsx';
+import React, { useRef, useState, useContext, PropsWithChildren } from 'react';
+import { sidebarConfig, SidebarContext } from './config';
+import { BackstageTheme } from '@backstage/theme';
+import { SidebarPinStateContext } from './Page';
+
+const useStyles = makeStyles(theme => ({
+ root: {
+ zIndex: 1000,
+ position: 'relative',
+ overflow: 'visible',
+ width: theme.spacing(7) + 1,
+ },
+ drawer: {
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ alignItems: 'flex-start',
+ position: 'fixed',
+ left: 0,
+ top: 0,
+ bottom: 0,
+ padding: 0,
+ background: theme.palette.navigation.background,
+ overflowX: 'hidden',
+ msOverflowStyle: 'none',
+ scrollbarWidth: 'none',
+ width: sidebarConfig.drawerWidthClosed,
+ transition: theme.transitions.create('width', {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.shortest,
+ }),
+ '& > *': {
+ flexShrink: 0,
+ },
+ '&::-webkit-scrollbar': {
+ display: 'none',
+ },
+ },
+ drawerOpen: {
+ width: sidebarConfig.drawerWidthOpen,
+ transition: theme.transitions.create('width', {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.shorter,
+ }),
+ },
+ drawerPeek: {
+ width: sidebarConfig.drawerWidthClosed + 4,
+ },
+}));
+
+enum State {
+ Closed,
+ Peek,
+ Open,
+}
+
+type Props = {
+ openDelayMs?: number;
+ closeDelayMs?: number;
+};
+
+export const Sidebar = ({
+ openDelayMs = sidebarConfig.defaultOpenDelayMs,
+ closeDelayMs = sidebarConfig.defaultCloseDelayMs,
+ children,
+}: PropsWithChildren) => {
+ const classes = useStyles();
+ const [state, setState] = useState(State.Closed);
+ const hoverTimerRef = useRef();
+ const { isPinned } = useContext(SidebarPinStateContext);
+
+ const handleOpen = () => {
+ if (isPinned) {
+ return;
+ }
+ if (hoverTimerRef.current) {
+ clearTimeout(hoverTimerRef.current);
+ hoverTimerRef.current = undefined;
+ }
+ if (state !== State.Open) {
+ hoverTimerRef.current = window.setTimeout(() => {
+ hoverTimerRef.current = undefined;
+ setState(State.Open);
+ }, openDelayMs);
+
+ setState(State.Peek);
+ }
+ };
+
+ const handleClose = () => {
+ if (isPinned) {
+ return;
+ }
+ if (hoverTimerRef.current) {
+ clearTimeout(hoverTimerRef.current);
+ hoverTimerRef.current = undefined;
+ }
+ if (state === State.Peek) {
+ setState(State.Closed);
+ } else if (state === State.Open) {
+ hoverTimerRef.current = window.setTimeout(() => {
+ hoverTimerRef.current = undefined;
+ setState(State.Closed);
+ }, closeDelayMs);
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/components/src/layout/Sidebar/Intro.tsx b/packages/components/src/layout/Sidebar/Intro.tsx
new file mode 100644
index 0000000000..b95719c05f
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/Intro.tsx
@@ -0,0 +1,182 @@
+/*
+ * 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, { useContext, useState } from 'react';
+import { useLocalStorage } from 'react-use';
+import { Link, Typography, makeStyles, Collapse } from '@material-ui/core';
+import CloseIcon from '@material-ui/icons/Close';
+import { BackstageTheme } from '@backstage/theme';
+import {
+ SIDEBAR_INTRO_LOCAL_STORAGE,
+ SidebarContext,
+ sidebarConfig,
+} from './config';
+import { SidebarDivider } from './Items';
+
+const useStyles = makeStyles(theme => ({
+ introCard: {
+ color: '#b5b5b5',
+ // XXX (@koroeskohr): should I be using a Mui theme variable?
+ fontSize: 12,
+ width: sidebarConfig.drawerWidthOpen,
+ marginTop: 18,
+ marginBottom: 12,
+ paddingLeft: sidebarConfig.iconPadding,
+ paddingRight: sidebarConfig.iconPadding,
+ },
+ introDismiss: {
+ display: 'flex',
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ marginTop: 12,
+ },
+ introDismissLink: {
+ color: '#dddddd',
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: 4,
+ '&:hover': {
+ color: theme.palette.linkHover,
+ transition: theme.transitions.create('color', {
+ easing: theme.transitions.easing.sharp,
+ duration: theme.transitions.duration.shortest,
+ }),
+ },
+ },
+ introDismissText: {
+ fontSize: '0.7rem',
+ fontWeight: 'bold',
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ introDismissIcon: {
+ width: 18,
+ height: 18,
+ marginRight: 12,
+ },
+}));
+
+type IntroCardProps = {
+ text: string;
+ onClose: () => void;
+};
+
+export const IntroCard = (props: IntroCardProps) => {
+ const classes = useStyles();
+ const { text, onClose } = props;
+ const handleClose = () => onClose();
+
+ return (
+
+
{text}
+
+
+
+
+ Dismiss
+
+
+
+
+ );
+};
+
+type SidebarIntroLocalStorage = {
+ starredItemsDismissed: boolean;
+ recentlyViewedItemsDismissed: boolean;
+};
+
+type SidebarIntroCardProps = {
+ text: string;
+ onDismiss: () => void;
+};
+
+const SidebarIntroCard = (props: SidebarIntroCardProps) => {
+ const { text, onDismiss } = props;
+ const [collapsing, setCollapsing] = useState(false);
+ const startDismissing = () => {
+ setCollapsing(true);
+ };
+ return (
+
+
+
+ );
+};
+
+const starredIntroText = `Fun fact! As you explore all the awesome plugins in Backstage, you can actually pin them to this side nav.
+Keep an eye out for the little star icon (⭐) next to the plugin name and give it a click!`;
+const recentlyViewedIntroText =
+ 'And your recently viewed plugins will pop up here!';
+
+export const SidebarIntro = () => {
+ const { isOpen } = useContext(SidebarContext);
+ const defaultValue = {
+ starredItemsDismissed: false,
+ recentlyViewedItemsDismissed: false,
+ };
+ const [
+ dismissedIntro,
+ setDismissedIntro,
+ ] = useLocalStorage(SIDEBAR_INTRO_LOCAL_STORAGE);
+
+ const { starredItemsDismissed, recentlyViewedItemsDismissed } =
+ dismissedIntro ?? {};
+
+ const dismissStarred = () => {
+ setDismissedIntro(state => ({
+ ...defaultValue,
+ ...state,
+ starredItemsDismissed: true,
+ }));
+ };
+ const dismissRecentlyViewed = () => {
+ setDismissedIntro(state => ({
+ ...defaultValue,
+ ...state,
+ recentlyViewedItemsDismissed: true,
+ }));
+ };
+
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <>
+ {!starredItemsDismissed && (
+ <>
+
+
+ >
+ )}
+ {!recentlyViewedItemsDismissed && (
+
+ )}
+ >
+ );
+};
diff --git a/packages/components/src/layout/Sidebar/Items.test.tsx b/packages/components/src/layout/Sidebar/Items.test.tsx
new file mode 100644
index 0000000000..fb0ee174f8
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/Items.test.tsx
@@ -0,0 +1,75 @@
+/*
+ * 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 { renderInTestApp } from '@backstage/test-utils';
+import { screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import HomeIcon from '@material-ui/icons/Home';
+import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
+import { Sidebar } from './Bar';
+import { SidebarItem } from './Items';
+import { renderHook } from '@testing-library/react-hooks';
+import { hexToRgb, makeStyles } from '@material-ui/core';
+
+const useStyles = makeStyles({
+ spotlight: {
+ backgroundColor: '#2b2a2a',
+ },
+});
+
+async function renderSidebar() {
+ const { result } = renderHook(() => useStyles());
+
+ await renderInTestApp(
+
+
+ {}}
+ text="Create..."
+ className={result.current.spotlight}
+ />
+ ,
+ );
+ userEvent.hover(screen.getByTestId('sidebar-root'));
+}
+
+describe('Items', () => {
+ beforeEach(async () => {
+ await renderSidebar();
+ });
+
+ describe('SidebarItem', () => {
+ it('should render a link when `to` prop provided', async () => {
+ expect(
+ await screen.findByRole('link', { name: /home/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render a button when `to` prop is not provided', async () => {
+ expect(
+ await screen.findByRole('button', { name: /create/i }),
+ ).toBeInTheDocument();
+ });
+
+ it('should render a button with custom style', async () => {
+ expect(
+ await screen.findByRole('button', { name: /create/i }),
+ ).toHaveStyle(`background-color: ${hexToRgb('2b2a2a')}`);
+ });
+ });
+});
diff --git a/packages/components/src/layout/Sidebar/Items.tsx b/packages/components/src/layout/Sidebar/Items.tsx
new file mode 100644
index 0000000000..e41a45a225
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/Items.tsx
@@ -0,0 +1,295 @@
+/*
+ * 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 '@backstage/core-api';
+import { BackstageTheme } from '@backstage/theme';
+import {
+ Badge,
+ makeStyles,
+ styled,
+ TextField,
+ Typography,
+} from '@material-ui/core';
+import SearchIcon from '@material-ui/icons/Search';
+import clsx from 'clsx';
+import React, {
+ forwardRef,
+ KeyboardEventHandler,
+ ReactNode,
+ useContext,
+ useState,
+} from 'react';
+import { NavLink } from 'react-router-dom';
+import { sidebarConfig, SidebarContext } from './config';
+
+const useStyles = makeStyles(theme => {
+ const {
+ selectedIndicatorWidth,
+ drawerWidthClosed,
+ drawerWidthOpen,
+ iconContainerWidth,
+ iconSize,
+ } = sidebarConfig;
+
+ return {
+ root: {
+ color: theme.palette.navigation.color,
+ display: 'flex',
+ flexFlow: 'row nowrap',
+ alignItems: 'center',
+ height: 48,
+ cursor: 'pointer',
+ },
+ buttonItem: {
+ background: 'none',
+ border: 'none',
+ width: 'auto',
+ margin: 0,
+ padding: 0,
+ textAlign: 'inherit',
+ font: 'inherit',
+ },
+ closed: {
+ width: drawerWidthClosed,
+ justifyContent: 'center',
+ },
+ open: {
+ width: drawerWidthOpen,
+ },
+ label: {
+ // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
+ fontWeight: 'bold',
+ whiteSpace: 'nowrap',
+ lineHeight: 'auto',
+ flex: '3 1 auto',
+ width: '110px',
+ overflow: 'hidden',
+ 'text-overflow': 'ellipsis',
+ },
+ iconContainer: {
+ boxSizing: 'border-box',
+ height: '100%',
+ width: iconContainerWidth,
+ marginRight: -theme.spacing(2),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ icon: {
+ width: iconSize,
+ height: iconSize,
+ },
+ searchRoot: {
+ marginBottom: 12,
+ },
+ searchField: {
+ color: '#b5b5b5',
+ fontWeight: 'bold',
+ fontSize: theme.typography.fontSize,
+ },
+ searchContainer: {
+ width: drawerWidthOpen - iconContainerWidth,
+ },
+ secondaryAction: {
+ width: theme.spacing(6),
+ textAlign: 'center',
+ marginRight: theme.spacing(1),
+ },
+ selected: {
+ '&$root': {
+ borderLeft: `solid ${selectedIndicatorWidth}px ${theme.palette.navigation.indicator}`,
+ color: theme.palette.navigation.selectedColor,
+ },
+ '&$closed': {
+ width: drawerWidthClosed - selectedIndicatorWidth,
+ },
+ '& $iconContainer': {
+ marginLeft: -selectedIndicatorWidth,
+ },
+ },
+ };
+});
+
+type SidebarItemBaseProps = {
+ icon: IconComponent;
+ text?: string;
+ hasNotifications?: boolean;
+ children?: ReactNode;
+ className?: string;
+};
+
+type SidebarItemButtonProps = SidebarItemBaseProps & {
+ onClick: (ev: React.MouseEvent) => void;
+};
+
+type SidebarItemLinkProps = SidebarItemBaseProps & {
+ to: string;
+ onClick?: (ev: React.MouseEvent) => void;
+};
+
+type SidebarItemProps = SidebarItemButtonProps | SidebarItemLinkProps;
+
+function isButtonItem(
+ props: SidebarItemProps,
+): props is SidebarItemButtonProps {
+ return (props as SidebarItemLinkProps).to === undefined;
+}
+
+export const SidebarItem = forwardRef((props, ref) => {
+ const {
+ icon: Icon,
+ text,
+ hasNotifications = false,
+ onClick,
+ children,
+ className,
+ } = props;
+ const classes = useStyles();
+ // XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component
+ // depend on the current location, and at least have it being optionally forced to selected.
+ // Still waiting on a Q answered to fine tune the implementation
+ const { isOpen } = useContext(SidebarContext);
+
+ const itemIcon = (
+
+
+
+ );
+
+ const closedContent = itemIcon;
+
+ const openContent = (
+ <>
+
+ {itemIcon}
+
+ {text && (
+
+ {text}
+
+ )}
+ {children}
+ >
+ );
+
+ const content = isOpen ? openContent : closedContent;
+
+ const childProps = {
+ onClick,
+ className: clsx(
+ className,
+ classes.root,
+ isOpen ? classes.open : classes.closed,
+ isButtonItem(props) && classes.buttonItem,
+ ),
+ };
+
+ if (isButtonItem(props)) {
+ return (
+
+ {content}
+
+ );
+ }
+
+ return (
+
+ {content}
+
+ );
+});
+
+type SidebarSearchFieldProps = {
+ onSearch: (input: string) => void;
+ to?: string;
+};
+
+export const SidebarSearchField = (props: SidebarSearchFieldProps) => {
+ const [input, setInput] = useState('');
+ const classes = useStyles();
+
+ const search = () => {
+ props.onSearch(input);
+ setInput('');
+ };
+
+ const handleEnter: KeyboardEventHandler = ev => {
+ if (ev.key === 'Enter') {
+ search();
+ }
+ };
+
+ const handleInput = (ev: React.ChangeEvent) => {
+ setInput(ev.target.value);
+ };
+
+ const handleInputClick = (ev: React.MouseEvent) => {
+ // Clicking into the search fields shouldn't navigate to the search page
+ ev.preventDefault();
+ ev.stopPropagation();
+ };
+
+ const handleItemClick = (ev: React.MouseEvent) => {
+ // Clicking on the search icon while should execute a query with the current field content
+ search();
+ ev.preventDefault();
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export const SidebarSpace = styled('div')({
+ flex: 1,
+});
+
+export const SidebarSpacer = styled('div')({
+ height: 8,
+});
+
+export const SidebarDivider = styled('hr')({
+ height: 1,
+ width: '100%',
+ background: '#383838',
+ border: 'none',
+ margin: '12px 0px',
+});
diff --git a/packages/components/src/layout/Sidebar/Page.tsx b/packages/components/src/layout/Sidebar/Page.tsx
new file mode 100644
index 0000000000..25717cb053
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/Page.tsx
@@ -0,0 +1,74 @@
+/*
+ * 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 { makeStyles } from '@material-ui/core';
+import React, {
+ createContext,
+ PropsWithChildren,
+ useEffect,
+ useState,
+} from 'react';
+import { sidebarConfig } from './config';
+import { BackstageTheme } from '@backstage/theme';
+import { LocalStorage } from './localStorage';
+
+const useStyles = makeStyles({
+ root: {
+ width: '100%',
+ minHeight: '100%',
+ transition: 'padding-left 0.1s ease-out',
+ paddingLeft: ({ isPinned }) =>
+ isPinned
+ ? sidebarConfig.drawerWidthOpen
+ : sidebarConfig.drawerWidthClosed,
+ },
+});
+
+export type SidebarPinStateContextType = {
+ isPinned: boolean;
+ toggleSidebarPinState: () => any;
+};
+
+export const SidebarPinStateContext = createContext(
+ {
+ isPinned: false,
+ toggleSidebarPinState: () => {},
+ },
+);
+
+export const SidebarPage = (props: PropsWithChildren<{}>) => {
+ const [isPinned, setIsPinned] = useState(() =>
+ LocalStorage.getSidebarPinState(),
+ );
+
+ useEffect(() => {
+ LocalStorage.setSidebarPinState(isPinned);
+ }, [isPinned]);
+
+ const toggleSidebarPinState = () => setIsPinned(!isPinned);
+
+ const classes = useStyles({ isPinned });
+ return (
+
+ {props.children}
+
+ );
+};
diff --git a/packages/components/src/layout/Sidebar/Sidebar.stories.tsx b/packages/components/src/layout/Sidebar/Sidebar.stories.tsx
new file mode 100644
index 0000000000..d6a01fea33
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/Sidebar.stories.tsx
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ Sidebar,
+ SidebarIntro,
+ SidebarItem,
+ SidebarDivider,
+ SidebarSearchField,
+ SidebarSpace,
+} from '.';
+import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
+import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
+import { MemoryRouter } from 'react-router-dom';
+
+export default {
+ title: 'Layout/Sidebar',
+ component: Sidebar,
+ decorators: [
+ (storyFn: () => JSX.Element) => (
+ {storyFn()}
+ ),
+ ],
+};
+
+const handleSearch = (input: string) => {
+ // eslint-disable-next-line no-console
+ console.log(input);
+};
+
+export const SampleSidebar = () => (
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/components/src/layout/Sidebar/config.ts b/packages/components/src/layout/Sidebar/config.ts
new file mode 100644
index 0000000000..8ea6cc94f9
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/config.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 { createContext } from 'react';
+
+const drawerWidthClosed = 72;
+const iconPadding = 24;
+const userBadgePadding = 18;
+
+export const sidebarConfig = {
+ drawerWidthClosed,
+ drawerWidthOpen: 224,
+ // As per NN/g's guidance on timing for exposing hidden content
+ // See https://www.nngroup.com/articles/timing-exposing-content/
+ defaultOpenDelayMs: 300,
+ defaultCloseDelayMs: 0,
+ defaultFadeDuration: 200,
+ logoHeight: 32,
+ iconContainerWidth: drawerWidthClosed,
+ iconSize: drawerWidthClosed - iconPadding * 2,
+ iconPadding,
+ selectedIndicatorWidth: 3,
+ userBadgePadding,
+ userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
+};
+
+export const SIDEBAR_INTRO_LOCAL_STORAGE =
+ '@backstage/core/sidebar-intro-dismissed';
+
+export type SidebarContextType = {
+ isOpen: boolean;
+};
+
+export const SidebarContext = createContext({
+ isOpen: false,
+});
diff --git a/packages/components/src/layout/Sidebar/index.ts b/packages/components/src/layout/Sidebar/index.ts
new file mode 100644
index 0000000000..803306478d
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/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.
+ */
+
+export { Sidebar } from './Bar';
+export { SidebarPage, SidebarPinStateContext } from './Page';
+export type { SidebarPinStateContextType } from './Page';
+export {
+ SidebarDivider,
+ SidebarItem,
+ SidebarSearchField,
+ SidebarSpace,
+ SidebarSpacer,
+} from './Items';
+export { IntroCard, SidebarIntro } from './Intro';
+export {
+ SIDEBAR_INTRO_LOCAL_STORAGE,
+ SidebarContext,
+ sidebarConfig,
+} from './config';
+export type { SidebarContextType } from './config';
diff --git a/packages/components/src/layout/Sidebar/localStorage.test.ts b/packages/components/src/layout/Sidebar/localStorage.test.ts
new file mode 100644
index 0000000000..0cfcac73c5
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/localStorage.test.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { LocalStorage } from './localStorage';
+
+describe('local storage access object', () => {
+ it('should save and read sidebar pin state', () => {
+ LocalStorage.setSidebarPinState(false);
+ expect(LocalStorage.getSidebarPinState()).toBe(false);
+ LocalStorage.setSidebarPinState(true);
+ expect(LocalStorage.getSidebarPinState()).toBe(true);
+ });
+});
diff --git a/packages/components/src/layout/Sidebar/localStorage.ts b/packages/components/src/layout/Sidebar/localStorage.ts
new file mode 100644
index 0000000000..665c97423a
--- /dev/null
+++ b/packages/components/src/layout/Sidebar/localStorage.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+enum LocalStorageKeys {
+ SIDEBAR_PIN_STATE = 'sidebarPinState',
+}
+
+export const LocalStorage = {
+ getSidebarPinState(): boolean {
+ let value;
+ try {
+ value = JSON.parse(
+ window.localStorage.getItem(LocalStorageKeys.SIDEBAR_PIN_STATE) ||
+ 'false',
+ );
+ } catch {
+ return false;
+ }
+ return !!value;
+ },
+ setSidebarPinState(state: boolean) {
+ return window.localStorage.setItem(
+ LocalStorageKeys.SIDEBAR_PIN_STATE,
+ JSON.stringify(state),
+ );
+ },
+};
diff --git a/packages/components/src/layout/SignInPage/SignInPage.tsx b/packages/components/src/layout/SignInPage/SignInPage.tsx
new file mode 100644
index 0000000000..c3e5b75bb7
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/SignInPage.tsx
@@ -0,0 +1,162 @@
+/*
+ * 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, { useEffect, useState } from 'react';
+import { Page } from '../Page';
+import { Header } from '../Header';
+import { Content } from '../Content/Content';
+import { ContentHeader } from '../ContentHeader/ContentHeader';
+import { Grid, Button, Typography } from '@material-ui/core';
+import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api';
+import { useSignInProviders, getSignInProviders } from './providers';
+import { IdentityProviders, SignInConfig } from './types';
+import { Progress } from '../../components/Progress';
+import { GridItem, useStyles } from './styles';
+import { InfoCard } from '../InfoCard';
+
+type MultiSignInPageProps = SignInPageProps & {
+ providers: IdentityProviders;
+ title?: string;
+ align?: 'center' | 'left';
+};
+
+type SingleSignInPageProps = SignInPageProps & {
+ provider: SignInConfig;
+ auto?: boolean;
+};
+
+export type Props = MultiSignInPageProps | SingleSignInPageProps;
+
+export const MultiSignInPage = ({
+ onResult,
+ providers = [],
+ title,
+ align = 'left',
+}: MultiSignInPageProps) => {
+ const configApi = useApi(configApiRef);
+ const classes = useStyles();
+
+ const signInProviders = getSignInProviders(providers);
+ const [loading, providerElements] = useSignInProviders(
+ signInProviders,
+ onResult,
+ );
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+
+
+
+ {title && }
+
+ {providerElements}
+
+
+
+ );
+};
+
+export const SingleSignInPage = ({
+ onResult,
+ provider,
+ auto,
+}: SingleSignInPageProps) => {
+ const classes = useStyles();
+ const authApi = useApi(provider.apiRef);
+ const configApi = useApi(configApiRef);
+
+ const [retry, setRetry] = useState<{} | boolean | undefined>(auto);
+ const [error, setError] = useState();
+
+ useEffect(() => {
+ const login = async () => {
+ const identity = await authApi.getBackstageIdentity({
+ instantPopup: true,
+ });
+
+ const profile = await authApi.getProfile();
+ onResult({
+ userId: identity!.id,
+ profile: profile!,
+ getIdToken: () => {
+ return authApi.getBackstageIdentity().then(i => i!.idToken);
+ },
+ signOut: async () => {
+ await authApi.signOut();
+ },
+ });
+ };
+
+ if (retry) {
+ login().catch(setError);
+ }
+ }, [onResult, authApi, retry]);
+
+ return (
+
+
+
+
+
+ setRetry({})}
+ >
+ Sign In
+
+ }
+ >
+ {provider.message}
+ {error && error.name !== 'PopupRejectedError' && (
+
+ {error.message}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export const SignInPage = (props: Props) => {
+ if ('provider' in props) {
+ return ;
+ }
+
+ return ;
+};
diff --git a/packages/components/src/layout/SignInPage/auth0Provider.tsx b/packages/components/src/layout/SignInPage/auth0Provider.tsx
new file mode 100644
index 0000000000..423b28ea8d
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/auth0Provider.tsx
@@ -0,0 +1,88 @@
+/*
+ * 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 { Grid, Typography, Button } from '@material-ui/core';
+import { InfoCard } from '../InfoCard/InfoCard';
+import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
+import { useApi, auth0AuthApiRef, errorApiRef } from '@backstage/core-api';
+
+const Component: ProviderComponent = ({ onResult }) => {
+ const auth0AuthApi = useApi(auth0AuthApiRef);
+ const errorApi = useApi(errorApiRef);
+
+ const handleLogin = async () => {
+ try {
+ const identity = await auth0AuthApi.getBackstageIdentity({
+ instantPopup: true,
+ });
+
+ const profile = await auth0AuthApi.getProfile();
+
+ onResult({
+ userId: identity!.id,
+ profile: profile!,
+ getIdToken: () =>
+ auth0AuthApi.getBackstageIdentity().then(i => i!.idToken),
+ signOut: async () => {
+ await auth0AuthApi.signOut();
+ },
+ });
+ } catch (error) {
+ errorApi.post(error);
+ }
+ };
+
+ return (
+
+
+ Sign In
+
+ }
+ >
+ Sign In using Auth0
+
+
+ );
+};
+
+const loader: ProviderLoader = async apis => {
+ const auth0AuthApi = apis.get(auth0AuthApiRef)!;
+
+ const identity = await auth0AuthApi.getBackstageIdentity({
+ optional: true,
+ });
+
+ if (!identity) {
+ return undefined;
+ }
+
+ const profile = await auth0AuthApi.getProfile();
+
+ return {
+ userId: identity.id,
+ profile: profile!,
+ getIdToken: () => auth0AuthApi.getBackstageIdentity().then(i => i!.idToken),
+ signOut: async () => {
+ await auth0AuthApi.signOut();
+ },
+ };
+};
+
+export const auth0Provider: SignInProvider = { Component, loader };
diff --git a/packages/components/src/layout/SignInPage/commonProvider.tsx b/packages/components/src/layout/SignInPage/commonProvider.tsx
new file mode 100644
index 0000000000..b7480afa49
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/commonProvider.tsx
@@ -0,0 +1,96 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Typography, Button } from '@material-ui/core';
+import { InfoCard } from '../InfoCard/InfoCard';
+import {
+ ProviderComponent,
+ ProviderLoader,
+ SignInProvider,
+ SignInConfig,
+} from './types';
+import { useApi, errorApiRef } from '@backstage/core-api';
+import { GridItem } from './styles';
+
+const Component: ProviderComponent = ({ config, onResult }) => {
+ const { apiRef, title, message } = config as SignInConfig;
+ const authApi = useApi(apiRef);
+ const errorApi = useApi(errorApiRef);
+
+ const handleLogin = async () => {
+ try {
+ const identity = await authApi.getBackstageIdentity({
+ instantPopup: true,
+ });
+
+ const profile = await authApi.getProfile();
+ onResult({
+ userId: identity!.id,
+ profile: profile!,
+ getIdToken: () => {
+ return authApi.getBackstageIdentity().then(i => i!.idToken);
+ },
+ signOut: async () => {
+ await authApi.signOut();
+ },
+ });
+ } catch (error) {
+ errorApi.post(error);
+ }
+ };
+
+ return (
+
+
+ Sign In
+
+ }
+ >
+ {message}
+
+
+ );
+};
+
+const loader: ProviderLoader = async (apis, apiRef) => {
+ const authApi = apis.get(apiRef)!;
+
+ const identity = await authApi.getBackstageIdentity({
+ optional: true,
+ });
+
+ if (!identity) {
+ return undefined;
+ }
+
+ const profile = await authApi.getProfile();
+
+ return {
+ userId: identity.id,
+ profile: profile!,
+ getIdToken: () => authApi.getBackstageIdentity().then(i => i!.idToken),
+ signOut: async () => {
+ await authApi.signOut();
+ },
+ };
+};
+
+export const commonProvider: SignInProvider = { Component, loader };
diff --git a/packages/components/src/layout/SignInPage/customProvider.tsx b/packages/components/src/layout/SignInPage/customProvider.tsx
new file mode 100644
index 0000000000..09ffcda10a
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/customProvider.tsx
@@ -0,0 +1,126 @@
+/*
+ * 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 { useForm } from 'react-hook-form';
+import {
+ Typography,
+ Button,
+ FormControl,
+ TextField,
+ FormHelperText,
+ makeStyles,
+} from '@material-ui/core';
+import isEmpty from 'lodash/isEmpty';
+import { InfoCard } from '../InfoCard/InfoCard';
+import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
+import { GridItem } from './styles';
+
+// accept base64url format according to RFC7515 (https://tools.ietf.org/html/rfc7515#section-3)
+const ID_TOKEN_REGEX = /^[a-z0-9_\-]+\.[a-z0-9_\-]+\.[a-z0-9_\-]+$/i;
+
+const useFormStyles = makeStyles(theme => ({
+ form: {
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ },
+ button: {
+ alignSelf: 'center',
+ marginTop: theme.spacing(2),
+ },
+}));
+
+type Data = {
+ userId: string;
+ idToken?: string;
+};
+
+const Component: ProviderComponent = ({ onResult }) => {
+ const classes = useFormStyles();
+ const { register, handleSubmit, errors, formState } = useForm({
+ mode: 'onChange',
+ });
+
+ const handleResult = ({ userId, idToken }: Data) => {
+ onResult({
+ userId,
+ profile: {
+ email: `${userId}@example.com`,
+ },
+ getIdToken: idToken ? async () => idToken : undefined,
+ });
+ };
+
+ return (
+
+
+
+ Enter your own User ID and credentials.
+
+ This selection will not be stored.
+
+
+
+
+
+ );
+};
+
+// Custom provider doesn't store credentials
+const loader: ProviderLoader = async () => undefined;
+
+export const customProvider: SignInProvider = { Component, loader };
diff --git a/packages/components/src/layout/SignInPage/guestProvider.tsx b/packages/components/src/layout/SignInPage/guestProvider.tsx
new file mode 100644
index 0000000000..f4854311ac
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/guestProvider.tsx
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { Typography, Button } from '@material-ui/core';
+import { InfoCard } from '../InfoCard/InfoCard';
+import { GridItem } from './styles';
+import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
+
+const result = {
+ userId: 'guest',
+ profile: {
+ email: 'guest@example.com',
+ displayName: 'Guest',
+ },
+};
+
+const Component: ProviderComponent = ({ onResult }) => (
+
+ onResult(result)}
+ >
+ Enter
+
+ }
+ >
+
+ Enter as a Guest User.
+
+ You will not have a verified identity,
+
+ meaning some features might be unavailable.
+
+
+
+);
+
+const loader: ProviderLoader = async () => {
+ return result;
+};
+
+export const guestProvider: SignInProvider = { Component, loader };
diff --git a/packages/components/src/layout/SignInPage/index.ts b/packages/components/src/layout/SignInPage/index.ts
new file mode 100644
index 0000000000..49f55aefc5
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/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 { SignInPage } from './SignInPage';
diff --git a/packages/components/src/layout/SignInPage/providers.tsx b/packages/components/src/layout/SignInPage/providers.tsx
new file mode 100644
index 0000000000..e2c17ab80d
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/providers.tsx
@@ -0,0 +1,177 @@
+/*
+ * 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, { useLayoutEffect, useState, useMemo, useCallback } from 'react';
+import {
+ SignInPageProps,
+ SignInResult,
+ useApi,
+ useApiHolder,
+ errorApiRef,
+} from '@backstage/core-api';
+import { SignInConfig, IdentityProviders, SignInProvider } from './types';
+import { commonProvider } from './commonProvider';
+import { guestProvider } from './guestProvider';
+import { customProvider } from './customProvider';
+
+const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider';
+
+export type SignInProviderType = {
+ [key: string]: {
+ components: SignInProvider;
+ id: string;
+ config?: SignInConfig;
+ };
+};
+
+const signInProviders: { [key: string]: SignInProvider } = {
+ guest: guestProvider,
+ custom: customProvider,
+ common: commonProvider,
+};
+
+function validateIDs(id: string, providers: SignInProviderType): void {
+ if (id in providers)
+ throw new Error(
+ `"${id}" ID is duplicated. IDs of identity providers have to be unique.`,
+ );
+}
+
+export function getSignInProviders(
+ identityProviders: IdentityProviders,
+): SignInProviderType {
+ const providers = identityProviders.reduce(
+ (acc: SignInProviderType, config) => {
+ if (typeof config === 'string') {
+ validateIDs(config, acc);
+ acc[config] = { components: signInProviders[config], id: config };
+
+ return acc;
+ }
+
+ const { id } = config as SignInConfig;
+ validateIDs(id, acc);
+
+ acc[id] = { components: signInProviders.common, id, config };
+
+ return acc;
+ },
+ {},
+ );
+
+ return providers;
+}
+
+export const useSignInProviders = (
+ providers: SignInProviderType,
+ onResult: SignInPageProps['onResult'],
+) => {
+ const errorApi = useApi(errorApiRef);
+ const apiHolder = useApiHolder();
+ const [loading, setLoading] = useState(true);
+
+ // This decorates the result with sign out logic from this hook
+ const handleWrappedResult = useCallback(
+ (result: SignInResult) => {
+ onResult({
+ ...result,
+ signOut: async () => {
+ localStorage.removeItem(PROVIDER_STORAGE_KEY);
+ await result.signOut?.();
+ },
+ });
+ },
+ [onResult],
+ );
+
+ // In this effect we check if the user has already selected an existing login
+ // provider, and in that case try to load an existing session for the provider.
+ useLayoutEffect(() => {
+ if (!loading) {
+ return undefined;
+ }
+
+ // We can't use storageApi here, as it might have a dependency on the IdentityApi
+ const selectedProviderId = localStorage.getItem(
+ PROVIDER_STORAGE_KEY,
+ ) as string;
+
+ // No provider selected, let the user pick one
+ if (selectedProviderId === null) {
+ setLoading(false);
+ return undefined;
+ }
+
+ const provider = providers[selectedProviderId];
+ if (!provider) {
+ setLoading(false);
+ return undefined;
+ }
+
+ let didCancel = false;
+
+ provider.components
+ .loader(apiHolder, provider.config?.apiRef!)
+ .then(result => {
+ if (didCancel) {
+ return;
+ }
+ if (result) {
+ handleWrappedResult(result);
+ } else {
+ setLoading(false);
+ }
+ })
+ .catch(error => {
+ if (didCancel) {
+ return;
+ }
+ errorApi.post(error);
+ setLoading(false);
+ });
+
+ return () => {
+ didCancel = true;
+ };
+ }, [loading, errorApi, onResult, apiHolder, providers, handleWrappedResult]);
+
+ // This renders all available sign-in providers
+ const elements = useMemo(
+ () =>
+ Object.keys(providers).map(key => {
+ const provider = providers[key];
+
+ const { Component } = provider.components;
+
+ const handleResult = (result: SignInResult) => {
+ localStorage.setItem(PROVIDER_STORAGE_KEY, provider.id);
+
+ handleWrappedResult(result);
+ };
+
+ return (
+
+ );
+ }),
+ [providers, handleWrappedResult],
+ );
+
+ return [loading, elements];
+};
diff --git a/packages/components/src/layout/SignInPage/styles.tsx b/packages/components/src/layout/SignInPage/styles.tsx
new file mode 100644
index 0000000000..0d8bd245c5
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/styles.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 { Grid, makeStyles } from '@material-ui/core';
+
+export const useStyles = makeStyles({
+ container: {
+ padding: 0,
+ listStyle: 'none',
+ },
+ item: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ maxWidth: '400px',
+ margin: 0,
+ padding: 0,
+ },
+});
+
+export const GridItem = ({ children }: { children: JSX.Element }) => {
+ const classes = useStyles();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/components/src/layout/SignInPage/types.ts b/packages/components/src/layout/SignInPage/types.ts
new file mode 100644
index 0000000000..48945e6988
--- /dev/null
+++ b/packages/components/src/layout/SignInPage/types.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 { ComponentType } from 'react';
+import {
+ SignInPageProps,
+ SignInResult,
+ ApiHolder,
+ ApiRef,
+ ProfileInfoApi,
+ BackstageIdentityApi,
+ SessionApi,
+} from '@backstage/core-api';
+
+export type SignInConfig = {
+ id: string;
+ title: string;
+ message: string;
+ apiRef: ApiRef;
+};
+
+export type IdentityProviders = ('guest' | 'custom' | SignInConfig)[];
+
+export type ProviderComponent = ComponentType<
+ SignInPageProps & { config: SignInConfig }
+>;
+
+export type ProviderLoader = (
+ apis: ApiHolder,
+ apiRef: ApiRef,
+) => Promise;
+
+export type SignInProvider = {
+ Component: ProviderComponent;
+ loader: ProviderLoader;
+};
diff --git a/packages/components/src/layout/TabbedCard/TabbedCard.stories.tsx b/packages/components/src/layout/TabbedCard/TabbedCard.stories.tsx
new file mode 100644
index 0000000000..65c73b190a
--- /dev/null
+++ b/packages/components/src/layout/TabbedCard/TabbedCard.stories.tsx
@@ -0,0 +1,113 @@
+/*
+ * 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, PropsWithChildren } from 'react';
+import { TabbedCard, CardTab } from '.';
+import { Grid } from '@material-ui/core';
+import { MemoryRouter } from 'react-router';
+
+const cardContentStyle = { height: 200, width: 500 };
+
+const Wrapper = ({ children }: PropsWithChildren<{}>) => (
+ {children}
+);
+
+export default {
+ title: 'Layout/Tabbed Card',
+ component: TabbedCard,
+ decorators: [
+ (storyFn: () => JSX.Element) => (
+
+ {storyFn()}
+
+ ),
+ ],
+};
+
+export const Default = () => {
+ return (
+
+
+
+ Some content
+
+
+ Some content 2
+
+
+ Some content 3
+
+
+ Some content 4
+
+
+
+ );
+};
+
+const linkInfo = { title: 'Go to XYZ Location', link: '#' };
+
+export const WithFooterLink = () => {
+ return (
+
+
+
+ Some content
+
+
+ Some content 2
+
+
+ Some content 3
+
+
+ Some content 4
+
+
+
+ );
+};
+
+export const WithControlledTabValue = () => {
+ const [selectedTab, setSelectedTab] = useState('one');
+
+ const handleChange = (_ev: any, newSelectedTab: string | number) =>
+ setSelectedTab(newSelectedTab);
+
+ return (
+
+ Selected tab is {selectedTab}
+
+
+
+ Some content
+
+
+ Some content 2
+
+
+ Some content 3
+
+
+ Some content 4
+
+
+
+ );
+};
diff --git a/packages/components/src/layout/TabbedCard/TabbedCard.test.tsx b/packages/components/src/layout/TabbedCard/TabbedCard.test.tsx
new file mode 100644
index 0000000000..89abdfdd53
--- /dev/null
+++ b/packages/components/src/layout/TabbedCard/TabbedCard.test.tsx
@@ -0,0 +1,99 @@
+/*
+ * 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 { renderInTestApp, wrapInTestApp } from '@backstage/test-utils';
+import { TabbedCard, CardTab } from '.';
+
+const minProps = {
+ title: 'Some title',
+ deepLink: {
+ title: 'A deepLink title',
+ link: '/mocked',
+ },
+};
+
+describe(' ', () => {
+ it('renders without exploding', async () => {
+ const rendered = await renderInTestApp(
+
+ Test Content
+ Test Content
+ ,
+ );
+ expect(rendered.getByText('Some title')).toBeInTheDocument();
+ });
+
+ it('renders a deepLink when prop is set', async () => {
+ const rendered = await renderInTestApp(
+
+ Test Content
+ Test Content
+ ,
+ );
+ expect(rendered.getByText('A deepLink title')).toBeInTheDocument();
+ });
+
+ it('switches tabs when clicking', async () => {
+ const rendered = await renderInTestApp(
+
+ Test Content 1
+ Test Content 2
+ ,
+ );
+ expect(rendered.getByText('Test Content 1')).toBeInTheDocument();
+
+ fireEvent.click(rendered.getByText('Test 2'));
+ expect(rendered.getByText('Test Content 2')).toBeInTheDocument();
+ });
+
+ it('switches tabs when clicking in controlled mode', () => {
+ let selectedTab = 'one';
+
+ const handleTabChange = jest.fn(
+ (_ev, newSelectedTab) => (selectedTab = newSelectedTab),
+ );
+
+ const rendered = render(
+ wrapInTestApp(
+
+
+ Test Content 1
+
+
+ Test Content 2
+
+ ,
+ ),
+ );
+ expect(rendered.getByText('Test Content 1')).toBeInTheDocument();
+
+ fireEvent.click(rendered.getByText('Test 2'));
+ expect(handleTabChange.mock.calls.length).toBe(1);
+ rendered.rerender(
+
+
+ Test Content 1
+
+
+ Test Content 2
+
+ ,
+ );
+ expect(rendered.getByText('Test Content 2')).toBeInTheDocument();
+ });
+});
diff --git a/packages/components/src/layout/TabbedCard/TabbedCard.tsx b/packages/components/src/layout/TabbedCard/TabbedCard.tsx
new file mode 100644
index 0000000000..c9ae2eebc8
--- /dev/null
+++ b/packages/components/src/layout/TabbedCard/TabbedCard.tsx
@@ -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 React, {
+ useState,
+ ReactElement,
+ ReactNode,
+ PropsWithChildren,
+} from 'react';
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ Divider,
+ withStyles,
+ makeStyles,
+ Tabs,
+ Tab,
+ TabProps,
+} from '@material-ui/core';
+import { BottomLink, BottomLinkProps } from '../BottomLink';
+import { ErrorBoundary } from '../ErrorBoundary';
+
+const useTabsStyles = makeStyles(theme => ({
+ root: {
+ padding: theme.spacing(0, 2, 0, 2.5),
+ minHeight: theme.spacing(3),
+ },
+ indicator: {
+ backgroundColor: theme.palette.info.main,
+ height: theme.spacing(0.3),
+ },
+}));
+
+const BoldHeader = withStyles(theme => ({
+ root: { padding: theme.spacing(2, 2, 2, 2.5), display: 'inline-block' },
+ title: { fontWeight: 700 },
+ subheader: { paddingTop: theme.spacing(1) },
+}))(CardHeader);
+
+type Props = {
+ slackChannel?: string;
+ children?: ReactElement[];
+ onChange?: (event: React.ChangeEvent<{}>, value: number | string) => void;
+ title?: string;
+ value?: number | string;
+ deepLink?: BottomLinkProps;
+};
+
+const TabbedCard = ({
+ slackChannel = '#backstage',
+ children,
+ title,
+ deepLink,
+ value,
+ onChange,
+}: PropsWithChildren) => {
+ const tabsClasses = useTabsStyles();
+ const [selectedIndex, selectIndex] = useState(0);
+
+ const handleChange = onChange
+ ? onChange
+ : (_ev: unknown, newSelectedIndex: number) => selectIndex(newSelectedIndex);
+
+ let selectedTabContent: ReactNode;
+ if (!value) {
+ React.Children.map(children, (child, index) => {
+ if (index === selectedIndex) selectedTabContent = child?.props.children;
+ });
+ } else {
+ React.Children.map(children, child => {
+ if (child?.props.value === value)
+ selectedTabContent = child?.props.children;
+ });
+ }
+
+ return (
+
+
+ {title && }
+
+ {children}
+
+
+ {selectedTabContent}
+ {deepLink && }
+
+
+ );
+};
+
+const useCardTabStyles = makeStyles(theme => ({
+ root: {
+ minWidth: theme.spacing(6),
+ minHeight: theme.spacing(3),
+ margin: theme.spacing(0, 2, 0, 0),
+ padding: theme.spacing(0.5, 0, 0.5, 0),
+ textTransform: 'none',
+ },
+ selected: {
+ fontWeight: 'bold',
+ },
+}));
+
+type CardTabProps = TabProps & {
+ children: ReactNode;
+};
+
+const CardTab = ({ children, ...props }: PropsWithChildren) => {
+ const classes = useCardTabStyles();
+
+ return ;
+};
+
+export { TabbedCard, CardTab };
diff --git a/packages/components/src/layout/TabbedCard/index.ts b/packages/components/src/layout/TabbedCard/index.ts
new file mode 100644
index 0000000000..88d7782d55
--- /dev/null
+++ b/packages/components/src/layout/TabbedCard/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 { CardTab, TabbedCard } from './TabbedCard';
diff --git a/packages/components/src/layout/index.ts b/packages/components/src/layout/index.ts
new file mode 100644
index 0000000000..2ab1ebaf5b
--- /dev/null
+++ b/packages/components/src/layout/index.ts
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './Content';
+export * from './ContentHeader';
+export * from './ErrorBoundary';
+export * from './ErrorPage';
+export * from './Header';
+export * from './HeaderLabel';
+export * from './HeaderTabs';
+export * from './HomepageTimer';
+export * from './InfoCard';
+export * from './ItemCard';
+export * from './Page';
+export * from './Sidebar';
+export * from './SignInPage';
+export * from './TabbedCard';
+export * from './Breadcrumbs';
diff --git a/packages/components/src/setupTests.ts b/packages/components/src/setupTests.ts
new file mode 100644
index 0000000000..825bcd4115
--- /dev/null
+++ b/packages/components/src/setupTests.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.
+ */
+
+import '@testing-library/jest-dom';