duplicate packages/core to packages/componments, omitting api-wrappers
Co-authored-by: Juan Lulkin <jmaiz@spotify.com> Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
@@ -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: <Button onClick={myAction} />,
|
||||
icon: <MyIcon onClick={myAction} />,
|
||||
};
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```ts
|
||||
const myLink: IconLinkVerticalProps = {
|
||||
label: 'Click me',
|
||||
onClick: myAction,
|
||||
icon: <MyIcon />,
|
||||
};
|
||||
```
|
||||
|
||||
### 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
|
||||
<TabbedLayout>
|
||||
<TabbedLayout.Route path="/example" title="Example tab">
|
||||
<div>This is rendered under /example/anything-here route</div>
|
||||
</TabbedLayout.Route>
|
||||
</TabbedLayout>
|
||||
```
|
||||
|
||||
- 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
|
||||
<FlatRoutes>
|
||||
...
|
||||
<Route path='/my-path' element={<MyPage />}>
|
||||
...
|
||||
</FlatRoutes>
|
||||
```
|
||||
|
||||
## 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 `<Avatar>` 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.
|
||||
|
||||
```
|
||||
<MarkdownContent content={markdownGithubFlavored} />
|
||||
```
|
||||
|
||||
To render the Markdown content with plain [CommonMark](https://commonmark.org/), set the dialect to `common-mark`
|
||||
|
||||
```
|
||||
<MarkdownContent content={markdown} dialect='common-mark />
|
||||
```
|
||||
|
||||
- 4aca74e08: Extend default config loader to read config from the window object.
|
||||
|
||||
Config will be read from `window.__APP_CONFIG__` which should be an object.
|
||||
|
||||
- e8f69ba93: - The BottomLink is now able to handle with internal routes.
|
||||
- @backstage/core Link component detect whether it's an external link or not, and render accordingly
|
||||
- 0c0798f08: Extend the table to share its current filter state. The filter state can be used together with the new `useQueryParamState` hook to store the current filter state to the browser history and restore it after navigating to other routes.
|
||||
- 0c0798f08: Make the selected state of Select and CheckboxTree controllable from outside.
|
||||
- 6627b626f: Fix divider prop not respected on InfoCard
|
||||
- Updated dependencies [c5bab94ab]
|
||||
- Updated dependencies [4577e377b]
|
||||
- @backstage/core-api@0.2.1
|
||||
- @backstage/theme@0.2.1
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 819a70229: Add SAML login to backstage
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
- 482b6313d: Fix dense in Structured Metadata Table
|
||||
- 1c60f716e: Added EmptyState component
|
||||
- b79017fd3: Updated the `GithubAuth.create` method to configure the default scope of the GitHub Auth Api. As a result the
|
||||
default scope is configurable when overwriting the Core Api in the app.
|
||||
|
||||
```
|
||||
GithubAuth.create({
|
||||
discoveryApi,
|
||||
oauthRequestApi,
|
||||
defaultScopes: ['read:user', 'repo'],
|
||||
}),
|
||||
```
|
||||
|
||||
- 6d97d2d6f: The InfoCard variant `'height100'` is deprecated. Use variant `'gridItem'` instead.
|
||||
|
||||
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:
|
||||
`<InfoCard variant="gridItem">...</InfoCard>`
|
||||
|
||||
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
|
||||
@@ -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)
|
||||
Vendored
+113
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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('<AlertDisplay />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const apiRegistry = ApiRegistry.from([
|
||||
[alertApiRef, new AlertApiForwarder()],
|
||||
]);
|
||||
|
||||
const { queryByText } = await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<AlertDisplay />
|
||||
</ApiProvider>,
|
||||
);
|
||||
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(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<AlertDisplay />
|
||||
</ApiProvider>,
|
||||
);
|
||||
|
||||
expect(queryByText(TEST_MESSAGE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<Array<AlertMessage>>([]);
|
||||
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 (
|
||||
<Snackbar
|
||||
open
|
||||
message={firstMessage.message.toString()}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
action={
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={handleClose}
|
||||
data-testid="error-button-close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
severity={firstMessage.severity}
|
||||
>
|
||||
{firstMessage.message.toString()}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = () => (
|
||||
<Avatar
|
||||
displayName="Jenny Doe"
|
||||
// Avatar of the backstage GitHub org
|
||||
picture="https://avatars1.githubusercontent.com/u/72526453?s=200&v=4"
|
||||
/>
|
||||
);
|
||||
|
||||
export const NameFallback = () => <Avatar displayName="Jenny Doe" />;
|
||||
|
||||
export const Empty = () => <Avatar />;
|
||||
|
||||
export const CustomStyling = () => (
|
||||
<Avatar
|
||||
displayName="Jenny Doe"
|
||||
customStyles={{ width: '24px', height: '24px', fontSize: '8px' }}
|
||||
/>
|
||||
);
|
||||
@@ -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('<Avatar />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = render(<Avatar displayName="John Doe" />);
|
||||
|
||||
expect(getByText('JD')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<MaterialAvatar
|
||||
alt={displayName}
|
||||
src={picture}
|
||||
className={classes.avatar}
|
||||
style={{
|
||||
backgroundColor: stringToColor(displayName || picture || ''),
|
||||
...customStyles,
|
||||
}}
|
||||
>
|
||||
{displayName && extractInitials(displayName)}
|
||||
</MaterialAvatar>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <pre>Current location: {location.pathname}</pre>;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Inputs/Button',
|
||||
component: Button,
|
||||
decorators: [
|
||||
(storyFn: FunctionComponentFactory<{}>) => (
|
||||
<>
|
||||
<Typography>
|
||||
A collection of buttons that should be used in the Backstage
|
||||
interface. These leverage the properties inherited from{' '}
|
||||
<Link href="https://material-ui.com/components/buttons/">
|
||||
Material-UI Button
|
||||
</Link>
|
||||
, but include an opinionated set that align to the Backstage design.
|
||||
</Typography>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MemoryRouter>
|
||||
<div>
|
||||
<div>
|
||||
<Location />
|
||||
</div>
|
||||
{storyFn()}
|
||||
</div>
|
||||
</MemoryRouter>
|
||||
</>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const routeRef = createRouteRef({
|
||||
path: '/hello',
|
||||
title: 'Hi there!',
|
||||
});
|
||||
|
||||
// Design Permutations:
|
||||
// color = default | primary | secondary
|
||||
// variant = contained | outlined | text
|
||||
return (
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText>
|
||||
<Typography variant="h6">Default Button:</Typography>
|
||||
This is the default button design which should be used in most cases.
|
||||
<br />
|
||||
<pre>color="primary" variant="contained"</pre>
|
||||
</ListItemText>
|
||||
|
||||
<Button to={routeRef.path} color="primary" variant="contained">
|
||||
Register Component
|
||||
</Button>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText>
|
||||
<Typography variant="h6">Secondary Button:</Typography>
|
||||
Used for actions that cancel, skip, and in general perform negative
|
||||
functions, etc.
|
||||
<br />
|
||||
<pre>color="secondary" variant="contained"</pre>
|
||||
</ListItemText>
|
||||
|
||||
<Button to={routeRef.path} color="secondary" variant="contained">
|
||||
Cancel
|
||||
</Button>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText>
|
||||
<Typography variant="h6">Tertiary Button:</Typography>
|
||||
Used commonly in a ButtonGroup and when the button function itself is
|
||||
not a primary function on a page.
|
||||
<br />
|
||||
<pre>color="default" variant="outlined"</pre>
|
||||
</ListItemText>
|
||||
|
||||
<Button to={routeRef.path} color="default" variant="outlined">
|
||||
View Details
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
);
|
||||
};
|
||||
|
||||
export const ButtonLinks = () => {
|
||||
const routeRef = createRouteRef({
|
||||
path: '/hello',
|
||||
title: 'Hi there!',
|
||||
});
|
||||
|
||||
const handleClick = () => {
|
||||
return 'Your click worked!';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
{
|
||||
// TODO: Refactor to use new routing mechanisms
|
||||
}
|
||||
<ListItem>
|
||||
<Button to={routeRef.path} color="default" variant="outlined">
|
||||
Route Ref
|
||||
</Button>
|
||||
has props for both Material-UI's component as well as for
|
||||
react-router-dom's Route object.
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<Button to="/staticpath" color="default" variant="outlined">
|
||||
Static Path
|
||||
</Button>
|
||||
links to a statically defined route. In general, this should be
|
||||
avoided.
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<MaterialButton
|
||||
href="https://backstage.io"
|
||||
color="default"
|
||||
variant="outlined"
|
||||
>
|
||||
View URL
|
||||
</MaterialButton>
|
||||
links to a defined URL using Material-UI's Button.
|
||||
</ListItem>
|
||||
|
||||
<ListItem>
|
||||
<MaterialButton
|
||||
onClick={handleClick}
|
||||
color="default"
|
||||
variant="outlined"
|
||||
>
|
||||
Trigger Event
|
||||
</MaterialButton>
|
||||
triggers an onClick event using Material-UI's Button.
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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('<Button />', () => {
|
||||
it('navigates using react-router', async () => {
|
||||
const testString = 'This is test string';
|
||||
const buttonLabel = 'Navigate!';
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<Routes>
|
||||
<Route path="/test" element={<p>{testString}</p>} />
|
||||
<Button to="/test">{buttonLabel}</Button>
|
||||
</Routes>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(() => getByText(testString)).toThrow();
|
||||
await act(async () => {
|
||||
fireEvent.click(getByText(buttonLabel));
|
||||
});
|
||||
expect(getByText(testString)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<typeof MaterialButton> &
|
||||
ComponentProps<typeof RouterLink>;
|
||||
|
||||
/**
|
||||
* Thin wrapper on top of material-ui's Button component
|
||||
* Makes the Button to utilise react-router
|
||||
*/
|
||||
export const Button = React.forwardRef<any, Props>((props, ref) => (
|
||||
<MaterialButton ref={ref} component={RouterLink} {...props} />
|
||||
));
|
||||
@@ -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';
|
||||
@@ -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 = () => (
|
||||
<CheckboxTree
|
||||
onChange={() => {}}
|
||||
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 (
|
||||
<CheckboxTree
|
||||
onChange={state => {
|
||||
setTree(generateTree(state.some(c => c.category === 'Show more')));
|
||||
}}
|
||||
label="default"
|
||||
subCategories={tree}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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('<CheckboxTree />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText, getByTestId } = render(<CheckboxTree {...minProps} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<T> = {
|
||||
[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>;
|
||||
};
|
||||
|
||||
/* 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<SubCategoryWithIndexedOptions>;
|
||||
}
|
||||
| { type: 'updateSelected'; payload: Selection }
|
||||
| { type: 'triggerReset' };
|
||||
|
||||
const reducer = (
|
||||
state: IndexedObject<SubCategoryWithIndexedOptions>,
|
||||
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<SubCategoryWithIndexedOptions> =>
|
||||
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 (
|
||||
<div>
|
||||
<Typography variant="button">{label}</Typography>
|
||||
<List className={classes.root}>
|
||||
{Object.values(state).map(item => (
|
||||
<div key={item.label}>
|
||||
<ListItem
|
||||
className={classes.listItem}
|
||||
dense
|
||||
button
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'checkCategory',
|
||||
payload: item.label,
|
||||
})
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
edge="start"
|
||||
checked={item.isChecked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText className={classes.text} primary={item.label} />
|
||||
{Object.values(item.options).length ? (
|
||||
<>
|
||||
{item.isOpen ? (
|
||||
<ExpandLess
|
||||
data-testid="expandable"
|
||||
onClick={event => handleOpen(event, item.label)}
|
||||
/>
|
||||
) : (
|
||||
<ExpandMore
|
||||
data-testid="expandable"
|
||||
onClick={event => handleOpen(event, item.label)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</ListItem>
|
||||
<Collapse in={item.isOpen} timeout="auto" unmountOnExit>
|
||||
<List component="div" disablePadding>
|
||||
{Object.values(item.options).map(option => (
|
||||
<ListItem
|
||||
button
|
||||
key={option.label}
|
||||
className={classes.nested}
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'checkOption',
|
||||
payload: {
|
||||
subCategoryLabel: item.label,
|
||||
optionLabel: option.label,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<ListItemIcon className={classes.listItemIcon}>
|
||||
<Checkbox
|
||||
color="primary"
|
||||
edge="start"
|
||||
checked={option.isChecked}
|
||||
tabIndex={-1}
|
||||
disableRipple
|
||||
/>
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
className={classes.text}
|
||||
primary={option.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Collapse>
|
||||
</div>
|
||||
))}
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = () => <Chip label="Default" />;
|
||||
|
||||
export const LargeDeletable = () => (
|
||||
<Chip label="Large deletable" size="medium" onDelete={() => ({})} />
|
||||
);
|
||||
|
||||
export const LargeNotDeletable = () => (
|
||||
<Chip label="Large not deletable" size="medium" />
|
||||
);
|
||||
|
||||
export const SmallDeletable = () => (
|
||||
<Chip label="Small deletable" size="small" onDelete={() => ({})} />
|
||||
);
|
||||
|
||||
export const SmallNotDeletable = () => (
|
||||
<Chip label="Small not deletable" size="small" />
|
||||
);
|
||||
|
||||
export const Outline = () => <Chip label="Outline" variant="outlined" />;
|
||||
@@ -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 = () => (
|
||||
<InfoCard title="JavaScript example">
|
||||
<CodeSnippet text={"const hello = 'World';"} language="javascript" />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const MultipleLines = () => (
|
||||
<InfoCard title="JavaScript multi-line example">
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const LineNumbers = () => (
|
||||
<InfoCard title="Show line numbers">
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const Overflow = () => (
|
||||
<InfoCard title="Overflow">
|
||||
<div style={containerStyle}>
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" />
|
||||
</div>
|
||||
<div style={containerStyle}>
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
|
||||
</div>
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const Languages = () => (
|
||||
<InfoCard title="Multiple languages">
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
|
||||
<CodeSnippet text={TYPESCRIPT} language="typescript" showLineNumbers />
|
||||
<CodeSnippet text={PYTHON} language="python" showLineNumbers />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const CopyCode = () => (
|
||||
<InfoCard title="Copy Code">
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" showCopyCodeButton />
|
||||
</InfoCard>
|
||||
);
|
||||
@@ -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('<CodeSnippet />', () => {
|
||||
it('renders text without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(<CodeSnippet {...minProps} />);
|
||||
expect(getByText(/"Hello"/)).toBeInTheDocument();
|
||||
expect(getByText(/"World"/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without line numbers', async () => {
|
||||
const { queryByText } = await renderInTestApp(
|
||||
<CodeSnippet {...minProps} />,
|
||||
);
|
||||
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(
|
||||
<CodeSnippet {...minProps} showLineNumbers />,
|
||||
);
|
||||
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(
|
||||
<CodeSnippet {...minProps} showCopyCodeButton />,
|
||||
);
|
||||
const button = getByTitle('Text copied to clipboard');
|
||||
fireEvent.click(button);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(document.execCommand).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -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<BackstageTheme>();
|
||||
const mode = theme.palette.type === 'dark' ? dark : docco;
|
||||
const highlightColor = theme.palette.type === 'dark' ? '#256bf3' : '#e6ffed';
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SyntaxHighlighter
|
||||
customStyle={customStyle}
|
||||
language={language}
|
||||
style={mode}
|
||||
showLineNumbers={showLineNumbers}
|
||||
wrapLines
|
||||
lineNumberStyle={{ color: theme.palette.textVerySubtle }}
|
||||
lineProps={(lineNumber: number) =>
|
||||
highlightedNumbers?.includes(lineNumber)
|
||||
? {
|
||||
style: {
|
||||
backgroundColor: highlightColor,
|
||||
},
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{text}
|
||||
</SyntaxHighlighter>
|
||||
{showCopyCodeButton && (
|
||||
<div style={{ position: 'absolute', top: 0, right: 0 }}>
|
||||
<CopyTextButton text={text} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = () => (
|
||||
<CopyTextButton text="The text to copy to clipboard" />
|
||||
);
|
||||
|
||||
export const WithTooltip = () => (
|
||||
<CopyTextButton
|
||||
text="The text to copy to clipboard"
|
||||
tooltipText="Custom tooltip shown on button click"
|
||||
/>
|
||||
);
|
||||
|
||||
export const LongerTooltipDelay = () => (
|
||||
<CopyTextButton
|
||||
text="The text to copy to clipboard"
|
||||
tooltipText="Waiting 3s before removing tooltip"
|
||||
tooltipDelay={3000}
|
||||
/>
|
||||
);
|
||||
@@ -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('<CopyTextButton />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByDisplayValue } = await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<CopyTextButton {...props} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
getByDisplayValue('mockText');
|
||||
});
|
||||
|
||||
it('displays tooltip on click', async () => {
|
||||
jest.useFakeTimers();
|
||||
document.execCommand = jest.fn();
|
||||
const rendered = await renderInTestApp(
|
||||
<ApiProvider apis={apiRegistry}>
|
||||
<CopyTextButton {...props} />
|
||||
</ApiProvider>,
|
||||
);
|
||||
const button = rendered.getByTitle('mockTooltip');
|
||||
fireEvent.click(button);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
expect(document.execCommand).toHaveBeenCalled();
|
||||
rendered.getByText('mockTooltip');
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -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<BackstageTheme>(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:
|
||||
* <CopyTextButton text="My text that I want to be copied to the clipboard" />
|
||||
*/
|
||||
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<HTMLTextAreaElement>(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 (
|
||||
<>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
style={{ position: 'absolute', top: -9999, left: 9999 }}
|
||||
defaultValue={text}
|
||||
/>
|
||||
<Tooltip
|
||||
id="copy-test-tooltip"
|
||||
title={tooltipText}
|
||||
placement="top"
|
||||
leaveDelay={tooltipDelay}
|
||||
onClose={() => setOpen(false)}
|
||||
open={open}
|
||||
>
|
||||
<IconButton onClick={handleCopyClick} className={classes.button}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Type check for the JS files using this core component
|
||||
CopyTextButton.propTypes = {
|
||||
text: PropTypes.string.isRequired,
|
||||
tooltipDelay: PropTypes.number,
|
||||
tooltipText: PropTypes.string,
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
<text className={classes.text} textAnchor="middle">
|
||||
{label}
|
||||
</text>
|
||||
);
|
||||
}
|
||||
@@ -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<SVGTextElement | null>(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 (
|
||||
<g>
|
||||
<rect
|
||||
className={classes.node}
|
||||
width={paddedWidth}
|
||||
height={paddedHeight}
|
||||
rx={10}
|
||||
/>
|
||||
<text
|
||||
ref={idRef}
|
||||
className={classes.text}
|
||||
y={paddedHeight / 2}
|
||||
x={paddedWidth / 2}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="middle"
|
||||
>
|
||||
{id}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const BottomToTop = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
direction={Direction.BOTTOM_TOP}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LeftToRight = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
direction={Direction.LEFT_RIGHT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const RightToLeft = () => (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={exampleEdges}
|
||||
direction={Direction.RIGHT_LEFT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithLabels = () => {
|
||||
const edges = exampleEdges.map(edge => ({ ...edge, label: 'label' }));
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={edges}
|
||||
direction={Direction.LEFT_RIGHT}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomNodes = () => {
|
||||
const colors = ['pink', 'coral', 'yellowgreen', 'aquamarine'];
|
||||
const nodes = exampleNodes.map((node, index) => ({
|
||||
...node,
|
||||
description: 'Description text',
|
||||
color: colors[index],
|
||||
}));
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={nodes}
|
||||
edges={exampleEdges}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
renderNode={props => (
|
||||
<g>
|
||||
<rect width={200} height={100} rx={20} fill={props.node.color} />
|
||||
<text
|
||||
x={100}
|
||||
y={45}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="baseline"
|
||||
style={{ fontWeight: 'bold' }}
|
||||
>
|
||||
{props.node.id}
|
||||
</text>
|
||||
<text
|
||||
x={100}
|
||||
y={55}
|
||||
textAnchor="middle"
|
||||
alignmentBaseline="hanging"
|
||||
>
|
||||
{props.node.description}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CustomLabels = () => {
|
||||
const colors = ['pink', 'coral', 'aqua'];
|
||||
const edges = exampleEdges.map((edge, index) => ({
|
||||
...edge,
|
||||
label: colors[index],
|
||||
color: colors[index],
|
||||
}));
|
||||
return (
|
||||
<div style={containerStyle}>
|
||||
<DependencyGraph
|
||||
nodes={exampleNodes}
|
||||
edges={edges}
|
||||
labelPosition={LabelPosition.CENTER}
|
||||
style={graphStyle}
|
||||
paddingX={50}
|
||||
paddingY={50}
|
||||
renderLabel={props => (
|
||||
<g>
|
||||
<circle r={25} fill={props.edge.color} />
|
||||
<text x={0} y={0} textAnchor="middle" alignmentBaseline="middle">
|
||||
{props.edge.label}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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('<DependencyGraph />', () => {
|
||||
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(
|
||||
<DependencyGraph nodes={nodes} edges={edges} />,
|
||||
);
|
||||
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(
|
||||
<DependencyGraph nodes={nodes} edges={labeledEdges} />,
|
||||
);
|
||||
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) => (
|
||||
<g>
|
||||
<text>{props.node.id}</text>
|
||||
<circle data-testid={CUSTOM_TEST_ID} r={100} />
|
||||
</g>
|
||||
);
|
||||
const { getByText, findByTestId, container } = render(
|
||||
<DependencyGraph nodes={singleNode} edges={[]} renderNode={renderNode} />,
|
||||
);
|
||||
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) => (
|
||||
<g>
|
||||
<text>{props.edge.label}</text>
|
||||
<circle data-testid={CUSTOM_TEST_ID} r={100} />
|
||||
</g>
|
||||
);
|
||||
const { getByText, findByTestId, container } = render(
|
||||
<DependencyGraph
|
||||
nodes={nodes}
|
||||
edges={labeledEdge}
|
||||
renderLabel={renderLabel}
|
||||
/>,
|
||||
);
|
||||
const node = await findByTestId(CUSTOM_TEST_ID);
|
||||
expect(node).toBeInTheDocument();
|
||||
expect(container.querySelector('circle')).toBeInTheDocument();
|
||||
expect(getByText(labeledEdge[0].label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<SVGSVGElement> & {
|
||||
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<number>(100);
|
||||
const [containerHeight, setContainerHeight] = React.useState<number>(100);
|
||||
|
||||
const graph = React.useRef<dagre.graphlib.Graph<{}>>(
|
||||
new dagre.graphlib.Graph(),
|
||||
);
|
||||
const [graphWidth, setGraphWidth] = React.useState<number>(
|
||||
graph.current.graph()?.width || 0,
|
||||
);
|
||||
const [graphHeight, setGraphHeight] = React.useState<number>(
|
||||
graph.current.graph()?.height || 0,
|
||||
);
|
||||
const [graphNodes, setGraphNodes] = React.useState<string[]>([]);
|
||||
const [graphEdges, setGraphEdges] = React.useState<dagre.Edge[]>([]);
|
||||
|
||||
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<SVGSVGElement, null>(node);
|
||||
const workspace = d3Selection.select(node.getElementById(WORKSPACE_ID));
|
||||
const zoom = d3Zoom
|
||||
.zoom<SVGSVGElement, null>()
|
||||
.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 (
|
||||
<svg
|
||||
ref={containerRef}
|
||||
{...svgProps}
|
||||
width={maxWidth}
|
||||
height={maxHeight}
|
||||
viewBox={`0 0 ${maxWidth} ${maxHeight}`}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id={ARROW_MARKER_ID}
|
||||
viewBox="0 0 24 24"
|
||||
markerWidth="14"
|
||||
markerHeight="14"
|
||||
refX="16"
|
||||
refY="12"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path
|
||||
fill={theme.palette.textSubtle}
|
||||
d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6-1.41-1.41z"
|
||||
/>
|
||||
</marker>
|
||||
{defs}
|
||||
</defs>
|
||||
<g id={WORKSPACE_ID}>
|
||||
<svg
|
||||
width={graphWidth}
|
||||
height={graphHeight}
|
||||
y={maxHeight / 2 - graphHeight / 2}
|
||||
x={maxWidth / 2 - graphWidth / 2}
|
||||
viewBox={`0 0 ${graphWidth} ${graphHeight}`}
|
||||
>
|
||||
{graphEdges.map(e => {
|
||||
const edge = graph.current.edge(e) as GraphEdge;
|
||||
if (!edge) return null;
|
||||
return (
|
||||
<Edge
|
||||
key={`${e.v}-${e.w}`}
|
||||
id={e}
|
||||
setEdge={setEdge}
|
||||
render={renderLabel}
|
||||
edge={edge}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{graphNodes.map((id: string) => {
|
||||
const node = graph.current.node(id) as GraphNode;
|
||||
if (!node) return null;
|
||||
return (
|
||||
<Node
|
||||
key={id}
|
||||
setNode={setNode}
|
||||
render={renderNode}
|
||||
node={node}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<text>{props.edge.label}</text>
|
||||
));
|
||||
|
||||
const minProps = {
|
||||
points: [
|
||||
{ x: 10, y: 20 },
|
||||
{ x: 20, y: 20 },
|
||||
],
|
||||
id,
|
||||
setEdge,
|
||||
renderElement,
|
||||
edge,
|
||||
};
|
||||
|
||||
const label = 'label';
|
||||
const edgeWithLabel = { ...edge, label };
|
||||
|
||||
describe('<Edge />', () => {
|
||||
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(<Edge {...minProps} />);
|
||||
expect(container.getElementsByTagName('g')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the supplied label element if label is present', () => {
|
||||
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
|
||||
expect(getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes down edge properties to the render method if label is present', () => {
|
||||
const edgeWithRandomProp = { ...edge, label, randomProp: true };
|
||||
render(
|
||||
<Edge {...minProps} render={renderElement} edge={edgeWithRandomProp} />,
|
||||
);
|
||||
|
||||
expect(renderElement).toHaveBeenCalledWith({ edge: edgeWithRandomProp });
|
||||
});
|
||||
|
||||
it('calls setEdge with edge ID and actual label size after rendering', () => {
|
||||
const { getByText } = render(<Edge {...minProps} edge={edgeWithLabel} />);
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<T = any> = {
|
||||
id: dagre.Edge;
|
||||
edge: GraphEdge<T>;
|
||||
render?: RenderLabelFunction;
|
||||
setEdge: (id: dagre.Edge, edge: DependencyEdge) => dagre.graphlib.Graph<{}>;
|
||||
};
|
||||
|
||||
const renderDefault = (props: RenderLabelProps) => <DefaultLabel {...props} />;
|
||||
|
||||
const createPath = d3Shape
|
||||
.line<EdgePoint>()
|
||||
.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<SVGGElement>(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 && (
|
||||
<path
|
||||
data-testid={EDGE_TEST_ID}
|
||||
className={classes.path}
|
||||
markerEnd={`url(#${ARROW_MARKER_ID})`}
|
||||
d={path}
|
||||
/>
|
||||
)}
|
||||
{labelProps.label ? (
|
||||
<g
|
||||
ref={labelRef}
|
||||
data-testid={LABEL_TEST_ID}
|
||||
className={classes.label}
|
||||
transform={`translate(${x},${y})`}
|
||||
>
|
||||
{render({ edge: labelProps })}
|
||||
</g>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
<text>{props.node.id}</text>
|
||||
));
|
||||
|
||||
const minProps = {
|
||||
id: node.id,
|
||||
node,
|
||||
setNode,
|
||||
render: renderElement,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
describe('<Node />', () => {
|
||||
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(<Node {...minProps} />);
|
||||
expect(getByText(minProps.id)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes down node properties to the render method', () => {
|
||||
const nodeWithRandomProp = { ...node, randomProp: true };
|
||||
render(<Node {...minProps} node={nodeWithRandomProp} />);
|
||||
|
||||
expect(renderElement).toHaveBeenCalledWith({ node: nodeWithRandomProp });
|
||||
});
|
||||
|
||||
it('calls setNode with node ID and actual size after rendering', () => {
|
||||
const { getByText } = render(<Node {...minProps} />);
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<T = any> = {
|
||||
node: GraphNode<T>;
|
||||
render?: RenderNodeFunction;
|
||||
setNode: dagre.graphlib.Graph['setNode'];
|
||||
};
|
||||
|
||||
const renderDefault = (props: RenderNodeProps) => <DefaultNode {...props} />;
|
||||
|
||||
export function Node({
|
||||
render = renderDefault,
|
||||
setNode,
|
||||
node,
|
||||
}: NodeComponentProps) {
|
||||
const { width, height, x = 0, y = 0, ...nodeProps } = node;
|
||||
const classes = useStyles();
|
||||
const nodeRef = React.useRef<SVGGElement | null>(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 (
|
||||
<g
|
||||
ref={nodeRef}
|
||||
data-testid={NODE_TEST_ID}
|
||||
className={classes.node}
|
||||
transform={`translate(${x - width / 2},${y - height / 2})`}
|
||||
>
|
||||
{render({ node: nodeProps })}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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 = CustomType> = T & {
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type GraphEdge<T = CustomType> = DependencyEdge<T> &
|
||||
dagre.GraphEdge &
|
||||
EdgeProperties;
|
||||
|
||||
export type RenderLabelProps<T = CustomType> = { edge: DependencyEdge<T> };
|
||||
|
||||
export type RenderLabelFunction = (
|
||||
props: RenderLabelProps<any>,
|
||||
) => React.ReactNode;
|
||||
|
||||
/* Nodes */
|
||||
export type DependencyNode<T = CustomType> = T & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type GraphNode<T = CustomType> = dagre.Node<DependencyNode<T>>;
|
||||
|
||||
export type RenderNodeProps<T = CustomType> = { node: DependencyNode<T> };
|
||||
|
||||
export type RenderNodeFunction = (
|
||||
props: RenderNodeProps<any>,
|
||||
) => 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',
|
||||
}
|
||||
@@ -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<CreateStorageApiOptions>,
|
||||
): StorageApi => {
|
||||
return WebStorage.create({
|
||||
errorApi: errorApi,
|
||||
...args,
|
||||
});
|
||||
};
|
||||
|
||||
const apis = ApiRegistry.from([[storageApiRef, createWebStorage()]]);
|
||||
|
||||
export const Default = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner"
|
||||
variant="info"
|
||||
id="default_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Error = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with an error message"
|
||||
variant="error"
|
||||
id="error_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const EmojisIncluded = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with emojis: 🚀 💚 😆 "
|
||||
variant="info"
|
||||
id="emojis_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithLink = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message={
|
||||
<Typography>
|
||||
This is a dismissable banner with a link:{' '}
|
||||
<Link href="http://example.com" color="textPrimary">
|
||||
example.com
|
||||
</Link>
|
||||
</Typography>
|
||||
}
|
||||
variant="info"
|
||||
id="linked_dismissable"
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
export const Fixed = () => (
|
||||
<div style={containerStyle}>
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
message="This is a dismissable banner with a fixed position fixed at the bottom of the page"
|
||||
variant="info"
|
||||
id="fixed_dismissable"
|
||||
fixed
|
||||
/>
|
||||
</ApiProvider>
|
||||
</div>
|
||||
);
|
||||
@@ -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('<DismissableBanner />', () => {
|
||||
let apis: ApiRegistry;
|
||||
const mockErrorApi = { post: jest.fn(), error$: jest.fn() };
|
||||
const createWebStorage = (
|
||||
args?: Partial<CreateStorageApiOptions>,
|
||||
): 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(
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
// setting={mockSetting}
|
||||
message="test message"
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
const element = await rendered.findByText('test message');
|
||||
expect(element).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gets placed in local storage on dismiss', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={apis}>
|
||||
<DismissableBanner
|
||||
variant="info"
|
||||
// setting={mockSetting}
|
||||
message="test message"
|
||||
id="catalog_page_welcome_banner"
|
||||
/>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
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<string[]>('dismissedBanners') ?? [];
|
||||
expect(
|
||||
dismissedBanners.includes('catalog_page_welcome_banner'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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<string[]>('dismissedBanners') ?? [];
|
||||
|
||||
const [dismissedBanners, setDismissedBanners] = useState(
|
||||
new Set(rawDismissedBanners),
|
||||
);
|
||||
|
||||
const observedItems = useObservable(
|
||||
notificationsStore.observe$<string[]>('dismissedBanners'),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (observedItems?.newValue) {
|
||||
const currentValue = observedItems?.newValue ?? [];
|
||||
setDismissedBanners(new Set(currentValue));
|
||||
}
|
||||
}, [observedItems?.newValue]);
|
||||
|
||||
const handleClick = () => {
|
||||
notificationsStore.set('dismissedBanners', [...dismissedBanners, id]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
anchorOrigin={
|
||||
fixed
|
||||
? { vertical: 'bottom', horizontal: 'center' }
|
||||
: { vertical: 'top', horizontal: 'center' }
|
||||
}
|
||||
open={!dismissedBanners.has(id)}
|
||||
classes={{
|
||||
root: classNames(classes.root, !fixed && classes.topPosition),
|
||||
}}
|
||||
>
|
||||
<SnackbarContent
|
||||
classes={{
|
||||
root: classNames(classes.content, classes[variant]),
|
||||
message: classes.message,
|
||||
}}
|
||||
message={message}
|
||||
action={[
|
||||
<IconButton
|
||||
key="dismiss"
|
||||
title="Permanently dismiss this message"
|
||||
color="inherit"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Close className={classes.icon} />
|
||||
</IconButton>,
|
||||
]}
|
||||
/>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className={classes.header}>
|
||||
<Typography variant="h5">Side Panel Title</Typography>
|
||||
<IconButton
|
||||
key="dismiss"
|
||||
title="Close the drawer"
|
||||
onClick={() => toggleDrawer(false)}
|
||||
color="inherit"
|
||||
>
|
||||
<Close className={classes.icon} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className={classes.content} />
|
||||
<div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(false)}
|
||||
>
|
||||
Primary Action
|
||||
</Button>
|
||||
<Button
|
||||
className={classes.secondaryAction}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(false)}
|
||||
>
|
||||
Secondary Action
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* 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 (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(true)}
|
||||
>
|
||||
Open Default Drawer
|
||||
</Button>
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.paper,
|
||||
}}
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={() => toggleDrawer(false)}
|
||||
>
|
||||
<DrawerContent toggleDrawer={toggleDrawer} />
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* 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 (
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={() => toggleDrawer(true)}
|
||||
>
|
||||
Open Persistent Drawer
|
||||
</Button>
|
||||
<Drawer
|
||||
classes={{
|
||||
paper: classes.paper,
|
||||
}}
|
||||
variant="persistent"
|
||||
anchor="right"
|
||||
open={isOpen}
|
||||
onClose={() => toggleDrawer(false)}
|
||||
>
|
||||
<DrawerContent toggleDrawer={toggleDrawer} />
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 = () => (
|
||||
<div style={containerStyle}>
|
||||
<MissingAnnotationEmptyState annotation="backstage.io/example" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Info = () => (
|
||||
<div style={containerStyle}>
|
||||
<EmptyState
|
||||
missing="info"
|
||||
title="No information to display"
|
||||
description="Add a description here."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Content = () => (
|
||||
<div style={containerStyle}>
|
||||
<EmptyState
|
||||
missing="content"
|
||||
title="Create a component"
|
||||
description="Add a description here."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Data = () => (
|
||||
<div style={containerStyle}>
|
||||
<EmptyState
|
||||
missing="data"
|
||||
title="No builds to show"
|
||||
description="Add a description here."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const WithAction = () => (
|
||||
<div style={containerStyle}>
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="Your plugin is missing an annotation"
|
||||
description="Click the docs to learn more."
|
||||
action={
|
||||
<Button color="primary" href="#" onClick={() => {}} variant="contained">
|
||||
DOCS
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -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('<EmptyState />', () => {
|
||||
it('render EmptyState component with type annotaion is missing', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="Your plugin is missing an annotation"
|
||||
action={<Button aria-label="button">DOCS</Button>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
rendered.getByText('Your plugin is missing an annotation'),
|
||||
).toBeInTheDocument();
|
||||
expect(rendered.getByLabelText('button')).toBeInTheDocument();
|
||||
expect(rendered.getByAltText('annotation is missing')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
justify="space-around"
|
||||
alignItems="flex-start"
|
||||
className={classes.root}
|
||||
spacing={2}
|
||||
>
|
||||
<Grid item container direction="column" xs={12} md={6}>
|
||||
<Grid item>
|
||||
<Typography variant="h5">{title}</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="body1">{description}</Typography>
|
||||
</Grid>
|
||||
<Grid item className={classes.action}>
|
||||
{action}
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6} className={classes.imageContainer}>
|
||||
<EmptyStateImage missing={missing} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
@@ -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('<EmptyStateImage />', () => {
|
||||
it('render EmptyStateImage component with missing field', async () => {
|
||||
const { getByAltText } = await renderWithEffects(
|
||||
wrapInTestApp(<EmptyStateImage missing="field" />),
|
||||
);
|
||||
expect(getByAltText('annotation is missing')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render EmptyStateImage component with missing info', async () => {
|
||||
const { getByAltText } = await renderWithEffects(
|
||||
wrapInTestApp(<EmptyStateImage missing="info" />),
|
||||
);
|
||||
expect(getByAltText('no Information')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render EmptyStateImage component with missing content', async () => {
|
||||
const { getByAltText } = await renderWithEffects(
|
||||
wrapInTestApp(<EmptyStateImage missing="content" />),
|
||||
);
|
||||
expect(getByAltText('create Component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render EmptyStateImage component with missing data', async () => {
|
||||
const { getByAltText } = await renderWithEffects(
|
||||
wrapInTestApp(<EmptyStateImage missing="data" />),
|
||||
);
|
||||
expect(getByAltText('no Build')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<img
|
||||
src={missingAnnotation}
|
||||
className={classes.generalImg}
|
||||
alt="annotation is missing"
|
||||
/>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<img
|
||||
src={noInformation}
|
||||
alt="no Information"
|
||||
className={classes.generalImg}
|
||||
/>
|
||||
);
|
||||
case 'content':
|
||||
return (
|
||||
<img
|
||||
src={createComponent}
|
||||
alt="create Component"
|
||||
className={classes.generalImg}
|
||||
/>
|
||||
);
|
||||
case 'data':
|
||||
return (
|
||||
<img src={noBuild} alt="no Build" className={classes.generalImg} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -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<BackstageTheme>(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 <code>{annotation}</code> annotation is missing. You need to add the
|
||||
annotation to your component if you want to enable this tool.
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<EmptyState
|
||||
missing="field"
|
||||
title="Missing Annotation"
|
||||
description={description}
|
||||
action={
|
||||
<>
|
||||
<Typography variant="body1">
|
||||
Add the annotation to your component YAML as shown in the
|
||||
highlighted example below:
|
||||
</Typography>
|
||||
<div className={classes.code}>
|
||||
<CodeSnippet
|
||||
text={COMPONENT_YAML.replace('ANNOTATION', annotation)}
|
||||
language="yaml"
|
||||
showLineNumbers
|
||||
highlightedNumbers={[6, 7]}
|
||||
customStyle={{ background: 'inherit', fontSize: '115%' }}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
href="https://backstage.io/docs/features/software-catalog/well-known-annotations"
|
||||
>
|
||||
Read more
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="693" height="425" fill="none" viewBox="0 0 693 425"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><path fill="#9E9E9E" d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V107.892C116 113.124 120.246 117.365 125.484 117.365H567.437C572.675 117.365 576.921 113.124 576.921 107.892V79.473C576.921 74.2412 572.675 70 567.437 70Z"/><mask id="mask0" width="461" height="277" x="116" y="70" mask-type="alpha" maskUnits="userSpaceOnUse"><path fill="#404040" d="M567.437 70H125.484C120.246 70 116 74.2412 116 79.473V337.138C116 342.37 120.246 346.611 125.484 346.611H567.437C572.675 346.611 576.921 342.37 576.921 337.138V79.473C576.921 74.2412 572.675 70 567.437 70Z"/></mask><g mask="url(#mask0)"><path fill="#EEE" d="M577 96.5244H116V347H577V96.5244Z"/><path fill="#D9D9D9" d="M129.278 87.0483C131.373 87.0483 133.071 85.3525 133.071 83.2606C133.071 81.1687 131.373 79.4729 129.278 79.4729C127.182 79.4729 125.484 81.1687 125.484 83.2606C125.484 85.3525 127.182 87.0483 129.278 87.0483Z" opacity=".4"/><path fill="#D9D9D9" d="M142.762 87.0483C144.857 87.0483 146.555 85.3525 146.555 83.2606C146.555 81.1687 144.857 79.4729 142.762 79.4729C140.667 79.4729 138.968 81.1687 138.968 83.2606C138.968 85.3525 140.667 87.0483 142.762 87.0483Z" opacity=".4"/><path fill="#D9D9D9" d="M155.833 87.0483C157.928 87.0483 159.626 85.3525 159.626 83.2606C159.626 81.1687 157.928 79.4729 155.833 79.4729C153.738 79.4729 152.039 81.1687 152.039 83.2606C152.039 85.3525 153.738 87.0483 155.833 87.0483Z" opacity=".3"/><rect width="27" height="251" x="116" y="96" fill="#616161"/><rect width="434" height="31" x="143" y="96" fill="#D9D9D9"/><rect width="60" height="7" x="153" y="136" fill="#fff" rx="3.5"/><rect width="118" height="7" x="153" y="148" fill="#fff" rx="3.5"/><rect width="52" height="16" x="515" y="136" fill="#BDBDBD" rx="2"/><rect width="121" height="94" x="154.5" y="166.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="128" height="94" x="292.5" y="166.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="128" height="94" x="437.5" y="166.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="197" height="78" x="154.5" y="276.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/><rect width="197" height="78" x="368.5" y="276.5" stroke="#D9D9D9" stroke-dasharray="5 5" stroke-width="3" rx="3.5"/></g></g><defs><filter id="filter0_d" width="500.921" height="316.611" x="98" y="54" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="10"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 7.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="693" height="425" fill="none" viewBox="0 0 693 425"><path fill="#000" fill-opacity=".05" fill-rule="evenodd" d="M40.4387 110.977C27.1556 113.452 18.3941 126.227 20.8693 139.51C23.3445 152.793 36.1192 161.555 49.4023 159.079L67.7036 155.669C62.6078 161.153 60.0854 168.896 61.562 176.82C64.0372 190.103 76.8118 198.865 90.0949 196.39L220.39 172.11C220.416 172.267 220.444 172.425 220.473 172.582C221.95 180.507 227.092 186.822 233.821 190.102L225.826 191.592C212.542 194.067 203.781 206.841 206.256 220.125C208.731 233.408 221.506 242.169 234.789 239.694L614.544 168.929C627.827 166.454 636.588 153.679 634.113 140.396C632.636 132.472 627.494 126.157 620.765 122.877L633.556 120.493C646.839 118.018 655.601 105.244 653.126 91.9604C650.65 78.6773 637.876 69.9158 624.593 72.3911L396.82 114.835C396.794 114.678 396.766 114.52 396.737 114.363C395.26 106.438 390.118 100.123 383.389 96.8431L405.503 92.7224C418.786 90.2471 427.547 77.4725 425.072 64.1894C422.597 50.9063 409.822 42.1448 396.539 44.62L40.4387 110.977ZM59.718 301.107C57.2428 287.824 66.0043 275.05 79.2874 272.574L468.975 199.959C482.258 197.484 495.032 206.245 497.508 219.528C499.983 232.811 491.221 245.586 477.938 248.061L466.779 250.141C468.082 250.776 469.325 251.524 470.493 252.376L605.53 227.213C618.813 224.738 631.588 233.499 634.063 246.782C635.54 254.707 633.017 262.45 627.922 267.934L630.641 267.427C643.924 264.952 656.699 273.713 659.174 286.996C661.649 300.279 652.888 313.054 639.604 315.529L234.809 390.96C221.526 393.435 208.751 384.674 206.276 371.391C203.801 358.108 212.563 345.333 225.846 342.858L255.404 337.35C254.101 336.715 252.858 335.966 251.69 335.114L71.5053 368.69C58.2222 371.165 45.4476 362.404 42.9723 349.121C40.4971 335.838 49.2586 323.063 62.5417 320.588L73.0655 318.627C66.3367 315.347 61.1946 309.032 59.718 301.107Z" clip-rule="evenodd"/><g filter="url(#filter0_d)"><rect width="461" height="286" x="122" y="70" fill="#F8F8F8" rx="10"/><rect width="55" height="7" x="150" y="96" fill="#D9D9D9" rx="3.5"/><rect width="42" height="7" x="150" y="135" fill="#BDBDBD" rx="3.5"/><rect width="65" height="7" x="150" y="174" fill="#BDBDBD" rx="3.5"/><rect width="60" height="7" x="150" y="213" fill="#BDBDBD" rx="3.5"/><rect width="84" height="7" x="150" y="252" fill="#BDBDBD" rx="3.5"/><rect width="42" height="7" x="150" y="291" fill="#BDBDBD" rx="3.5"/><rect width="65" height="7" x="150" y="330" fill="#BDBDBD" rx="3.5"/><rect width="35" height="7" x="282" y="96" fill="#D9D9D9" rx="3.5"/><rect width="102" height="7" x="282" y="135" fill="#BDBDBD" rx="3.5"/><rect width="77" height="7" x="282" y="174" fill="#BDBDBD" rx="3.5"/><rect width="93" height="7" x="282" y="213" fill="#BDBDBD" rx="3.5"/><rect width="42" height="7" x="282" y="252" fill="#BDBDBD" rx="3.5"/><rect width="69" height="7" x="282" y="291" fill="#BDBDBD" rx="3.5"/><rect width="97" height="7" x="282" y="330" fill="#BDBDBD" rx="3.5"/><rect width="92" height="7" x="422" y="96" fill="#D9D9D9" rx="3.5"/><rect width="62" height="7" x="422" y="135" fill="#BDBDBD" rx="3.5"/><rect width="21" height="7" x="422" y="174" fill="#BDBDBD" rx="3.5"/><rect width="39" height="7" x="422" y="213" fill="#BDBDBD" rx="3.5"/><rect width="112" height="7" x="422" y="252" fill="#BDBDBD" rx="3.5"/><rect width="65" height="7" x="422" y="291" fill="#BDBDBD" rx="3.5"/><rect width="30" height="7" x="422" y="330" fill="#BDBDBD" rx="3.5"/><line x1="138" x2="567" y1="118.5" y2="118.5" stroke="#EEE"/><line x1="138" x2="567" y1="157.5" y2="157.5" stroke="#EEE"/><line x1="138" x2="567" y1="196.5" y2="196.5" stroke="#EEE"/><line x1="138" x2="567" y1="235.5" y2="235.5" stroke="#EEE"/><line x1="138" x2="567" y1="274.5" y2="274.5" stroke="#EEE"/><line x1="138" x2="567" y1="313.5" y2="313.5" stroke="#EEE"/></g><defs><filter id="filter0_d" width="485" height="310" x="112" y="62" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="267" height="172" fill="none" viewBox="0 0 267 172"><g filter="url(#filter0_d)"><rect width="139" height="104.906" x="10" y="50.165" fill="#EEE" rx="5"/></g><mask id="mask0" width="121" height="98" x="19" y="58" mask-type="alpha" maskUnits="userSpaceOnUse"><rect width="9.179" height="70.156" x="19.835" y="85.571" fill="#fff" rx="4.59"/><rect width="9.179" height="78.679" x="38.194" y="77.047" fill="#fff" rx="4.59"/><rect width="9.179" height="97.693" x="56.552" y="58.033" fill="#fff" rx="4.59"/><rect width="9.179" height="81.957" x="74.91" y="73.769" fill="#fff" rx="4.59"/><rect width="9.179" height="60.321" x="93.269" y="95.406" fill="#fff" rx="4.59"/><rect width="9.179" height="74.09" x="111.627" y="81.637" fill="#fff" rx="4.59"/><rect width="9.179" height="93.104" x="129.986" y="62.623" fill="#fff" rx="4.59"/></mask><g mask="url(#mask0)"><rect width="139" height="100.316" x="10.656" y="50.165" fill="#C4C4C4"/></g><g filter="url(#filter1_d)"><rect width="144" height="108.679" x="109" y="8" fill="#EEE" rx="5"/></g><path fill="#D9D9D9" d="M173.85 62.1192C144.607 37.3215 129.993 65.1991 120.077 80.5984V106.585H241.923V25.7384C208.172 24.5834 212.569 94.9538 173.85 62.1192Z"/><path stroke="#9E9E9E" stroke-linecap="round" stroke-linejoin="round" d="M120.077 80.5984C129.993 65.1991 144.607 37.3215 173.85 62.1192C212.569 94.9539 208.172 24.5834 241.923 25.7384"/><defs><filter id="filter0_d" width="163" height="128.906" x="0" y="42.165" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter><filter id="filter1_d" width="168" height="132.679" x="99" y="0" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/><feOffset dx="2" dy="4"/><feGaussianBlur stdDeviation="6"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/><feBlend in2="BackgroundImageFix" mode="normal" result="effect1_dropShadow"/><feBlend in="SourceGraphic" in2="effect1_dropShadow" mode="normal" result="shape"/></filter></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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';
|
||||
@@ -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('<FeatureCalloutCircular />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const rendered = await renderInTestApp(
|
||||
<FeatureCalloutCircular
|
||||
featureId="feature-id"
|
||||
title="title"
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
rendered.getByText('description');
|
||||
rendered.getByText('title');
|
||||
});
|
||||
|
||||
it('renders with correct style', async () => {
|
||||
const { getByTestId } = await renderInTestApp(
|
||||
<FeatureCalloutCircular
|
||||
featureId="feature-id"
|
||||
title="title"
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FeatureCalloutCircular
|
||||
featureId="feature-id"
|
||||
title="title"
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FeatureCalloutCircular
|
||||
featureId="feature-id"
|
||||
title="title"
|
||||
description="description"
|
||||
/>,
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<Props>) => {
|
||||
const { show, hide } = useShowCallout(featureId);
|
||||
const portalElement = usePortal('core.callout');
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [placement, setPlacement] = useState<Placement | undefined>();
|
||||
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 (
|
||||
<>
|
||||
<div className={classes.featureWrapper} ref={wrapperRef}>
|
||||
{children}
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className={classes.backdrop}>
|
||||
<ClickAwayListener onClickAway={hide}>
|
||||
<>
|
||||
<div
|
||||
className={classes.dot}
|
||||
data-testid="dot"
|
||||
style={{
|
||||
left: placement?.dotLeft,
|
||||
top: placement?.dotTop,
|
||||
width: placement?.dotSize,
|
||||
height: placement?.dotSize,
|
||||
borderWidth: placement?.borderWidth,
|
||||
}}
|
||||
onClick={hide}
|
||||
onKeyDown={hide}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className={classes.pulseCircle} />
|
||||
</div>
|
||||
<div
|
||||
className={classes.text}
|
||||
data-testid="text"
|
||||
style={{
|
||||
left: placement?.textLeft,
|
||||
top: placement?.textTop,
|
||||
width: placement?.textWidth,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h2" paragraph>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography>{description}</Typography>
|
||||
</div>
|
||||
</>
|
||||
</ClickAwayListener>
|
||||
</div>,
|
||||
portalElement,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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<HTMLElement | null>(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;
|
||||
@@ -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<string, boolean>;
|
||||
setState: (key: string, value: boolean) => void;
|
||||
} {
|
||||
const [states, setStates] = useState<Record<string, boolean>>(() => {
|
||||
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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<nav className={classes.links}>
|
||||
{links.map((link, index) => (
|
||||
<IconLinkVertical key={index + 1} {...link} />
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLAnchorElement>;
|
||||
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 = <LinkIcon />,
|
||||
label,
|
||||
onClick,
|
||||
title,
|
||||
}: IconLinkVerticalProps) {
|
||||
const classes = useIconStyles();
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<Link
|
||||
title={title}
|
||||
className={classnames(classes.link, classes.disabled)}
|
||||
underline="none"
|
||||
>
|
||||
{icon}
|
||||
<span className={classes.label}>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
title={title}
|
||||
className={classnames(classes.link, classes[color])}
|
||||
to={href}
|
||||
component={RouterLink}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
<span className={classes.label}>{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
+37
@@ -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 = () => (
|
||||
<div style={containerStyle}>
|
||||
<HorizontalScrollGrid>
|
||||
{opacityArray.map(element => {
|
||||
const style = { backgroundColor: `rgba(0, 185, 151, ${element})` };
|
||||
return <div style={{ ...style, ...cardContentStyle }} key={element} />;
|
||||
})}
|
||||
</HorizontalScrollGrid>
|
||||
</div>
|
||||
);
|
||||
@@ -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('<HorizontalScrollGrid />', () => {
|
||||
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(
|
||||
<HorizontalScrollGrid>
|
||||
<Grid item>item1</Grid>
|
||||
<Grid item>item2</Grid>
|
||||
</HorizontalScrollGrid>,
|
||||
);
|
||||
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(
|
||||
<HorizontalScrollGrid>
|
||||
<Grid item style={{ minWidth: 200 }}>
|
||||
item1
|
||||
</Grid>
|
||||
<Grid item style={{ minWidth: 200 }}>
|
||||
item2
|
||||
</Grid>
|
||||
</HorizontalScrollGrid>,
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -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>(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<HTMLElement | undefined>,
|
||||
): [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<HTMLElement | undefined>,
|
||||
speed: number,
|
||||
minDistance: number,
|
||||
) {
|
||||
const [scrollTarget, setScrollTarget] = React.useState<number>(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<Props>) => {
|
||||
const {
|
||||
scrollStep = 100,
|
||||
scrollSpeed = 50,
|
||||
minScrollDistance = 5,
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
const classes = useStyles(props);
|
||||
const ref = React.useRef<HTMLElement>();
|
||||
|
||||
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 (
|
||||
<div {...otherProps} className={classes.root}>
|
||||
<Grid
|
||||
container
|
||||
direction="row"
|
||||
wrap="nowrap"
|
||||
className={classes.container}
|
||||
ref={ref as any}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
<div
|
||||
className={classNames(classes.fade, classes.fadeLeft, {
|
||||
[classes.fadeHidden]: scrollLeft === 0,
|
||||
})}
|
||||
/>
|
||||
<div
|
||||
className={classNames(classes.fade, classes.fadeRight, {
|
||||
[classes.fadeHidden]: scrollRight === 0,
|
||||
})}
|
||||
/>
|
||||
{scrollLeft > 0 && (
|
||||
<IconButton
|
||||
title="Scroll Left"
|
||||
onClick={() => handleScrollClick(false)}
|
||||
className={classNames(classes.button, classes.buttonLeft, {})}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{scrollRight > 0 && (
|
||||
<IconButton
|
||||
title="Scroll Right"
|
||||
onClick={() => handleScrollClick(true)}
|
||||
className={classNames(classes.button, classes.buttonRight, {})}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 <Lifecycle alpha />
|
||||
</>
|
||||
);
|
||||
export const AlphaShorthand = () => (
|
||||
<>
|
||||
This feature is in <Lifecycle alpha shorthand />
|
||||
</>
|
||||
);
|
||||
export const BetaDefault = () => (
|
||||
<>
|
||||
This feature is in <Lifecycle />
|
||||
</>
|
||||
);
|
||||
export const BetaShorthand = () => (
|
||||
<>
|
||||
This feature is in <Lifecycle shorthand />
|
||||
</>
|
||||
);
|
||||
@@ -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('<Lifecycle />', () => {
|
||||
it('renders Alpha with shorthand', async () => {
|
||||
const { getByText } = await renderInTestApp(<Lifecycle alpha shorthand />);
|
||||
expect(getByText('α')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Alpha without shorthand', async () => {
|
||||
const { getByText } = await renderInTestApp(<Lifecycle alpha />);
|
||||
expect(getByText('Alpha')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Beta with shorthand', async () => {
|
||||
const { getByText } = await renderInTestApp(<Lifecycle shorthand />);
|
||||
expect(getByText('β')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Beta without shorthand', async () => {
|
||||
const { getByText } = await renderInTestApp(<Lifecycle />);
|
||||
expect(getByText('Beta')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 ? (
|
||||
<span
|
||||
className={classes[alpha ? 'alpha' : 'beta']}
|
||||
style={{ fontSize: '120%' }}
|
||||
>
|
||||
{alpha ? <>α</> : <>β</>}
|
||||
</span>
|
||||
) : (
|
||||
<span className={classes[alpha ? 'alpha' : 'beta']}>
|
||||
{alpha ? 'Alpha' : 'Beta'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 <pre>Current location: {location.pathname}</pre>;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: 'Navigation/Link',
|
||||
component: Link,
|
||||
decorators: [
|
||||
(storyFn: FunctionComponentFactory<{}>) => (
|
||||
<MemoryRouter>
|
||||
<div>
|
||||
<div>
|
||||
<Location />
|
||||
</div>
|
||||
{storyFn()}
|
||||
</div>
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Default = () => {
|
||||
const routeRef = createRouteRef({
|
||||
path: '/hello',
|
||||
title: 'Hi there!',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link to={routeRef.path}>This link</Link> will utilise the
|
||||
react-router MemoryRouter's navigation
|
||||
<Route path={routeRef.path}>
|
||||
<h1>{routeRef.title}</h1>
|
||||
</Route>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PassProps = () => {
|
||||
const routeRef = createRouteRef({
|
||||
path: '/hello',
|
||||
title: 'Hi there!',
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link
|
||||
to={routeRef.path}
|
||||
/** react-router-dom related prop */
|
||||
component={RouterNavLink}
|
||||
/** material-ui related prop */
|
||||
color="secondary"
|
||||
>
|
||||
This link
|
||||
</Link>
|
||||
has props for both material-ui's component as well as for
|
||||
react-router-dom's
|
||||
<Route path={routeRef.path}>
|
||||
<h1>{routeRef.title}</h1>
|
||||
</Route>
|
||||
</>
|
||||
);
|
||||
};
|
||||
PassProps.story = {
|
||||
name: `Accepts material-ui Link's and react-router-dom Link's props`,
|
||||
};
|
||||
@@ -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('<Link />', () => {
|
||||
it('navigates using react-router', async () => {
|
||||
const testString = 'This is test string';
|
||||
const linkText = 'Navigate!';
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<Routes>
|
||||
<Link to="/test">{linkText}</Link>
|
||||
<Route path="/test" element={<p>{testString}</p>} />
|
||||
</Routes>,
|
||||
),
|
||||
);
|
||||
expect(() => getByText(testString)).toThrow();
|
||||
await act(async () => {
|
||||
fireEvent.click(getByText(linkText));
|
||||
});
|
||||
expect(getByText(testString)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin wrapper on top of material-ui's Link component
|
||||
* Makes the Link to utilise react-router
|
||||
*/
|
||||
export const Link = React.forwardRef<any, LinkProps>((props, ref) => {
|
||||
const to = String(props.to);
|
||||
return /^https?:\/\//.test(to) ? (
|
||||
// External links
|
||||
<MaterialLink ref={ref} href={to} {...props} />
|
||||
) : (
|
||||
// Interact with React Router for internal links
|
||||
<MaterialLink ref={ref} component={RouterLink} {...props} />
|
||||
);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 = () => (
|
||||
<MarkdownContent content={markdown} dialect="common-mark" />
|
||||
);
|
||||
|
||||
export const MarkdownContentGithubFlavoredCommonMark = () => (
|
||||
<MarkdownContent content={markdownGithubFlavored} dialect="gfm" />
|
||||
);
|
||||
@@ -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('<MarkdownContent />', () => {
|
||||
it('render MarkdownContent component', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<MarkdownContent content={'# H1\n' + '## H2\n' + '### H3'} />,
|
||||
),
|
||||
);
|
||||
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(<MarkdownContent content="https://example.com" />),
|
||||
);
|
||||
expect(
|
||||
rendered.getByText('https://example.com', { selector: 'a' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Render MarkdownContent component with common mark dialect', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(
|
||||
<MarkdownContent content="https://example.com" dialect="common-mark" />,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
rendered.getByText('https://example.com', { selector: 'p' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render MarkdownContent component with CodeSnippet for code blocks', async () => {
|
||||
const rendered = await renderWithEffects(
|
||||
wrapInTestApp(<MarkdownContent content=" jest(test: string);" />),
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<BackstageTheme>(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 <CodeSnippet language={language} text={value} />;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<ReactMarkdown
|
||||
plugins={dialect === 'gfm' ? [gfm] : []}
|
||||
className={classes.markdown}
|
||||
children={content}
|
||||
renderers={renderers}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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>(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<Error>();
|
||||
|
||||
const handleContinue = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request.trigger();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const IconComponent = request.provider.icon;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
button
|
||||
disabled={busy}
|
||||
onClick={handleContinue}
|
||||
classes={{ root: classes.root }}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<IconComponent fontSize="large" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={request.provider.title}
|
||||
secondary={
|
||||
error && (
|
||||
<Typography color="error">
|
||||
{error.message || 'An unspecified error occurred'}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginRequestListItem;
|
||||
@@ -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>(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 (
|
||||
<Dialog
|
||||
open={Boolean(requests.length)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
classes={{ paper: classes.dialog }}
|
||||
>
|
||||
<DialogTitle classes={{ root: classes.title }}>
|
||||
Login Required
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent classes={{ root: classes.contentList }}>
|
||||
<List>
|
||||
{requests.map(request => (
|
||||
<LoginRequestListItem
|
||||
key={request.provider.title}
|
||||
request={request}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleRejectAll}>Reject All</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = () => (
|
||||
<Box maxWidth="200px">
|
||||
<OverflowTooltip text={text} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const MultiLine = () => (
|
||||
<Box maxWidth="200px">
|
||||
<OverflowTooltip text={text} line={2} />
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const DifferentTitle = () => (
|
||||
<Box maxWidth="200px">
|
||||
<OverflowTooltip
|
||||
title="Visit loremipsum.io for more info"
|
||||
text={text}
|
||||
line={2}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
@@ -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 (
|
||||
<Tooltip
|
||||
title={props.title ?? props.text!}
|
||||
placement={props.placement}
|
||||
disableHoverListener={!hover}
|
||||
>
|
||||
<TextTruncate
|
||||
text={props.text}
|
||||
line={props.line}
|
||||
onToggled={handleToggled}
|
||||
containerClassName={classes.container}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = () => <Progress />;
|
||||
@@ -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('<Progress />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
jest.useFakeTimers();
|
||||
const { getByTestId, queryByTestId } = await renderInTestApp(<Progress />);
|
||||
expect(queryByTestId('progress')).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
expect(getByTestId('progress')).toBeInTheDocument();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -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<LinearProgressProps>) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handle = setTimeout(() => setIsVisible(true), 250);
|
||||
return () => clearTimeout(handle);
|
||||
}, []);
|
||||
|
||||
return isVisible ? (
|
||||
<LinearProgress {...props} data-testid="progress" />
|
||||
) : (
|
||||
<div style={{ display: 'none' }} />
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 = () => (
|
||||
<div style={containerStyle}>
|
||||
<Gauge value={0.8} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const MediumProgress = () => (
|
||||
<div style={containerStyle}>
|
||||
<Gauge value={0.5} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const LowProgress = () => (
|
||||
<div style={containerStyle}>
|
||||
<Gauge value={0.2} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const InverseLowProgress = () => (
|
||||
<div style={containerStyle}>
|
||||
<Gauge value={0.2} inverse />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const AbsoluteProgress = () => (
|
||||
<div style={containerStyle}>
|
||||
<Gauge value={89.2} fractional={false} unit="m/s" />
|
||||
</div>
|
||||
);
|
||||
@@ -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('<Gauge />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Gauge value={10} fractional={false} />,
|
||||
);
|
||||
getByText('10%');
|
||||
});
|
||||
it('handles fractional prop', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Gauge value={0.1} fractional />,
|
||||
);
|
||||
getByText('10%');
|
||||
});
|
||||
|
||||
it('handles max prop', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Gauge value={1} max={10} fractional={false} />,
|
||||
);
|
||||
getByText('1%');
|
||||
});
|
||||
|
||||
it('handles unit prop', async () => {
|
||||
const { getByText } = await renderInTestApp(
|
||||
<Gauge value={10} fractional={false} unit="m" />,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<BackstageTheme>(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<BackstageTheme>();
|
||||
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 (
|
||||
<div className={classes.root}>
|
||||
<Circle
|
||||
strokeLinecap="butt"
|
||||
percent={asPercentage}
|
||||
strokeWidth={12}
|
||||
trailWidth={12}
|
||||
strokeColor={getProgressColor(theme.palette, asActual, inverse, max)}
|
||||
className={classes.circle}
|
||||
/>
|
||||
<div className={classes.overlay}>
|
||||
{isNaN(value) ? 'N/A' : `${asActual}${unit}`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<{}>) => (
|
||||
<MemoryRouter>
|
||||
<Grid container spacing={2}>
|
||||
{children}
|
||||
</Grid>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
export default {
|
||||
title: 'Data Display/Progress Card',
|
||||
component: GaugeCard,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<Wrapper>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" progress={0.3} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" progress={0.57} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" progress={0.89} />
|
||||
</Grid>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export const Subhead = () => (
|
||||
<Wrapper>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" subheader="With a subheader" progress={0.3} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<GaugeCard
|
||||
title="Progress"
|
||||
subheader="With a subheader"
|
||||
progress={0.57}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<GaugeCard
|
||||
title="Progress"
|
||||
subheader="With a subheader"
|
||||
progress={0.89}
|
||||
/>
|
||||
</Grid>
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
export const LinkInFooter = () => (
|
||||
<Wrapper>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" deepLink={linkInfo} progress={0.3} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" deepLink={linkInfo} progress={0.57} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<GaugeCard title="Progress" deepLink={linkInfo} progress={0.89} />
|
||||
</Grid>
|
||||
</Wrapper>
|
||||
);
|
||||
@@ -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('<GaugeCard />', () => {
|
||||
it('renders without exploding', async () => {
|
||||
const { getByText } = await renderInTestApp(<GaugeCard {...minProps} />);
|
||||
expect(getByText(/Tingle.*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders progress and title', async () => {
|
||||
const { getByText } = await renderInTestApp(<GaugeCard {...minProps} />);
|
||||
expect(getByText(/Tingle.*/)).toBeInTheDocument();
|
||||
expect(getByText(/12%.*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render deepLink', async () => {
|
||||
const { queryByText } = await renderInTestApp(<GaugeCard {...minProps} />);
|
||||
expect(queryByText('View more')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles invalid numbers', async () => {
|
||||
const badProps = { title: 'Tingle upgrade', progress: 'hejjo' } as any;
|
||||
const { getByText } = await renderInTestApp(<GaugeCard {...badProps} />);
|
||||
expect(getByText(/N\/A.*/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className={classes.root}>
|
||||
<InfoCard
|
||||
title={title}
|
||||
subheader={subheader}
|
||||
deepLink={deepLink}
|
||||
variant={variant}
|
||||
>
|
||||
<Gauge value={progress} />
|
||||
</InfoCard>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user