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:
Patrik Oldsberg
2021-03-10 12:30:24 +01:00
parent 65da177bd2
commit b738adebe3
241 changed files with 17776 additions and 0 deletions
+8
View File
@@ -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,
},
};
+1
View File
@@ -0,0 +1 @@
registry=https://registry.npmjs.org/
+358
View File
@@ -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.
![image](https://user-images.githubusercontent.com/46953622/105390193-0bfd0080-5c19-11eb-8e86-2161bbe6e8d9.png)
### 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
![](https://user-images.githubusercontent.com/872486/92251660-bb9e3400-eeff-11ea-86fe-1f2a0262cd31.png)
![](https://user-images.githubusercontent.com/872486/93851658-1a76f200-fce3-11ea-990b-26ca1a327a15.png)
- 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.
![](https://user-images.githubusercontent.com/15106494/93765685-999df480-fc15-11ea-8fa5-11cac5836cf1.png)
![](https://user-images.githubusercontent.com/15106494/93765697-9e62a880-fc15-11ea-92af-b6a7fee4bb21.png)
- 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
+22
View File
@@ -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)
+113
View File
@@ -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;
};
}
+91
View File
@@ -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>
&nbsp; 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>
&nbsp; 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>
&nbsp; links to a defined URL using Material-UI's Button.
</ListItem>
<ListItem>
<MaterialButton
onClick={handleClick}
color="default"
variant="outlined"
>
Trigger Event
</MaterialButton>
&nbsp; 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';
@@ -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 ? <>&alpha;</> : <>&beta;</>}
</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>&nbsp;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>
&nbsp;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