Merge branch 'master' of https://github.com/spotify/backstage into new_branch
This commit is contained in:
+3
-3
@@ -1,5 +1,5 @@
|
||||
const path = require('path')
|
||||
const base = require('@spotify-backstage/cli/config/eslint');
|
||||
const path = require('path');
|
||||
const base = require('@backstage/cli/config/eslint');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
@@ -8,7 +8,7 @@ module.exports = {
|
||||
'notice/notice': [
|
||||
'error',
|
||||
{
|
||||
templateFile: path.resolve(__dirname, "scripts/copyright.js"),
|
||||
templateFile: path.resolve(__dirname, 'scripts/copyright.js'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+16
-10
@@ -2,7 +2,10 @@ name: CLI Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['.github/workflows/cli.yml', 'packages/cli/**']
|
||||
paths:
|
||||
- '.github/workflows/cli.yml'
|
||||
- 'packages/cli/**'
|
||||
- 'packages/core/**'
|
||||
types: [opened, reopened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
@@ -11,16 +14,17 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
node-version: [12.x]
|
||||
|
||||
name: Node ${{ matrix.node-version }} on ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: find location of yarn cache
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
- name: cache global yarn cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.yarn-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
@@ -30,15 +34,17 @@ jobs:
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- run: yarn install
|
||||
- name: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
- run: yarn build
|
||||
# This creates a new plugin and pollutes the workspace, so it should be run last.
|
||||
- name: verify app serve and plugin creation
|
||||
shell: bash
|
||||
- name: verify app serve and plugin creation on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: node scripts/cli-e2e-test.js
|
||||
- name: verify app serve and plugin creation on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Linux" ]; then
|
||||
sudo sysctl fs.inotify.max_user_watches=524288
|
||||
fi
|
||||
sudo sysctl fs.inotify.max_user_watches=524288
|
||||
node scripts/cli-e2e-test.js
|
||||
- name: yarn lint, test after plugin creation
|
||||
working-directory: plugins/test-plugin
|
||||
|
||||
@@ -12,26 +12,47 @@ jobs:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
BACKSTAGE_CACHE_DIR: <repoRoot>/.backstage-build-cache
|
||||
BACKSTAGE_CACHE_MAX_ENTRIES: 2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: find location of yarn cache
|
||||
- name: fetch branch master
|
||||
run: git fetch origin master
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
- name: cache global yarn cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.yarn-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
- name: cache node_modules
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: cache build cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: .backstage-build-cache
|
||||
key: build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
build-cache-
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
- name: yarn install, build, and test
|
||||
run: |
|
||||
yarn install
|
||||
yarn lint
|
||||
yarn build
|
||||
yarn test
|
||||
env:
|
||||
CI: true
|
||||
- name: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
- run: yarn build
|
||||
- run: yarn test
|
||||
- name: yarn bundle, if app was changed
|
||||
run: git diff --quiet origin/master HEAD -- packages/app packages/core || yarn bundle
|
||||
- name: verify storybook
|
||||
run: yarn workspace storybook build-storybook
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Master Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
|
||||
env:
|
||||
CI: true
|
||||
BACKSTAGE_CACHE_DIR: <repoRoot>/.backstage-build-cache
|
||||
BACKSTAGE_CACHE_MAX_ENTRIES: 2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- name: cache global yarn cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.yarn-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
- name: cache node_modules
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
|
||||
- name: cache build cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: .backstage-build-cache
|
||||
key: build-cache-${{ github.sha }}
|
||||
restore-keys: |
|
||||
build-cache-
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
registry-url: https://registry.npmjs.org/ # Needed for auth
|
||||
- name: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
- run: yarn lint
|
||||
- run: yarn build
|
||||
- run: yarn test
|
||||
# Publishes current version of packages that are not already present in the registry
|
||||
- name: publish
|
||||
run: npx --no-install lerna publish from-package --yes
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
# Tags the commit with the version in the core package if the tag doesn't exist
|
||||
- uses: Klemensas/action-autotag@1.2.3
|
||||
with:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
package_root: "packages/core"
|
||||
tag_prefix: "v"
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Deploy Storybook
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- '.github/workflows/storybook-deploy.yml'
|
||||
- 'packages/storybook/**'
|
||||
- 'packages/core/src/**'
|
||||
|
||||
jobs:
|
||||
deploy-storybook:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [12.x]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: find location of global yarn cache
|
||||
id: yarn-cache
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- name: cache global yarn cache
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: ${{ steps.yarn-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
- name: cache node_modules
|
||||
uses: actions/cache@v1
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-modules-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: use node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
registry-url: https://registry.npmjs.org/ # Needed for auth
|
||||
- name: yarn install
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: build storybook
|
||||
run: yarn workspace storybook build-storybook
|
||||
- name: deploy storybook to gh-pages
|
||||
uses: JamesIves/github-pages-deploy-action@3.4.2
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BRANCH: gh-pages
|
||||
FOLDER: packages/storybook/dist
|
||||
@@ -0,0 +1,27 @@
|
||||
# Deploying Backstage
|
||||
|
||||
## Heroku
|
||||
|
||||
Deploying to heroku is relatively easy following these steps.
|
||||
|
||||
First, make sure you have the [heroku CLI installed](https://devcenter.heroku.com/articles/heroku-cli) and log into it as well as loging into Heroku's [container registry](https://devcenter.heroku.com/articles/container-registry-and-runtime).
|
||||
|
||||
```bash
|
||||
$ heroku login
|
||||
$ heroku container:login
|
||||
```
|
||||
|
||||
You _might_ also need to set your Heroku app's stack to `container`
|
||||
|
||||
```bash
|
||||
$ heroku stack:set container -a <your-app>
|
||||
```
|
||||
|
||||
We can now build/push the Docker image to Heroku's container registry and release it to the `web` worker.
|
||||
|
||||
```bash
|
||||
$ heroku container:push web -a <your-app>
|
||||
$ heroku container:release web -a <your-app>
|
||||
```
|
||||
|
||||
With that, you should have Backstage up and running!
|
||||
+8
-1
@@ -8,10 +8,17 @@ COPY plugins /app/plugins
|
||||
|
||||
RUN yarn
|
||||
|
||||
COPY . .
|
||||
COPY lerna.json tsconfig.json .eslintignore .eslintrc.js /app/
|
||||
COPY scripts/ /app/scripts
|
||||
|
||||
RUN yarn build
|
||||
|
||||
FROM nginx:mainline
|
||||
|
||||
COPY --from=builder /app/packages/app/build /usr/share/nginx/html
|
||||
|
||||
COPY docker/default.conf.template /etc/nginx/conf.d/default.conf.template
|
||||
COPY docker/run.sh /usr/local/bin/run.sh
|
||||
CMD run.sh
|
||||
|
||||
ENV PORT 80
|
||||
|
||||
@@ -33,7 +33,7 @@ We created Backstage about 4 years ago. While our internal version of Backstage
|
||||
|
||||
- 🐇 **Phase 3:** Ecosystem (later) - Everyone's infrastructure stack is different. By fostering a vibrant community of contributors we hope to provide an ecosystem of Open Source plugins/integrations that allows you to pick the tools that match your stack.
|
||||
|
||||
Check out our [Milestones](https://github.com/spotify/backstage/milestones) and how they relate to the 3 Phases outlined above.
|
||||
Check out our [Milestones](https://github.com/spotify/backstage/milestones) and open [RFCs](https://github.com/spotify/backstage/labels/rfc) how they relate to the three Phases outlined above.
|
||||
|
||||
Our vision for Backstage is for it to become the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. If this sounds interesting or you'd like to help us shape our product vision, we'd love to talk. You can email me directly: [alund@spotify.com](mailto:alund@spotify.com).
|
||||
|
||||
@@ -69,16 +69,8 @@ $ yarn start
|
||||
|
||||
The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
|
||||
|
||||
### (Optional)Try on Docker
|
||||
|
||||
Run the following commands if you have Docker environment
|
||||
|
||||
```bash
|
||||
$ docker build . -t spotify/backstage
|
||||
$ docker run --rm -it -p 80:80 spotify/backstage
|
||||
```
|
||||
|
||||
Then open http://localhost/ on your browser.
|
||||
For more complex development environment configuration, see the
|
||||
[Development Environment](docs/getting-started/development-environment.md) section of the Getting Started docs.
|
||||
|
||||
## Documentation
|
||||
|
||||
@@ -93,8 +85,10 @@ Then open http://localhost/ on your browser.
|
||||
|
||||
- [Discord chat](https://discord.gg/MUpMjP2) - Get support or discuss the project
|
||||
- [Good First Issues](https://github.com/spotify/backstage/labels/good%20first%20issue) - Start here if you want to contribute
|
||||
- [RFCs](https://github.com/spotify/backstage/labels/rfc) - Help shape the technical direction
|
||||
- [FAQ](docs/FAQ.md) - Frequently Asked Questions
|
||||
- [Code of Conduct](CODE_OF_CONDUCT.md) - This is how we roll
|
||||
- [Blog](https://backstage.io/blog/) - Announcements and updates
|
||||
- Give us a star ⭐️ - If you are using Backstage or think it is an interesting project, we would love a star ❤️
|
||||
|
||||
Or, if you are an open source developer and are interested in joining our team, please reach out to [foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen $PORT;
|
||||
server_name localhost;
|
||||
|
||||
#charset koi8-r;
|
||||
#access_log /var/log/nginx/host.access.log main;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
|
||||
#error_page 404 /404.html;
|
||||
|
||||
# redirect server error pages to the static page /50x.html
|
||||
#
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Run nginx as root
|
||||
sed -i 's/user nginx.*$//' /etc/nginx/nginx.conf
|
||||
|
||||
# Write selected env vars to nginx config
|
||||
envsubst '$PORT' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
+20
@@ -20,3 +20,23 @@ Additional open sourced plugins would be added to the `plugins` directory in thi
|
||||
While we encourage using the open soure model, integrators that want to experiment with
|
||||
Backstage internally may also choose to develop closed source plugins in a manner that suits
|
||||
them best, for example in their respective Backstage source repository.
|
||||
|
||||
## Any plans for integrating with other repository managers such as Gitlab or Bitbucket?
|
||||
|
||||
We chose Github by the fact that it is the tool that we are most familiar with and that will naturally
|
||||
lead to integrations for Github specifically being developed in an early stage.
|
||||
|
||||
Hosting this project on Github does not exclude integrations with other alternatives such as Gitlab or
|
||||
Bitbucket. We believe that in time there will be plugins that will provide functionality for these tools
|
||||
as well. Hopefully contributed by the community.
|
||||
|
||||
And note that implementations of Backstage can be hosted wherever you feel suits your needs best.
|
||||
|
||||
## Can Backstage by used for other things than developer portals?
|
||||
|
||||
Yes.
|
||||
|
||||
The core frontend framework could be used for building any large-scale web application where multiple teams are building separate parts of the app, but you want the overall experience to be consistent.
|
||||
|
||||
That being said, in [Phase 2](https://github.com/spotify/backstage#project-roadmap) of the project we will add features that are needed for developer portals and systems for managing software ecosystems. Our ambition will be to keep Backstage modular.
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ Check out [https://backstage.io]() or see the table of content below.
|
||||
- [Architecture and terminology](architecture-terminology.md)
|
||||
- [Getting started](getting-started/README.md)
|
||||
- [References](reference/README.md)
|
||||
- [Publishing](publishing.md)
|
||||
|
||||
@@ -40,7 +40,7 @@ The app will call the `createPlugin` method on each plugin, passing in a `router
|
||||
of methods on it.
|
||||
|
||||
```typescript
|
||||
import { createPlugin } from '@spotify-backstage/core';
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export default createPlugin({
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Here is a collection of tutorials that will guide you through setting up and extending an instance of Backstage with your own plugins.
|
||||
|
||||
- [Development Environment](development-environment.md)
|
||||
- [Create a Backstage plugin](create-a-plugin.md)
|
||||
- [Structure of a plugin](structure-of-a-plugin.md)
|
||||
- Using Backstage components (TODO)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Development Environment
|
||||
|
||||
Open a terminal window and start the web app using the following commands from the project root:
|
||||
|
||||
```bash
|
||||
$ yarn install # may take a while
|
||||
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
|
||||
|
||||
By default, backstage will start on port 3000, however you can override this by setting an environment variable `PORT` on your local machine. e.g. `export PORT=8080` then running `yarn start`. Or `PORT=8080 yarn start`.
|
||||
|
||||
Once successfully started, you should see the following message in your terminal window:
|
||||
|
||||
```
|
||||
You can now view example-app in the browser.
|
||||
|
||||
Local: http://localhost:8080
|
||||
On Your Network: http://192.168.1.224:8080
|
||||
```
|
||||
|
||||
### (Optional)Try on Docker
|
||||
|
||||
Run the following commands if you have Docker environment
|
||||
|
||||
```bash
|
||||
$ docker build . -t spotify/backstage
|
||||
$ docker run --rm -it -p 80:80 spotify/backstage
|
||||
```
|
||||
|
||||
Then open http://localhost/ on your browser.
|
||||
|
||||
[Back to Docs](README.md)
|
||||
@@ -43,7 +43,7 @@ In the root folder you have some configuration for typescript and jest, the test
|
||||
In the `src` folder we get to the interesting bits. Check out the `plugin.ts`:
|
||||
|
||||
```jsx
|
||||
import { createPlugin } from '@spotify-backstage/core';
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export default createPlugin({
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Publishing
|
||||
|
||||
## NPM
|
||||
|
||||
NPM packages are published through CI/CD in the
|
||||
[.github/workflows/master.yml](../.github/workflows/master.yml) workflow. Every
|
||||
commit that is merged to master will be checked for new versions of all public
|
||||
packages, and any new versions will automatically be published to NPM.
|
||||
|
||||
### Creating a new release
|
||||
|
||||
Version bumps are made through release PRs. To create a new release, checkout out
|
||||
a new branch that you will use for the release, e.g.
|
||||
|
||||
```sh
|
||||
$ git checkout -b new-release
|
||||
```
|
||||
|
||||
Then, from the root of the repo, run
|
||||
|
||||
```sh
|
||||
$ yarn release
|
||||
```
|
||||
|
||||
This will bring up the lerna release CLI where you choose what type of version bump
|
||||
you want to make, (major/minor/patch/prerelease). The CLI will take you through choosing
|
||||
a version, previewing all changes, and then approving the release. Once the release
|
||||
is approved, a new commit is created that you can submit as a PR. Push the branch to GitHub:
|
||||
|
||||
```sh
|
||||
$ git push origin -u new-release
|
||||
```
|
||||
|
||||
And then create a PR. Once the PR is approved and merged into master, the master build
|
||||
will publish new versions of all bumped packages.
|
||||
|
||||
[Back to Docs](README.md)
|
||||
@@ -4,5 +4,6 @@ APIs and Components
|
||||
|
||||
- [createPlugin](createPlugin.md)
|
||||
- [createPlugin - router](createPlugin-router.md)
|
||||
- [createPlugin - feature flags](createPlugin-feature-flags.md)
|
||||
|
||||
[Back to Docs](../README.md)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# createPlugin - feature flags
|
||||
|
||||
The `featureFlags` object passed to the `register` function makes it possible for plugins to register Feature Flags in Backstage for users to opt into. You can use this to split out logic in your code for manual A/B testing, etc.
|
||||
|
||||
```typescript
|
||||
export type FeatureFlagsHooks = {
|
||||
register(name: FeatureFlagName): void;
|
||||
};
|
||||
```
|
||||
|
||||
Here's a code sample:
|
||||
|
||||
```typescript
|
||||
import { createPlugin } from '@backstage/core';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'welcome',
|
||||
register({ router, featureFlags }) {
|
||||
// router.registerRoute('/', Component);
|
||||
featureFlags.register('enable-example-feature');
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using with useApi
|
||||
|
||||
To use it, you'll first need to register the `FeatureFlags` API via `ApiRegistry` in your `apis.ts` in your App:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
ApiHolder,
|
||||
ApiRegistry,
|
||||
featureFlagsApiRef,
|
||||
FeatureFlags,
|
||||
} from '@backstage/core';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
builder.add(featureFlagsApiRef, FeatureFlags);
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
```
|
||||
|
||||
Then, later, you can directly use it via `useApi`:
|
||||
|
||||
```tsx
|
||||
import React, { FC } from 'react';
|
||||
import { Button } from '@material-ui/core';
|
||||
import { featureFlagsApiRef, useApi } from '@backstage/core';
|
||||
|
||||
const ExampleButton: FC<{}> = () => {
|
||||
const flags = useApi(featureFlagsApiRef).getFlags();
|
||||
|
||||
const handleClick = () => {
|
||||
flags.set('enable-example-feature', FeatureFlagState.On);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="contained" color="primary" onClick={handleClick}>
|
||||
Enable the 'enable-example-feature' feature flag
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
[Back to References](README.md)
|
||||
@@ -17,21 +17,24 @@ type PluginHooks = {
|
||||
};
|
||||
```
|
||||
|
||||
[Read more about the router here](createPlugin-router.md)
|
||||
- [Read more about the router here](createPlugin-router.md)
|
||||
- [Read more about feature flags here](createPlugin-feature-flags.md)
|
||||
|
||||
## Example Uses
|
||||
|
||||
### Creating a basic plugin
|
||||
|
||||
Showcasing adding multiple routes and a redirect.
|
||||
Showcasing adding multiple routes, a feature flag and a redirect.
|
||||
|
||||
```jsx
|
||||
import { createPlugin } from '@spotify-backstage/core';
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export default createPlugin({
|
||||
id: 'new-plugin',
|
||||
register({ router }) {
|
||||
register({ router, featureFlags }) {
|
||||
featureFlags.register('enable-example-component');
|
||||
|
||||
router.registerRoute('/new-plugin', ExampleComponent);
|
||||
},
|
||||
});
|
||||
|
||||
+5
-2
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"packages": ["packages/*", "plugins/*"],
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"plugins/*"
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.1.0"
|
||||
"version": "0.1.1-alpha.0"
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"version": 2,
|
||||
"builds": [
|
||||
{ "src": "./packages/app/build/**", "use": "@now/static" }
|
||||
],
|
||||
"routes": [
|
||||
{ "src": "/(.*)", "dest": "packages/app/build/$1" }
|
||||
]
|
||||
}
|
||||
+21
-3
@@ -1,12 +1,18 @@
|
||||
{
|
||||
"name": "root",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn build && yarn workspace @spotify-backstage/app start",
|
||||
"start": "yarn build && yarn workspace example-app start",
|
||||
"bundle": "yarn build && yarn workspace example-app bundle",
|
||||
"build": "lerna run build",
|
||||
"test": "cross-env CI=true lerna run test -- --coverage",
|
||||
"test": "cross-env CI=true lerna run test --since origin/master -- --coverage",
|
||||
"create-plugin": "backstage-cli create-plugin",
|
||||
"lint": "lerna run lint"
|
||||
"release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi",
|
||||
"lint": "cross-env CI=true lerna run lint --since origin/master --",
|
||||
"storybook": "yarn workspace storybook start"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -18,7 +24,9 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.0",
|
||||
"eslint-plugin-notice": "^0.8.9",
|
||||
"husky": "^4.2.3",
|
||||
"lerna": "^3.20.2",
|
||||
"lint-staged": "^10.1.0",
|
||||
"prettier": "^1.19.1",
|
||||
"typescript": "^3.7.5",
|
||||
"zombie": "^6.1.4"
|
||||
@@ -26,5 +34,15 @@
|
||||
"dependencies": {
|
||||
"@types/classnames": "^2.2.9",
|
||||
"moment": "^2.24.0"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"prettier --config ./prettier.config.js --list-different --write"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts?(x)'],
|
||||
rules: {
|
||||
'react/prop-types': 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
# example-app
|
||||
|
||||
This package is an example of a Backstage application.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"fixturesFolder": false,
|
||||
"pluginsFile": false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"plugins": ["cypress"],
|
||||
"extends": ["plugin:cypress/recommended"],
|
||||
"rules": {
|
||||
"jest/expect-expect": [
|
||||
"error",
|
||||
{
|
||||
"assertFunctionNames": ["expect", "cy.contains"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
describe('App', () => {
|
||||
it('should render the welcome page', () => {
|
||||
cy.visit('/');
|
||||
cy.contains('Welcome to Backstage');
|
||||
cy.contains('Getting Started');
|
||||
cy.contains('Quick Links');
|
||||
cy.contains('APIs');
|
||||
});
|
||||
|
||||
it('should display support info when clicking the button', () => {
|
||||
cy.visit('/');
|
||||
cy.findByTestId('support-button').click({ force: true });
|
||||
cy.contains('#backstage');
|
||||
});
|
||||
|
||||
it('should display error message when triggering it', () => {
|
||||
cy.visit('/');
|
||||
cy.findByTestId('error-button').click({ force: true });
|
||||
cy.contains('Error: Oh no!');
|
||||
cy.findByTestId('error-button-close').click({ force: true });
|
||||
});
|
||||
|
||||
it('should be able to login and logout', () => {
|
||||
const name = 'test-name';
|
||||
Cypress.on('window:before:load', win => {
|
||||
win.fetch = cy.stub().resolves({
|
||||
status: 200,
|
||||
json: () => ({ username: 'test name', token: 'token', name }),
|
||||
});
|
||||
});
|
||||
|
||||
cy.visit('/');
|
||||
cy.get('a[href="/login"]').click({ force: true });
|
||||
cy.url().should('include', '/login');
|
||||
cy.contains('Welcome, guest!');
|
||||
cy.contains('Username')
|
||||
.get('input[name=github-username-tf]')
|
||||
.type(name, { force: true });
|
||||
cy.contains('Token')
|
||||
.get('input[name=github-auth-tf]')
|
||||
.type('password', { force: true });
|
||||
cy.findByTestId('github-auth-button').click({ force: true });
|
||||
cy.contains(`Welcome, ${name}!`);
|
||||
cy.contains('Logout').click({ force: true });
|
||||
cy.contains('Welcome, guest!');
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
import '@testing-library/cypress/add-commands';
|
||||
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "@spotify-backstage/app",
|
||||
"version": "0.0.0",
|
||||
"name": "example-app",
|
||||
"version": "0.1.1-alpha.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.0",
|
||||
"@backstage/core": "^0.1.1-alpha.0",
|
||||
"@backstage/plugin-home-page": "^0.1.1-alpha.0",
|
||||
"@backstage/plugin-welcome": "^0.1.1-alpha.0",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@spotify-backstage/cli": "^0.1.0",
|
||||
"@spotify-backstage/core": "^0.1.0",
|
||||
"@spotify-backstage/plugin-home-page": "^0.1.0",
|
||||
"@spotify-backstage/plugin-welcome": "^0.1.0",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
@@ -25,9 +26,13 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "backstage-cli app:serve",
|
||||
"build": "backstage-cli app:build",
|
||||
"bundle": "backstage-cli app:build",
|
||||
"test": "backstage-cli test",
|
||||
"lint": "backstage-cli lint"
|
||||
"test:e2e": "start-server-and-test start http://localhost:3000 cy:dev",
|
||||
"test:e2e:ci": "start-server-and-test start http://localhost:3000 cy:run",
|
||||
"lint": "backstage-cli lint",
|
||||
"cy:dev": "cypress open",
|
||||
"cy:run": "cypress run"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -41,5 +46,12 @@
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"license": "Apache-2.0"
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@testing-library/cypress": "^6.0.0",
|
||||
"@types/jquery": "^3.3.34",
|
||||
"cypress": "^4.2.0",
|
||||
"eslint-plugin-cypress": "^2.10.3",
|
||||
"start-server-and-test": "^1.10.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
*/
|
||||
|
||||
import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
|
||||
import { BackstageTheme, createApp } from '@spotify-backstage/core';
|
||||
import { BackstageTheme, createApp } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import Root from './components/Root';
|
||||
import ErrorDisplay from './components/ErrorDisplay';
|
||||
import * as plugins from './plugins';
|
||||
import apis, { errorDialogForwarder } from './apis';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
'@global': {
|
||||
@@ -40,6 +42,7 @@ const useStyles = makeStyles(theme => ({
|
||||
}));
|
||||
|
||||
const app = createApp();
|
||||
app.registerApis(apis);
|
||||
app.registerPlugin(...Object.values(plugins));
|
||||
const AppComponent = app.build();
|
||||
|
||||
@@ -48,6 +51,7 @@ const App: FC<{}> = () => {
|
||||
return (
|
||||
<CssBaseline>
|
||||
<ThemeProvider theme={BackstageTheme}>
|
||||
<ErrorDisplay forwarder={errorDialogForwarder} />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
|
||||
@@ -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 {
|
||||
ApiHolder,
|
||||
ApiRegistry,
|
||||
errorApiRef,
|
||||
featureFlagsApiRef,
|
||||
FeatureFlags,
|
||||
} from '@backstage/core';
|
||||
import { ErrorDisplayForwarder } from './components/ErrorDisplay/ErrorDisplay';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
export const errorDialogForwarder = new ErrorDisplayForwarder();
|
||||
builder.add(errorApiRef, errorDialogForwarder);
|
||||
|
||||
builder.add(featureFlagsApiRef, new FeatureFlags());
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
@@ -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, { FC, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Snackbar, IconButton } from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import { ErrorApi, ErrorContext } from '@backstage/core';
|
||||
|
||||
type SubscriberFunc = (error: Error) => void;
|
||||
type Unsubscribe = () => void;
|
||||
|
||||
// TODO: figure out where to put implementations of APIs, both inside apps
|
||||
// but also in core/separate package.
|
||||
export class ErrorDisplayForwarder implements ErrorApi {
|
||||
private readonly subscribers = new Set<SubscriberFunc>();
|
||||
|
||||
post(error: Error, context?: ErrorContext) {
|
||||
if (context?.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscribers.forEach(subscriber => subscriber(error));
|
||||
}
|
||||
|
||||
subscribe(func: SubscriberFunc): Unsubscribe {
|
||||
this.subscribers.add(func);
|
||||
|
||||
return () => {
|
||||
this.subscribers.delete(func);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
forwarder: ErrorDisplayForwarder;
|
||||
};
|
||||
|
||||
// TODO: improve on this and promote to a shared component for use by all apps.
|
||||
const ErrorDisplay: FC<Props> = ({ forwarder }) => {
|
||||
const [errors, setErrors] = useState<Array<Error>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
return forwarder.subscribe(error => setErrors(errs => errs.concat(error)));
|
||||
}, [forwarder]);
|
||||
|
||||
if (errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [firstError] = errors;
|
||||
|
||||
const handleClose = () => {
|
||||
setErrors(errs => errs.filter(err => err !== firstError));
|
||||
};
|
||||
|
||||
return (
|
||||
<Snackbar
|
||||
open
|
||||
message={firstError.toString()}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
action={
|
||||
<IconButton
|
||||
color="inherit"
|
||||
size="small"
|
||||
onClick={handleClose}
|
||||
data-testid="error-button-close"
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
}
|
||||
severity="error"
|
||||
>
|
||||
{firstError.toString()}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
);
|
||||
};
|
||||
|
||||
ErrorDisplay.propTypes = {
|
||||
forwarder: PropTypes.instanceOf(ErrorDisplayForwarder).isRequired,
|
||||
};
|
||||
|
||||
export default ErrorDisplay;
|
||||
@@ -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 { default } from './ErrorDisplay';
|
||||
@@ -18,6 +18,7 @@ import React, { FC, useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link, makeStyles, Typography } from '@material-ui/core';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import AccountCircle from '@material-ui/icons/AccountCircle';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarPage,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
SidebarSpacer,
|
||||
SidebarDivider,
|
||||
SidebarSpace,
|
||||
} from '@spotify-backstage/core';
|
||||
} from '@backstage/core';
|
||||
|
||||
const useSidebarLogoStyles = makeStyles({
|
||||
root: {
|
||||
@@ -77,6 +78,7 @@ const Root: FC<{}> = ({ children }) => (
|
||||
<SidebarSpacer />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={HomeIcon} to="/" text="Home" />
|
||||
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
|
||||
<SidebarDivider />
|
||||
<SidebarSpace />
|
||||
</Sidebar>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
/* eslint-disable notice/notice */
|
||||
export { default as HomePagePlugin } from '@spotify-backstage/plugin-home-page';
|
||||
export { default as WelcomePlugin } from '@spotify-backstage/plugin-welcome';
|
||||
export { default as HomePagePlugin } from '@backstage/plugin-home-page';
|
||||
export { default as WelcomePlugin } from '@backstage/plugin-welcome';
|
||||
|
||||
Vendored
-2
@@ -13,5 +13,3 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# @backstage/cli
|
||||
|
||||
This package provides a CLI for developing Backstage plugins and apps.
|
||||
|
||||
## Installation
|
||||
|
||||
Install the package via npm or yarn:
|
||||
|
||||
```sh
|
||||
$ npm install --save @backstage/cli
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```sh
|
||||
$ yarn add @backstage/cli
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
|
||||
@@ -39,4 +39,13 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['**/dist/**', '**/build/**'],
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts?(x)'],
|
||||
rules: {
|
||||
// Default to not enforcing prop-types in typescript
|
||||
'react/prop-types': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
{
|
||||
"name": "@spotify-backstage/cli",
|
||||
"version": "0.1.0",
|
||||
"main": "dist",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@backstage/cli",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spotify/backstage",
|
||||
"directory": "packages/cli"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "dist",
|
||||
"scripts": {
|
||||
"exec": "npx ts-node ./src",
|
||||
"build": "tsc --outDir dist --noEmit false --module CommonJS",
|
||||
"build": "backstage-cli build-cache -- tsc --outDir dist --noEmit false --module CommonJS",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"start": "nodemon ."
|
||||
@@ -19,10 +32,12 @@
|
||||
"@types/ora": "^3.2.0",
|
||||
"@types/react-dev-utils": "^9.0.4",
|
||||
"@types/recursive-readdir": "^2.2.0",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/webpack": "^4.41.7",
|
||||
"@types/webpack-dev-server": "^3.10.0",
|
||||
"del": "^5.1.0",
|
||||
"nodemon": "^2.0.2",
|
||||
"tar": "^6.0.1",
|
||||
"ts-node": "^8.6.2"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import tar from 'tar';
|
||||
import { dirname } from 'path';
|
||||
|
||||
// packages all files in inputDir into an archive at archivePath, deleting any existing archive
|
||||
export async function createArchive(
|
||||
archivePath: string,
|
||||
inputDir: string,
|
||||
): Promise<void> {
|
||||
await fs.remove(archivePath);
|
||||
await fs.ensureDir(dirname(archivePath));
|
||||
await tar.create({ gzip: true, file: archivePath, cwd: inputDir }, ['.']);
|
||||
}
|
||||
|
||||
// extracts archive at archive path into outputDir, deleting any existing files at outputDir
|
||||
export async function extractArchive(
|
||||
archivePath: string,
|
||||
outputDir: string,
|
||||
): Promise<void> {
|
||||
await fs.remove(outputDir);
|
||||
await fs.ensureDir(outputDir);
|
||||
await tar.extract({ file: archivePath, cwd: outputDir });
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath, relative as relativePath } from 'path';
|
||||
import { runPlain, runCheck } from '../../helpers/run';
|
||||
import { Options } from './options';
|
||||
import { extractArchive, createArchive } from './archive';
|
||||
|
||||
const INFO_FILE = '.backstage-build-cache';
|
||||
|
||||
// Result from a cache query
|
||||
export type CacheQueryResult = {
|
||||
// True if there was a cache hit
|
||||
hit: boolean;
|
||||
// If there is a cache hit and this method is defined, it needs to be called to restore the
|
||||
// output contents before continuing.
|
||||
copy?: (outputDir: string) => Promise<void>;
|
||||
// Call after a successful build to archive the output content.
|
||||
// The content will be archived using the same key as was used to query the cache.
|
||||
archive: (outputDir: string, maxEntries: number) => Promise<void>;
|
||||
};
|
||||
|
||||
// Key that determines whether cached output can be reused
|
||||
export type CacheKey = string[];
|
||||
|
||||
type CacheEntry = {
|
||||
// Key for the input of this cache entry
|
||||
key: CacheKey;
|
||||
// Path to the archive of this cache entry
|
||||
path: string;
|
||||
};
|
||||
|
||||
// Struct containing information about cache entries on the filesystem.
|
||||
// Stored inside the INFO_FILE as JSON.
|
||||
type CacheInfo = {
|
||||
// Optional key entry for the contents of the current directory. Used to key the
|
||||
// output present in the outputs folder, where the info file resides inside the output folder.
|
||||
key?: CacheKey;
|
||||
// Optional list of cache archives present in the same directory. Resides in the external
|
||||
// cache location inside one info file for each package.
|
||||
entries?: CacheEntry[];
|
||||
};
|
||||
|
||||
export class Cache {
|
||||
// Read the current cache state form the filesystem.
|
||||
static async read(options: Options) {
|
||||
const repoPath = relativePath(options.repoRoot, process.cwd());
|
||||
const location = resolvePath(options.cacheDir, repoPath);
|
||||
|
||||
const outputInfo = await readCacheInfo(options.output);
|
||||
const localKey = outputInfo?.key;
|
||||
|
||||
const { entries = [] } = (await readCacheInfo(location)) ?? {};
|
||||
return new Cache(location, entries, localKey);
|
||||
}
|
||||
|
||||
// Generates a key based on the contents of the input paths.
|
||||
// Returns undefined if it's not possible to generate a stable key.
|
||||
static async readInputKey(
|
||||
inputPaths: string[],
|
||||
): Promise<CacheKey | undefined> {
|
||||
const quotedInputPaths = inputPaths.map(input => `'${input}'`);
|
||||
|
||||
// Make sure we don't have any uncommitted changes to the input, in that case we skip caching.
|
||||
const noChanges = await runCheck(
|
||||
`git diff --quiet HEAD -- ${quotedInputPaths.join(' ')}`,
|
||||
);
|
||||
if (!noChanges) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trees = [];
|
||||
for (const quotedInputPath of quotedInputPaths) {
|
||||
const output = await runPlain(`git ls-tree HEAD ${quotedInputPath}`);
|
||||
const [, , sha] = output.split(/\s+/, 3);
|
||||
trees.push(sha);
|
||||
}
|
||||
return trees;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly location: string,
|
||||
private readonly entries: CacheEntry[] = [],
|
||||
private readonly localKey?: CacheKey,
|
||||
) {}
|
||||
|
||||
// Query for the presense of cached output for a given key
|
||||
query(key: CacheKey): CacheQueryResult {
|
||||
const { location } = this;
|
||||
|
||||
const archive = async (outputDir: string, maxEntries: number) => {
|
||||
await writeCacheInfo(outputDir, { key });
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/-|:|\..*/g, '');
|
||||
const rand = Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 6);
|
||||
const archiveName = `cache-${timestamp}-${rand}.tgz`;
|
||||
const archivePath = resolvePath(location, archiveName);
|
||||
|
||||
// Read existing entries and prepend the new one
|
||||
const { entries = [] } = (await readCacheInfo(location)) ?? {};
|
||||
|
||||
// Check if there's already aan entry for this key, in that case we just wanna bump it
|
||||
const entryIndex = entries.findIndex(e => compareKeys(e.key, key));
|
||||
if (entryIndex !== -1) {
|
||||
const [existingEntry] = entries.splice(entryIndex, 1);
|
||||
entries.unshift(existingEntry);
|
||||
|
||||
await writeCacheInfo(location, { entries });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create and add new archive to entries
|
||||
await createArchive(archivePath, outputDir);
|
||||
entries.unshift({ key, path: archiveName });
|
||||
|
||||
// Remove old cache entries
|
||||
const removedEntries = entries.splice(maxEntries);
|
||||
for (const entry of removedEntries) {
|
||||
try {
|
||||
await fs.remove(resolvePath(location, entry.path));
|
||||
} catch (error) {
|
||||
process.stderr.write(`failed to remove old cache entry, ${error}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
await writeCacheInfo(location, { entries });
|
||||
};
|
||||
|
||||
if (compareKeys(this.localKey, key)) {
|
||||
return { hit: true, archive };
|
||||
}
|
||||
|
||||
const matchingEntry = this.entries.find(e => compareKeys(e.key, key));
|
||||
if (!matchingEntry) {
|
||||
return { hit: false, archive };
|
||||
}
|
||||
|
||||
return {
|
||||
hit: true,
|
||||
archive,
|
||||
copy: async (outputDir: string) => {
|
||||
const archivePath = resolvePath(location, matchingEntry.path);
|
||||
await extractArchive(archivePath, outputDir);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Compares to cache keys, returning true if they are both defined and equal
|
||||
function compareKeys(a?: CacheKey, b?: CacheKey): boolean {
|
||||
if (!a || !b) {
|
||||
return false;
|
||||
}
|
||||
return a.join(',') === b.join(',');
|
||||
}
|
||||
|
||||
// Read and parse a cache info file in the given directory
|
||||
async function readCacheInfo(
|
||||
parentDir: string,
|
||||
): Promise<CacheInfo | undefined> {
|
||||
const infoFile = resolvePath(parentDir, INFO_FILE);
|
||||
const exists = await fs.pathExists(infoFile);
|
||||
if (!exists) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const infoData = await fs.readFile(infoFile);
|
||||
const cacheInfo = JSON.parse(infoData.toString('utf8')) as CacheInfo;
|
||||
return cacheInfo;
|
||||
}
|
||||
|
||||
// Write a cache info file to the given directory
|
||||
async function writeCacheInfo(
|
||||
parentDir: string,
|
||||
cacheInfo: CacheInfo,
|
||||
): Promise<void> {
|
||||
const infoData = Buffer.from(JSON.stringify(cacheInfo, null, 2), 'utf8');
|
||||
await fs.writeFile(resolvePath(parentDir, INFO_FILE), infoData);
|
||||
}
|
||||
@@ -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 { Command } from 'commander';
|
||||
import { run } from '../../helpers/run';
|
||||
import { Cache } from './cache';
|
||||
import { parseOptions } from './options';
|
||||
|
||||
function print(msg: string) {
|
||||
process.stdout.write(`[build-cache] ${msg}\n`);
|
||||
}
|
||||
|
||||
/*
|
||||
* The build-cache command is used to make builds a no-op if there are no changes to the package.
|
||||
* It supports both local development where the output directory remains intact, as well as CI
|
||||
* where the output directory is stored in a separate cache dir.
|
||||
*/
|
||||
export default async (cmd: Command, args: string[]) => {
|
||||
const options = await parseOptions(cmd);
|
||||
|
||||
const key = await Cache.readInputKey(options.inputs);
|
||||
if (!key) {
|
||||
print('input directory is dirty, skipping cache');
|
||||
await run(args[0], args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = await Cache.read(options);
|
||||
|
||||
const cacheResult = cache.query(key);
|
||||
if (cacheResult.hit) {
|
||||
if (cacheResult.copy) {
|
||||
print('external cache hit, copying archive to output folder');
|
||||
await cacheResult.copy(options.output);
|
||||
} else {
|
||||
print('cache hit, nothing to be done');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
print('cache miss, need to build');
|
||||
|
||||
await run(args[0], args.slice(1));
|
||||
|
||||
print('caching build output');
|
||||
await cacheResult.archive(options.output, options.maxCacheEntries);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { Command } from 'commander';
|
||||
import { runPlain } from '../../helpers/run';
|
||||
|
||||
const DEFAULT_MAX_ENTRIES = 10;
|
||||
|
||||
export type Options = {
|
||||
inputs: string[];
|
||||
output: string;
|
||||
cacheDir: string;
|
||||
maxCacheEntries: number;
|
||||
repoRoot: string;
|
||||
};
|
||||
|
||||
export async function parseOptions(cmd: Command): Promise<Options> {
|
||||
const repoRoot = await runPlain('git rev-parse --show-toplevel');
|
||||
const argTransformer = (arg: string) =>
|
||||
resolvePath(arg.replace(/<repoRoot>/g, repoRoot).replace(/'/g, ''));
|
||||
|
||||
const inputs = cmd.input.map(argTransformer) as string[];
|
||||
if (inputs.length === 0) {
|
||||
inputs.push(argTransformer('.'));
|
||||
}
|
||||
const output = argTransformer(cmd.output);
|
||||
const cacheDir = argTransformer(
|
||||
process.env.BACKSTAGE_CACHE_DIR || cmd.cacheDir,
|
||||
);
|
||||
const maxCacheEntries =
|
||||
Number(process.env.BACKSTAGE_CACHE_MAX_ENTRIES) || DEFAULT_MAX_ENTRIES;
|
||||
return { inputs, output, cacheDir, repoRoot, maxCacheEntries };
|
||||
}
|
||||
+2
-3
@@ -59,9 +59,8 @@ describe('createPlugin', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
|
||||
try {
|
||||
const sourceData =
|
||||
'{"name": "@spotify-backstage/{{id}}", "version": "{{version}}"}';
|
||||
const targetData =
|
||||
'{"name": "@spotify-backstage/foo", "version": "0.0.0"}';
|
||||
'{"name": "@backstage/{{id}}", "version": "{{version}}"}';
|
||||
const targetData = '{"name": "@backstage/foo", "version": "0.0.0"}';
|
||||
const sourcePath = path.join(tempDir, 'in.hbs');
|
||||
const targetPath = path.join(tempDir, 'out.json');
|
||||
fs.writeFileSync(sourcePath, sourceData);
|
||||
+46
-5
@@ -25,6 +25,11 @@ import { resolve as resolvePath } from 'path';
|
||||
import { realpathSync, existsSync } from 'fs';
|
||||
import os from 'os';
|
||||
import ora from 'ora';
|
||||
import {
|
||||
parseOwnerIds,
|
||||
addCodeownersEntry,
|
||||
getCodeownersFilePath,
|
||||
} from './lib/codeowners';
|
||||
|
||||
const MARKER_SUCCESS = chalk.green(` ✔︎\n`);
|
||||
const MARKER_FAILURE = chalk.red(` ✘\n`);
|
||||
@@ -125,7 +130,7 @@ export const addPluginDependencyToApp = (
|
||||
console.log();
|
||||
console.log(chalk.green(' Adding plugin as dependency in app:'));
|
||||
|
||||
const pluginPackage = `@spotify-backstage/plugin-${pluginName}`;
|
||||
const pluginPackage = `@backstage/plugin-${pluginName}`;
|
||||
const packageFile = path.join(rootDir, 'packages', 'app', 'package.json');
|
||||
|
||||
process.stdout.write(
|
||||
@@ -166,7 +171,7 @@ export const addPluginToApp = (rootDir: string, pluginName: string) => {
|
||||
console.log();
|
||||
console.log(chalk.green(' Import plugin in app:'));
|
||||
|
||||
const pluginPackage = `@spotify-backstage/plugin-${pluginName}`;
|
||||
const pluginPackage = `@backstage/plugin-${pluginName}`;
|
||||
const pluginNameCapitalized = pluginName
|
||||
.split('-')
|
||||
.map(name => capitalize(name))
|
||||
@@ -327,6 +332,9 @@ export const movePlugin = (
|
||||
};
|
||||
|
||||
const createPlugin = async () => {
|
||||
const rootDir = realpathSync(process.cwd());
|
||||
const codeownersPath = await getCodeownersFilePath(rootDir);
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
type: 'input',
|
||||
@@ -344,15 +352,40 @@ const createPlugin = async () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (codeownersPath) {
|
||||
questions.push({
|
||||
type: 'input',
|
||||
name: 'owner',
|
||||
message: chalk.blue(
|
||||
'Enter the owner(s) of the plugin. If specified, this will be added to CODEOWNERS for the plugin path. [optional]',
|
||||
),
|
||||
validate: (value: any) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const ownerIds = parseOwnerIds(value);
|
||||
if (!ownerIds) {
|
||||
return chalk.red(
|
||||
'The owner must be a space separated list of team names (e.g. @org/team-name), usernames (e.g. @username), or the email addresses of users (e.g. user@example.com).',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const answers: Answers = await inquirer.prompt(questions);
|
||||
|
||||
const rootDir = realpathSync(process.cwd());
|
||||
const appPackage = resolvePath(rootDir, 'packages', 'app');
|
||||
const cliPackage = resolvePath(__dirname, '..', '..');
|
||||
const cliPackage = resolvePath(__dirname, '..', '..', '..');
|
||||
const templateFolder = resolvePath(cliPackage, 'templates', 'default-plugin');
|
||||
const tempDir = path.join(os.tmpdir(), answers.id);
|
||||
const pluginDir = path.join(rootDir, 'plugins', answers.id);
|
||||
const version = require(resolvePath(cliPackage, 'package.json')).version;
|
||||
const ownerIds = parseOwnerIds(answers.owner);
|
||||
|
||||
console.log();
|
||||
console.log(chalk.green('Creating the plugin...'));
|
||||
@@ -369,11 +402,19 @@ const createPlugin = async () => {
|
||||
addPluginToApp(rootDir, answers.id);
|
||||
}
|
||||
|
||||
if (ownerIds && ownerIds.length) {
|
||||
await addCodeownersEntry(
|
||||
codeownersPath!,
|
||||
`/plugins/${answers.id}`,
|
||||
ownerIds,
|
||||
);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.green(
|
||||
`🥇 Successfully created ${chalk.cyan(
|
||||
`@spotify-backstage/plugin-${answers.id}`,
|
||||
`@backstage/plugin-${answers.id}`,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
@@ -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 { isValidSingleOwnerId, parseOwnerIds } from './codeowners';
|
||||
|
||||
describe('codeowners', () => {
|
||||
it('isValidSingleOwnerId', () => {
|
||||
[
|
||||
'@foo',
|
||||
'@a-b',
|
||||
'@org-a/team-a',
|
||||
'@a/b',
|
||||
'adam_driver+spam@deathstar.com',
|
||||
].forEach(id => {
|
||||
expect(isValidSingleOwnerId(id)).toBeTruthy();
|
||||
});
|
||||
|
||||
[
|
||||
'',
|
||||
'@',
|
||||
'@/team-a',
|
||||
'@orsdsd/',
|
||||
'adam_driver@deathstar',
|
||||
'something',
|
||||
].forEach(id => {
|
||||
expect(isValidSingleOwnerId(id)).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it('parseOwnerIds', () => {
|
||||
expect(parseOwnerIds('')).toBeUndefined();
|
||||
expect(parseOwnerIds('@foo')).toEqual(['@foo']);
|
||||
expect(parseOwnerIds(' @foo @bar/baz ')).toEqual(['@foo', '@bar/baz']);
|
||||
expect(parseOwnerIds(' @foo @bar/ ')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
const TEAM_ID_RE = /^@[-\w]+\/[-\w]+$/;
|
||||
const USER_ID_RE = /^@[-\w]+$/;
|
||||
const EMAIL_RE = /^[^@]+@[-.\w]+\.[-\w]+$/i;
|
||||
const DEFAULT_OWNER = '@spotify/backstage-core';
|
||||
|
||||
type CodeownersEntry = {
|
||||
ownedPath: string;
|
||||
ownerIds: string[];
|
||||
};
|
||||
|
||||
export async function getCodeownersFilePath(
|
||||
rootDir: string,
|
||||
): Promise<string | undefined> {
|
||||
const paths = [
|
||||
path.join(rootDir, '.github', 'CODEOWNERS'),
|
||||
path.join(rootDir, '.gitlab', 'CODEOWNERS'),
|
||||
path.join(rootDir, 'docs', 'CODEOWNERS'),
|
||||
path.join(rootDir, 'CODEOWNERS'),
|
||||
];
|
||||
|
||||
for (const p of paths) {
|
||||
if (await fs.pathExists(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isValidSingleOwnerId(id: string): boolean {
|
||||
if (!id || typeof id !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return TEAM_ID_RE.test(id) || USER_ID_RE.test(id) || EMAIL_RE.test(id);
|
||||
}
|
||||
|
||||
export function parseOwnerIds(
|
||||
spaceSeparatedOwnerIds: string,
|
||||
): string[] | undefined {
|
||||
if (!spaceSeparatedOwnerIds || typeof spaceSeparatedOwnerIds !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ids = spaceSeparatedOwnerIds.split(' ').filter(Boolean);
|
||||
if (!ids.every(isValidSingleOwnerId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function addCodeownersEntry(
|
||||
codeownersFilePath: string,
|
||||
ownedPath: string,
|
||||
ownerIds: string[],
|
||||
): Promise<void> {
|
||||
const allLines = (await fs.readFile(codeownersFilePath, 'utf8')).split('\n');
|
||||
|
||||
// Only keep comments from the top of the file
|
||||
const commentLines = [];
|
||||
for (const line of allLines) {
|
||||
if (line[0] !== '#') {
|
||||
break;
|
||||
}
|
||||
commentLines.push(line);
|
||||
}
|
||||
|
||||
const oldDeclarationEntries: CodeownersEntry[] = allLines
|
||||
.filter(line => line[0] !== '#')
|
||||
.map(line => line.split(/\s+/).filter(Boolean))
|
||||
.filter(tokens => tokens.length >= 2)
|
||||
.map(tokens => ({
|
||||
ownedPath: tokens[0],
|
||||
ownerIds: tokens.slice(1),
|
||||
}));
|
||||
|
||||
const newDeclarationEntries = oldDeclarationEntries
|
||||
.filter(entry => entry.ownedPath !== '*')
|
||||
.concat([{ ownedPath, ownerIds }])
|
||||
.sort((l1, l2) => l1.ownedPath.localeCompare(l2.ownedPath));
|
||||
newDeclarationEntries.unshift({
|
||||
ownedPath: '*',
|
||||
ownerIds: [DEFAULT_OWNER],
|
||||
});
|
||||
|
||||
// Calculate longest path to be able to align entries nicely
|
||||
const longestOwnedPath = newDeclarationEntries.reduce(
|
||||
(length, entry) => Math.max(length, entry.ownedPath.length),
|
||||
0,
|
||||
);
|
||||
|
||||
const newDeclarationLines = newDeclarationEntries.map(entry => {
|
||||
const entryPath =
|
||||
entry.ownedPath + ' '.repeat(longestOwnedPath - entry.ownedPath.length);
|
||||
return [entryPath, ...entry.ownerIds].join(' ');
|
||||
});
|
||||
|
||||
const newLines = [...commentLines, '', ...newDeclarationLines, ''];
|
||||
|
||||
await fs.writeFile(codeownersFilePath, newLines.join('\n'), 'utf8');
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { waitForExit } from '../../helpers/run';
|
||||
|
||||
const PACKAGE_BLACKLIST = [
|
||||
// We never want to watch for changes in the cli, but all packages will depend on it.
|
||||
'@spotify-backstage/cli',
|
||||
'@backstage/cli',
|
||||
];
|
||||
|
||||
const WATCH_LOCATIONS = ['package.json', 'src', 'assets'];
|
||||
|
||||
@@ -14,8 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SpawnOptions, spawn, ChildProcess } from 'child_process';
|
||||
import {
|
||||
SpawnOptions,
|
||||
spawn,
|
||||
ChildProcess,
|
||||
exec as execCb,
|
||||
} from 'child_process';
|
||||
import { ExitCodeError } from './errors';
|
||||
import { promisify } from 'util';
|
||||
const exec = promisify(execCb);
|
||||
|
||||
type SpawnOptionsPartialEnv = Omit<SpawnOptions, 'env'> & {
|
||||
env?: Partial<NodeJS.ProcessEnv>;
|
||||
@@ -43,6 +50,27 @@ export async function run(
|
||||
await waitForExit(child, name);
|
||||
}
|
||||
|
||||
export async function runPlain(cmd: string) {
|
||||
try {
|
||||
const { stdout } = await exec(cmd);
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
if (error.stderr) {
|
||||
process.stderr.write(error.stderr);
|
||||
}
|
||||
throw new ExitCodeError(error.code, cmd);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCheck(cmd: string): Promise<boolean> {
|
||||
try {
|
||||
await exec(cmd);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForExit(
|
||||
child: ChildProcess & { exitCode?: number },
|
||||
name?: string,
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
import program from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import createPluginCommand from './commands/createPlugin';
|
||||
import createPluginCommand from './commands/create-plugin/createPlugin';
|
||||
import watch from './commands/watch-deps';
|
||||
import buildCache from './commands/build-cache';
|
||||
import lintCommand from './commands/lint';
|
||||
import testCommand from './commands/testCommand';
|
||||
import appBuild from './commands/app/build';
|
||||
@@ -76,6 +77,23 @@ const main = (argv: string[]) => {
|
||||
.description('Watch all dependencies while running another command')
|
||||
.action(actionHandler(watch));
|
||||
|
||||
program
|
||||
.command('build-cache')
|
||||
.description('Wrap build command with a cache')
|
||||
.option(
|
||||
'--input <dirs>',
|
||||
'List of input directories that invalidate the cache [.]',
|
||||
(value, acc) => acc.concat(value),
|
||||
[],
|
||||
)
|
||||
.option('--output <dir>', 'Output directory to cache', 'dist')
|
||||
.option(
|
||||
'--cache-dir <dir>',
|
||||
'Cache dir',
|
||||
'<repoRoot>/node_modules/.cache/backstage-builds',
|
||||
)
|
||||
.action(actionHandler(buildCache));
|
||||
|
||||
program.on('command:*', () => {
|
||||
console.log();
|
||||
console.log(
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{
|
||||
"name": "@spotify-backstage/plugin-{{id}}",
|
||||
"name": "@backstage/plugin-{{id}}",
|
||||
"version": "{{version}}",
|
||||
"main": "dist/cjs/index.js",
|
||||
"types": "dist/cjs/index.d.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "backstage-cli plugin:build",
|
||||
"build": "backstage-cli build-cache -- backstage-cli plugin:build",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spotify-backstage/cli": "^{{version}}",
|
||||
"@types/testing-library__jest-dom": "5.0.2"
|
||||
"@backstage/cli": "^{{version}}",
|
||||
"@types/testing-library__jest-dom": "5.0.2",
|
||||
"jest-fetch-mock": "^3.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/core": "^{{version}}",
|
||||
"@material-ui/lab": "4.0.0-alpha.45"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -16,12 +16,14 @@
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import mockFetch from 'jest-fetch-mock';
|
||||
import ExampleComponent from './ExampleComponent';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { BackstageTheme } from '@spotify-backstage/core';
|
||||
import { BackstageTheme } from '@backstage/core';
|
||||
|
||||
describe('ExampleComponent', () => {
|
||||
it('should render', () => {
|
||||
mockFetch.mockResponse(() => new Promise(() => {}));
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={BackstageTheme}>
|
||||
<ExampleComponent />
|
||||
|
||||
+2
-2
@@ -25,7 +25,7 @@ import {
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
SupportButton,
|
||||
} from '@spotify-backstage/core';
|
||||
} from '@backstage/core';
|
||||
import ExampleFetchComponent from '../ExampleFetchComponent';
|
||||
|
||||
const ExampleComponent: FC<{}> = () => (
|
||||
@@ -40,7 +40,7 @@ const ExampleComponent: FC<{}> = () => (
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<InfoCard title="Information card" maxWidth>
|
||||
<InfoCard title="Information card">
|
||||
<Typography variant="body1">
|
||||
All content should be wrapped in a card like this.
|
||||
</Typography>
|
||||
|
||||
+4
-2
@@ -16,11 +16,13 @@
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import mockFetch from 'jest-fetch-mock';
|
||||
import ExampleFetchComponent from './ExampleFetchComponent';
|
||||
|
||||
describe('ExampleFetchComponent', () => {
|
||||
it('should render', () => {
|
||||
it('should render', async () => {
|
||||
mockFetch.mockResponse(() => new Promise(() => {}));
|
||||
const rendered = render(<ExampleFetchComponent />);
|
||||
expect(rendered.getByTestId('progress')).toBeInTheDocument();
|
||||
expect(await rendered.findByTestId('progress')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import TableHead from '@material-ui/core/TableHead';
|
||||
import TableRow from '@material-ui/core/TableRow';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useAsync } from 'react-use';
|
||||
import { Progress } from '@spotify-backstage/core';
|
||||
import { Progress } from '@backstage/core';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createPlugin } from '@spotify-backstage/core';
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export default createPlugin({
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
module.exports = {
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts?(x)'],
|
||||
rules: {
|
||||
// TODO: add prop types and set to 1
|
||||
'react/prop-types': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
// TODO: add prop types to JS and remove
|
||||
'react/prop-types': 0,
|
||||
'jest/expect-expect': 0,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
stories: [
|
||||
'../src/layout/**/*.stories.tsx',
|
||||
'../src/components/**/*.stories.tsx',
|
||||
],
|
||||
addons: ['@storybook/addon-actions', '@storybook/addon-links'],
|
||||
webpackFinal: async config => {
|
||||
config.module.rules.push({
|
||||
test: /\.(ts|tsx)$/,
|
||||
use: [
|
||||
{
|
||||
loader: require.resolve('ts-loader'),
|
||||
options: {
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
config.resolve.extensions.push('.ts', '.tsx');
|
||||
return config;
|
||||
},
|
||||
};
|
||||
@@ -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/spotify/backstage/blob/master/README.md)
|
||||
- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
|
||||
@@ -1,12 +1,25 @@
|
||||
{
|
||||
"name": "@spotify-backstage/core",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"name": "@backstage/core",
|
||||
"description": "Core API used by Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"homepage": "https://backstage.io",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/spotify/backstage",
|
||||
"directory": "packages/core"
|
||||
},
|
||||
"keywords": [
|
||||
"backstage"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"main": "dist/cjs/index.js",
|
||||
"types": "dist/cjs/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc --outDir dist/cjs --noEmit false --module CommonJS",
|
||||
"build": "backstage-cli build-cache -- tsc --outDir dist/cjs --noEmit false --module CommonJS",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test"
|
||||
},
|
||||
@@ -26,7 +39,7 @@
|
||||
"recompose": "0.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spotify-backstage/cli": "^0.1.0",
|
||||
"@backstage/cli": "^0.1.1-alpha.0",
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^7.1.2",
|
||||
|
||||
@@ -25,14 +25,26 @@ describe('ApiRef', () => {
|
||||
expect(() => ref.T).toThrow('tried to read ApiRef.T of apiRef{abc}');
|
||||
});
|
||||
|
||||
it('should require a ascii letters only in id', () => {
|
||||
for (const id of ['a', 'abc', 'ABC', 'aBC', 'aBc']) {
|
||||
it('should reject invalid ids', () => {
|
||||
for (const id of ['a', 'abc', 'a.b.c', 'ab.c', 'abc.abc.abc3']) {
|
||||
expect(new ApiRef({ id, description: '123' }).id).toBe(id);
|
||||
}
|
||||
|
||||
for (const id of ['123', 'ab-c', 'ab_c', 'a2c', '', '_']) {
|
||||
for (const id of [
|
||||
'123',
|
||||
'ab-c',
|
||||
'ab_c',
|
||||
'.',
|
||||
'2ac',
|
||||
'ab.3a',
|
||||
'.abc',
|
||||
'abc.',
|
||||
'ab..s',
|
||||
'',
|
||||
'_',
|
||||
]) {
|
||||
expect(() => new ApiRef({ id, description: '123' }).id).toThrow(
|
||||
`API id must only contain ascii letters, got '${id}'`,
|
||||
`API id must only contain lowercase alphanums separated by dots, got '${id}'`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,9 +21,9 @@ export type ApiRefConfig = {
|
||||
|
||||
export default class ApiRef<T> {
|
||||
constructor(private readonly config: ApiRefConfig) {
|
||||
if (!config.id.match(/^[a-zA-Z]+$/)) {
|
||||
if (!config.id.match(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/)) {
|
||||
throw new Error(
|
||||
`API id must only contain ascii letters, got '${config.id}'`,
|
||||
`API id must only contain lowercase alphanums separated by dots, got '${config.id}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ type ApiImpl<T = unknown> = readonly [ApiRef<T>, T];
|
||||
class ApiRegistryBuilder {
|
||||
private apis: ApiImpl[] = [];
|
||||
|
||||
add<T>(api: ApiRef<T>, impl: T): T {
|
||||
add<T, I extends T>(api: ApiRef<T>, impl: I): I {
|
||||
this.apis.push([api, impl]);
|
||||
return impl;
|
||||
}
|
||||
|
||||
@@ -59,13 +59,24 @@ describe('ApiTestRegistry', () => {
|
||||
|
||||
it('should register factories with dependencies', () => {
|
||||
// 100% coverage + happy typescript = hasOwnProperty + this atrocity
|
||||
const cDeps = Object.create({ c: cRef }, { a: { enumerable: true, value: aRef } });
|
||||
const cDeps = Object.create(
|
||||
{ c: cRef },
|
||||
{ a: { enumerable: true, value: aRef } },
|
||||
);
|
||||
cDeps.b = bRef;
|
||||
|
||||
const registry = new ApiTestRegistry();
|
||||
registry.register({ implements: aRef, deps: {}, factory: () => 3 });
|
||||
registry.register({ implements: bRef, deps: { dep: aRef }, factory: ({ dep }) => `hello ${dep}` });
|
||||
registry.register({ implements: cRef, deps: cDeps, factory: ({ a, b }) => b.repeat(a) });
|
||||
registry.register({
|
||||
implements: bRef,
|
||||
deps: { dep: aRef },
|
||||
factory: ({ dep }) => `hello ${dep}`,
|
||||
});
|
||||
registry.register({
|
||||
implements: cRef,
|
||||
deps: cDeps,
|
||||
factory: ({ a, b }) => b.repeat(a),
|
||||
});
|
||||
expect(registry.get(aRef)).toBe(3);
|
||||
expect(registry.get(bRef)).toBe('hello 3');
|
||||
expect(registry.get(cRef)).toBe('hello 3hello 3hello 3');
|
||||
@@ -73,17 +84,39 @@ describe('ApiTestRegistry', () => {
|
||||
|
||||
it('should not allow cyclic dependencies', () => {
|
||||
const registry = new ApiTestRegistry();
|
||||
registry.register({ implements: aRef, deps: { b: bRef }, factory: () => 1 });
|
||||
registry.register({ implements: bRef, deps: { c: cRef }, factory: () => 'b' });
|
||||
registry.register({ implements: cRef, deps: { a: aRef }, factory: () => 'c' });
|
||||
expect(() => registry.get(aRef)).toThrow('Circular dependency of api factory for apiRef{a}');
|
||||
expect(() => registry.get(bRef)).toThrow('Circular dependency of api factory for apiRef{b}');
|
||||
expect(() => registry.get(cRef)).toThrow('Circular dependency of api factory for apiRef{c}');
|
||||
registry.register({
|
||||
implements: aRef,
|
||||
deps: { b: bRef },
|
||||
factory: () => 1,
|
||||
});
|
||||
registry.register({
|
||||
implements: bRef,
|
||||
deps: { c: cRef },
|
||||
factory: () => 'b',
|
||||
});
|
||||
registry.register({
|
||||
implements: cRef,
|
||||
deps: { a: aRef },
|
||||
factory: () => 'c',
|
||||
});
|
||||
expect(() => registry.get(aRef)).toThrow(
|
||||
'Circular dependency of api factory for apiRef{a}',
|
||||
);
|
||||
expect(() => registry.get(bRef)).toThrow(
|
||||
'Circular dependency of api factory for apiRef{b}',
|
||||
);
|
||||
expect(() => registry.get(cRef)).toThrow(
|
||||
'Circular dependency of api factory for apiRef{c}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if dependency is not available', () => {
|
||||
const registry = new ApiTestRegistry();
|
||||
registry.register({ implements: aRef, deps: { b: bRef }, factory: () => 1 });
|
||||
registry.register({
|
||||
implements: aRef,
|
||||
deps: { b: bRef },
|
||||
factory: () => 1,
|
||||
});
|
||||
expect(() => registry.get(aRef)).toThrow(
|
||||
'No API factory available for dependency apiRef{b} of dependent apiRef{a}',
|
||||
);
|
||||
|
||||
@@ -19,8 +19,14 @@ import { TypesToApiRefs, AnyApiRef, ApiHolder, ApiFactory } from './types';
|
||||
|
||||
export default class ApiTestRegistry implements ApiHolder {
|
||||
private readonly apis = new Map<AnyApiRef, unknown>();
|
||||
private factories = new Map<AnyApiRef, ApiFactory<unknown, unknown, unknown>>();
|
||||
private savedFactories = new Map<AnyApiRef, ApiFactory<unknown, unknown, unknown>>();
|
||||
private factories = new Map<
|
||||
AnyApiRef,
|
||||
ApiFactory<unknown, unknown, unknown>
|
||||
>();
|
||||
private savedFactories = new Map<
|
||||
AnyApiRef,
|
||||
ApiFactory<unknown, unknown, unknown>
|
||||
>();
|
||||
|
||||
get<T>(ref: ApiRef<T>): T | undefined {
|
||||
return this.load(ref);
|
||||
@@ -28,7 +34,10 @@ export default class ApiTestRegistry implements ApiHolder {
|
||||
|
||||
register<T>(ref: ApiRef<T>, factoryFunc: () => T): ApiTestRegistry;
|
||||
register<A, I, D>(factory: ApiFactory<A, I, D>): ApiTestRegistry;
|
||||
register<A, I, D, T>(factory: ApiRef<T> | ApiFactory<A, I, D>, factoryFunc?: () => T): ApiTestRegistry {
|
||||
register<A, I, D, T>(
|
||||
factory: ApiRef<T> | ApiFactory<A, I, D>,
|
||||
factoryFunc?: () => T,
|
||||
): ApiTestRegistry {
|
||||
if (factory instanceof ApiRef) {
|
||||
this.factories.set(factory, {
|
||||
implements: factory,
|
||||
@@ -63,16 +72,25 @@ export default class ApiTestRegistry implements ApiHolder {
|
||||
}
|
||||
|
||||
if (loading.includes(factory.implements)) {
|
||||
throw new Error(`Circular dependency of api factory for ${factory.implements}`);
|
||||
throw new Error(
|
||||
`Circular dependency of api factory for ${factory.implements}`,
|
||||
);
|
||||
}
|
||||
|
||||
const deps = this.loadDeps(ref, factory.deps, [...loading, factory.implements]);
|
||||
const deps = this.loadDeps(ref, factory.deps, [
|
||||
...loading,
|
||||
factory.implements,
|
||||
]);
|
||||
const api = factory.factory(deps);
|
||||
this.apis.set(ref, api);
|
||||
return api as T;
|
||||
}
|
||||
|
||||
private loadDeps<T>(dependent: ApiRef<unknown>, apis: TypesToApiRefs<T>, loading: AnyApiRef[]): T {
|
||||
private loadDeps<T>(
|
||||
dependent: ApiRef<unknown>,
|
||||
apis: TypesToApiRefs<T>,
|
||||
loading: AnyApiRef[],
|
||||
): T {
|
||||
const impls = {} as T;
|
||||
|
||||
for (const key in apis) {
|
||||
@@ -81,7 +99,9 @@ export default class ApiTestRegistry implements ApiHolder {
|
||||
|
||||
const api = this.load(ref, loading);
|
||||
if (!api) {
|
||||
throw new Error(`No API factory available for dependency ${ref} of dependent ${dependent}`);
|
||||
throw new Error(
|
||||
`No API factory available for dependency ${ref} of dependent ${dependent}`,
|
||||
);
|
||||
}
|
||||
impls[key] = api;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ApiRef from '../ApiRef';
|
||||
|
||||
/**
|
||||
* Mirrors the javascript Error class, for the purpose of
|
||||
* providing documentation and optional fields.
|
||||
*/
|
||||
type Error = {
|
||||
name: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides additional information about an error that was posted to the application.
|
||||
*/
|
||||
export type ErrorContext = {
|
||||
// If set to true, this error should not be displayed to the user. Defaults to false.
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The error API is used to report errors to the app, and display them to the user.
|
||||
*
|
||||
* Plugins can use this API as a method of displaying errors to the user, but also
|
||||
* to report errors for collection by error reporting services.
|
||||
*
|
||||
* If an error can be displayed inline, e.g. as feedback in a form, that should be
|
||||
* preferred over relying on this API to display the error. The main use of this api
|
||||
* for displaying errors should be for asynchronous errors, such as a failing background process.
|
||||
*
|
||||
* Even if an error is displayed inline, it should still be reported through this api
|
||||
* if it would be useful to collect or log it for debugging purposes, but with
|
||||
* the hidden flag set. For example, an error arising from form field validation
|
||||
* should probably not be reported, while a failed REST call would be useful to report.
|
||||
*/
|
||||
export type ErrorApi = {
|
||||
/**
|
||||
* Post an error for handling by the application.
|
||||
*/
|
||||
post(error: Error, context?: ErrorContext);
|
||||
};
|
||||
|
||||
export const errorApiRef = new ApiRef<ErrorApi>({
|
||||
id: 'core.error',
|
||||
description: 'Used to report errors and forward them to the app',
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ApiRef from '../ApiRef';
|
||||
import {
|
||||
UserFlags,
|
||||
FeatureFlagsRegistry,
|
||||
FeatureFlagsRegistryItem,
|
||||
} from '../../app/FeatureFlags';
|
||||
|
||||
/**
|
||||
* The feature flags API is used to toggle functionality to users across plugins and Backstage.
|
||||
*
|
||||
* Plugins can use this API to register feature flags that they have available
|
||||
* for users to enable/disable, and this API will centralize the current user's
|
||||
* state of which feature flags they would like to enable.
|
||||
*
|
||||
* This is ideal for Backstage plugins, as well as your own App, to trial incomplete
|
||||
* or unstable upcoming features. Although there will be a common interface for users
|
||||
* to enable and disable feature flags, this API acts as another way to enable/disable.
|
||||
*/
|
||||
|
||||
export enum FeatureFlagState {
|
||||
Off = 0,
|
||||
On = 1,
|
||||
}
|
||||
|
||||
export interface FeatureFlagsApi {
|
||||
/**
|
||||
* Store a list of registered feature flags.
|
||||
*/
|
||||
registeredFeatureFlags: FeatureFlagsRegistryItem[];
|
||||
|
||||
/**
|
||||
* Get a list of all feature flags from the current user.
|
||||
*/
|
||||
getFlags(): UserFlags;
|
||||
|
||||
/**
|
||||
* Get a list of all registered flags.
|
||||
*/
|
||||
getRegisteredFlags(): FeatureFlagsRegistry;
|
||||
}
|
||||
|
||||
export const featureFlagsApiRef = new ApiRef<FeatureFlagsApi>({
|
||||
id: 'core.featureflags',
|
||||
description: 'Used to toggle functionality in features across Backstage',
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// This folder contains definitions for all core APIs.
|
||||
//
|
||||
// Plugins should rely on these APIs for functionality as much as possible.
|
||||
//
|
||||
// If you think some API definition is missing, please open an Issue or send a PR!
|
||||
|
||||
export * from './error';
|
||||
export * from './featureFlags';
|
||||
@@ -18,3 +18,4 @@ export { default as ApiProvider, useApi } from './ApiProvider';
|
||||
export { default as ApiRegistry } from './ApiRegistry';
|
||||
export { default as ApiTestRegistry } from './ApiTestRegistry';
|
||||
export * from './types';
|
||||
export * from './definitions';
|
||||
|
||||
@@ -19,12 +19,16 @@ import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { App } from './types';
|
||||
import BackstagePlugin from '../plugin/Plugin';
|
||||
import { FeatureFlagsRegistryItem } from './FeatureFlags';
|
||||
import { featureFlagsApiRef } from '../apis/definitions/featureFlags';
|
||||
import {
|
||||
IconComponent,
|
||||
SystemIcons,
|
||||
SystemIconKey,
|
||||
defaultSystemIcons,
|
||||
} from '../../icons';
|
||||
import { ApiHolder, ApiProvider } from '../apis';
|
||||
import LoginPage from './LoginPage';
|
||||
|
||||
class AppImpl implements App {
|
||||
constructor(private readonly systemIcons: SystemIcons) {}
|
||||
@@ -35,9 +39,14 @@ class AppImpl implements App {
|
||||
}
|
||||
|
||||
export default class AppBuilder {
|
||||
private apis?: ApiHolder;
|
||||
private systemIcons = { ...defaultSystemIcons };
|
||||
private readonly plugins = new Set<BackstagePlugin>();
|
||||
|
||||
registerApis(apis: ApiHolder) {
|
||||
this.apis = apis;
|
||||
}
|
||||
|
||||
registerIcons(icons: Partial<SystemIcons>) {
|
||||
this.systemIcons = { ...this.systemIcons, ...icons };
|
||||
}
|
||||
@@ -55,6 +64,7 @@ export default class AppBuilder {
|
||||
const app = new AppImpl(this.systemIcons);
|
||||
|
||||
const routes = new Array<JSX.Element>();
|
||||
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
|
||||
|
||||
for (const plugin of this.plugins.values()) {
|
||||
for (const output of plugin.output()) {
|
||||
@@ -80,19 +90,39 @@ export default class AppBuilder {
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'feature-flag': {
|
||||
registeredFeatureFlags.push({
|
||||
pluginId: plugin.getId(),
|
||||
name: output.name,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => (
|
||||
<AppContextProvider app={app}>
|
||||
<Switch>
|
||||
{routes}
|
||||
<Route component={() => <span>404 Not Found</span>} />
|
||||
</Switch>
|
||||
</AppContextProvider>
|
||||
const FeatureFlags = this.apis && this.apis.get(featureFlagsApiRef);
|
||||
if (FeatureFlags) {
|
||||
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
|
||||
}
|
||||
|
||||
routes.push(
|
||||
<Route key="login" path="/login" component={LoginPage} exact />,
|
||||
);
|
||||
|
||||
let rendered = (
|
||||
<Switch>
|
||||
{routes}
|
||||
<Route component={() => <span>404 Not Found</span>} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
if (this.apis) {
|
||||
rendered = <ApiProvider apis={this.apis} children={rendered} />;
|
||||
}
|
||||
|
||||
return () => <AppContextProvider app={app} children={rendered} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags';
|
||||
import { FeatureFlagState } from '../apis/definitions/featureFlags';
|
||||
|
||||
describe('FeatureFlags', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('#getFlags', () => {
|
||||
let featureFlags;
|
||||
|
||||
beforeEach(() => {
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
});
|
||||
|
||||
it('returns no flags', () => {
|
||||
expect(featureFlags.getFlags().toObject()).toMatchObject({});
|
||||
});
|
||||
|
||||
it('returns the correct flags', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 1,
|
||||
'feature-flag-three': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
expect(featureFlags.getFlags().toObject()).toMatchObject({
|
||||
'feature-flag-one': FeatureFlagState.On,
|
||||
'feature-flag-two': FeatureFlagState.On,
|
||||
'feature-flag-three': FeatureFlagState.Off,
|
||||
});
|
||||
});
|
||||
|
||||
it('gets the correct values', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
|
||||
expect(featureFlags.getFlags().get('feature-flag-one')).toEqual(
|
||||
FeatureFlagState.On,
|
||||
);
|
||||
expect(featureFlags.getFlags().get('feature-flag-two')).toEqual(
|
||||
FeatureFlagState.Off,
|
||||
);
|
||||
expect(featureFlags.getFlags().get('feature-flag-three')).toEqual(
|
||||
FeatureFlagState.Off,
|
||||
);
|
||||
});
|
||||
|
||||
it('sets the correct values', () => {
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.set('feature-flag-zero', FeatureFlagState.On);
|
||||
|
||||
expect(flags.get('feature-flag-zero')).toEqual(FeatureFlagState.On);
|
||||
expect(window.localStorage.getItem('featureFlags')).toEqual(
|
||||
'{"feature-flag-zero":1}',
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes the correct values', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.delete('feature-flag-one');
|
||||
|
||||
expect(flags.get('feature-flag-one')).toEqual(FeatureFlagState.Off);
|
||||
expect(window.localStorage.getItem('featureFlags')).toEqual(
|
||||
'{"feature-flag-two":0}',
|
||||
);
|
||||
});
|
||||
|
||||
it('clears all values', () => {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify({
|
||||
'feature-flag-one': 1,
|
||||
'feature-flag-two': 1,
|
||||
'feature-flag-three': 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const flags = featureFlags.getFlags();
|
||||
flags.clear();
|
||||
|
||||
expect(flags.toObject()).toEqual({});
|
||||
expect(window.localStorage.getItem('featureFlags')).toEqual('{}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getRegisteredFlags', () => {
|
||||
let featureFlags;
|
||||
|
||||
beforeEach(() => {
|
||||
featureFlags = new FeatureFlagsImpl();
|
||||
featureFlags.registeredFeatureFlags = [
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
];
|
||||
});
|
||||
|
||||
it('should return an empty list', () => {
|
||||
featureFlags.registeredFeatureFlags = [];
|
||||
expect(featureFlags.getRegisteredFlags().toObject()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return an valid list', () => {
|
||||
expect(featureFlags.getRegisteredFlags().toObject()).toMatchObject([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should get the correct values', () => {
|
||||
const getByName = name =>
|
||||
featureFlags.getRegisteredFlags().find(flag => flag.name === name);
|
||||
|
||||
expect(getByName('registered-flag-0')).toBeUndefined();
|
||||
expect(getByName('registered-flag-1')).toEqual({
|
||||
name: 'registered-flag-1',
|
||||
pluginId: 'plugin-one',
|
||||
});
|
||||
expect(getByName('registered-flag-2')).toEqual({
|
||||
name: 'registered-flag-2',
|
||||
pluginId: 'plugin-one',
|
||||
});
|
||||
expect(getByName('registered-flag-3')).toEqual({
|
||||
name: 'registered-flag-3',
|
||||
pluginId: 'plugin-two',
|
||||
});
|
||||
});
|
||||
|
||||
it('should append the correct value', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
|
||||
flags.push({
|
||||
name: 'registered-flag-4',
|
||||
pluginId: 'plugin-three',
|
||||
});
|
||||
|
||||
expect(flags.toObject()).toMatchObject([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
{ name: 'registered-flag-4', pluginId: 'plugin-three' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should concat the correct values', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
const concatValues = flags.concat([
|
||||
{
|
||||
name: 'registered-flag-4',
|
||||
pluginId: 'plugin-three',
|
||||
},
|
||||
{
|
||||
name: 'registered-flag-5',
|
||||
pluginId: 'plugin-four',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(concatValues).toMatchObject([
|
||||
{ name: 'registered-flag-1', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-2', pluginId: 'plugin-one' },
|
||||
{ name: 'registered-flag-3', pluginId: 'plugin-two' },
|
||||
{ name: 'registered-flag-4', pluginId: 'plugin-three' },
|
||||
{ name: 'registered-flag-5', pluginId: 'plugin-four' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('throws an error if length is less than three characters', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
name: 'ab',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
).toThrow(/minimum length of three characters/i);
|
||||
});
|
||||
|
||||
it('throws an error if length is greater than 150 characters', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
name:
|
||||
'loremipsumdolorsitametconsecteturadipiscingelitnuncvitaeportaexaullamcorperturpismaurisutmattisnequemorbisediaculisauguevivamuspulvinarcursuseratblandithendreritquisqueuttinciduntmagnavestibulumblanditaugueat',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
).toThrow(/not exceed 150 characters/i);
|
||||
});
|
||||
|
||||
it('throws an error if name does not start with a lowercase letter', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
name: '123456789',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
).toThrow(/start with a lowercase letter/i);
|
||||
});
|
||||
|
||||
it('throws an error if name contains characters other than lowercase letters, numbers and hyphens', () => {
|
||||
const flags = featureFlags.getRegisteredFlags();
|
||||
expect(() =>
|
||||
flags.push({
|
||||
name: 'Invalid_Feature_Flag',
|
||||
pluginId: 'plugin-three',
|
||||
}),
|
||||
).toThrow(/only contain lowercase letters, numbers and hyphens/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { FeatureFlagName } from '../plugin/types';
|
||||
import {
|
||||
FeatureFlagState,
|
||||
FeatureFlagsApi,
|
||||
} from '../apis/definitions/featureFlags';
|
||||
|
||||
/**
|
||||
* Helper method for validating compatibility and flag name.
|
||||
*/
|
||||
export function validateBrowserCompat(): void {
|
||||
if (!('localStorage' in window)) {
|
||||
throw new Error(
|
||||
'Feature Flags are not supported on browsers without the Local Storage API',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateFlagName(name: FeatureFlagName): void {
|
||||
if (name.length < 3) {
|
||||
throw new Error(
|
||||
`The '${name}' feature flag must have a minimum length of three characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (name.length > 150) {
|
||||
throw new Error(
|
||||
`The '${name}' feature flag must not exceed 150 characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!name.match(/^[a-z]+[a-z0-9-]+$/)) {
|
||||
throw new Error(
|
||||
`The '${name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens. ` +
|
||||
'Examples: feature-flag-one, alpha, release-2020',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The UserFlags class.
|
||||
*
|
||||
* This acts as a data structure for the user's feature flags. You
|
||||
* can use this to retrieve, add, edit, delete, clear and save the user's
|
||||
* feature flags to the local browser for persisted storage.
|
||||
*/
|
||||
export class UserFlags extends Map<FeatureFlagName, FeatureFlagState> {
|
||||
static load(): UserFlags {
|
||||
validateBrowserCompat();
|
||||
|
||||
try {
|
||||
const jsonString = window.localStorage.getItem('featureFlags') as string;
|
||||
const json = JSON.parse(jsonString);
|
||||
return new this(Object.entries(json));
|
||||
} catch (err) {
|
||||
return new this([]);
|
||||
}
|
||||
}
|
||||
|
||||
get(name: FeatureFlagName): FeatureFlagState {
|
||||
return super.get(name) || FeatureFlagState.Off;
|
||||
}
|
||||
|
||||
set(name: FeatureFlagName, state: FeatureFlagState): this {
|
||||
validateFlagName(name);
|
||||
const output = super.set(name, state);
|
||||
this.save();
|
||||
return output;
|
||||
}
|
||||
|
||||
delete(name: FeatureFlagName): boolean {
|
||||
const output = super.delete(name);
|
||||
this.save();
|
||||
return output;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
super.clear();
|
||||
this.save();
|
||||
}
|
||||
|
||||
save(): void {
|
||||
window.localStorage.setItem(
|
||||
'featureFlags',
|
||||
JSON.stringify(this.toObject()),
|
||||
);
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return Array.from(this.entries()).reduce(
|
||||
(obj, [key, value]) => ({ ...obj, [key]: value }),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return JSON.stringify(this.toObject());
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The FeatureFlagsRegistry class.
|
||||
*
|
||||
* This acts as a holding data structure for feature flags
|
||||
* that plugins wish to register for use in Backstage.
|
||||
*/
|
||||
export interface FeatureFlagsRegistryItem {
|
||||
pluginId: string;
|
||||
name: FeatureFlagName;
|
||||
}
|
||||
|
||||
export class FeatureFlagsRegistry extends Array<FeatureFlagsRegistryItem> {
|
||||
static from(entries: FeatureFlagsRegistryItem[]) {
|
||||
Array.from(entries).forEach(entry => validateFlagName(entry.name));
|
||||
return new FeatureFlagsRegistry(...entries);
|
||||
}
|
||||
|
||||
push(...entries: FeatureFlagsRegistryItem[]): number {
|
||||
Array.from(entries).forEach(entry => validateFlagName(entry.name));
|
||||
return super.push(...entries);
|
||||
}
|
||||
|
||||
concat(
|
||||
...entries: (
|
||||
| FeatureFlagsRegistryItem
|
||||
| ConcatArray<FeatureFlagsRegistryItem>
|
||||
)[]
|
||||
): FeatureFlagsRegistryItem[] {
|
||||
const _concat = super.concat(...entries);
|
||||
Array.from(_concat).forEach(entry => validateFlagName(entry.name));
|
||||
return _concat;
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return [...this.values()];
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return JSON.stringify(this.toObject());
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.toJSON();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the FeatureFlags implementation based on the API.
|
||||
*/
|
||||
export class FeatureFlags implements FeatureFlagsApi {
|
||||
public registeredFeatureFlags: FeatureFlagsRegistryItem[] = [];
|
||||
private userFlags: UserFlags | undefined;
|
||||
|
||||
getFlags(): UserFlags {
|
||||
if (!this.userFlags) this.userFlags = UserFlags.load();
|
||||
return this.userFlags;
|
||||
}
|
||||
|
||||
getRegisteredFlags(): FeatureFlagsRegistry {
|
||||
return FeatureFlagsRegistry.from(this.registeredFeatureFlags);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import { GitHub as GitHubIcon } from '@material-ui/icons';
|
||||
import Page from '../../../layout/Page';
|
||||
import Header from '../../../layout/Header';
|
||||
import Content from '../../../layout/Content/Content';
|
||||
import ContentHeader from '../../../layout/ContentHeader/ContentHeader';
|
||||
import {
|
||||
Grid,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
List,
|
||||
ListItem,
|
||||
Link,
|
||||
} from '@material-ui/core';
|
||||
import InfoCard from '../../../layout/InfoCard/InfoCard';
|
||||
|
||||
enum AuthType {
|
||||
GitHub,
|
||||
}
|
||||
|
||||
const LoginPage: FC<{}> = () => {
|
||||
const [githubUsername, setGithubUsername] = useState(String);
|
||||
const [githubPersonalAuthToken, setGithubPersonalAuthToken] = useState(
|
||||
String,
|
||||
);
|
||||
const [loginDetails, setLoginDetails] = useState(Object);
|
||||
|
||||
const saveGithubInfo = (info: {}) => {
|
||||
localStorage.setItem('githubLoginDetails', JSON.stringify(info));
|
||||
setLoginDetails(info);
|
||||
};
|
||||
|
||||
const deleteGithubInfo = () => {
|
||||
localStorage.removeItem('githubLoginDetails');
|
||||
setLoginDetails(undefined);
|
||||
};
|
||||
|
||||
const handleTokenRegistration = (event: any) => {
|
||||
switch (event.target.name) {
|
||||
case 'github-username-tf':
|
||||
setGithubUsername(event.target.value);
|
||||
break;
|
||||
case 'github-auth-tf':
|
||||
setGithubPersonalAuthToken(event.target.value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGitHubToken = (username: String, token: String) => {
|
||||
fetch('https://api.github.com/user', {
|
||||
headers: new Headers({
|
||||
Authorization: `Basic ${btoa(`${username}:${token}`)}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 200) return response.json();
|
||||
throw Error(`${response.status} ${response.statusText}`);
|
||||
})
|
||||
.then(data => {
|
||||
const info = {
|
||||
username: username,
|
||||
token: token,
|
||||
name: data.name || data.login,
|
||||
};
|
||||
saveGithubInfo(info);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
const validateUsernameAndToken = (username: String, token: String) => {
|
||||
if (username === undefined || username === null || username === '')
|
||||
return false;
|
||||
|
||||
if (token === undefined || token === null || token === '') return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const authenticate = (type: AuthType) => {
|
||||
switch (type) {
|
||||
case AuthType.GitHub:
|
||||
{
|
||||
const username = githubUsername;
|
||||
const token = githubPersonalAuthToken;
|
||||
if (validateUsernameAndToken(username, token))
|
||||
fetchGitHubToken(username, token);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const LoginIndicator = () => {
|
||||
const ls = localStorage.getItem('githubLoginDetails');
|
||||
if (ls !== null) {
|
||||
const obj = ls || loginDetails ? JSON.parse(ls) : loginDetails;
|
||||
return (
|
||||
<Typography variant="h6" component="h2">
|
||||
{`Welcome, ${obj.name}!`}
|
||||
<br />
|
||||
<Link onClick={deleteGithubInfo}>Logout</Link>
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography variant="h6" component="h2">
|
||||
Welcome, guest!
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<Header title="Login">
|
||||
<LoginIndicator />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Choose a method to authenticate" />
|
||||
<Grid container>
|
||||
<Grid item>
|
||||
<InfoCard>
|
||||
<Typography variant="h6">
|
||||
<GitHubIcon /> GitHub
|
||||
</Typography>
|
||||
<List>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="github-username-tf"
|
||||
label="Username"
|
||||
onChange={handleTokenRegistration}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="github-auth-tf"
|
||||
label="Token"
|
||||
onChange={handleTokenRegistration}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Button
|
||||
data-testid="github-auth-button"
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => authenticate(AuthType.GitHub)}
|
||||
>
|
||||
Authenticate
|
||||
</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -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 { default } from './LoginPage';
|
||||
@@ -16,4 +16,5 @@
|
||||
|
||||
export * from './api';
|
||||
export * from './apis';
|
||||
export { FeatureFlags } from './app/FeatureFlags';
|
||||
export { useApp } from './app/AppContext';
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
*/
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { PluginOutput, RoutePath, RouteOptions } from './types';
|
||||
import {
|
||||
PluginOutput,
|
||||
RoutePath,
|
||||
RouteOptions,
|
||||
FeatureFlagName,
|
||||
} from './types';
|
||||
import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags';
|
||||
import { Widget } from '../widgetView/types';
|
||||
|
||||
export type PluginConfig = {
|
||||
@@ -26,6 +32,7 @@ export type PluginConfig = {
|
||||
export type PluginHooks = {
|
||||
router: RouterHooks;
|
||||
widgets: WidgetHooks;
|
||||
featureFlags: FeatureFlagsHooks;
|
||||
};
|
||||
|
||||
export type RouterHooks = {
|
||||
@@ -46,6 +53,10 @@ export type WidgetHooks = {
|
||||
add(widget: Widget): void;
|
||||
};
|
||||
|
||||
export type FeatureFlagsHooks = {
|
||||
register(name: FeatureFlagName): void;
|
||||
};
|
||||
|
||||
export const registerSymbol = Symbol('plugin-register');
|
||||
export const outputSymbol = Symbol('plugin-output');
|
||||
|
||||
@@ -54,6 +65,10 @@ export default class Plugin {
|
||||
|
||||
constructor(private readonly config: PluginConfig) {}
|
||||
|
||||
getId(): string {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
output(): PluginOutput[] {
|
||||
if (this.storedOutput) {
|
||||
return this.storedOutput;
|
||||
@@ -78,6 +93,13 @@ export default class Plugin {
|
||||
outputs.push({ type: 'widget', widget });
|
||||
},
|
||||
},
|
||||
featureFlags: {
|
||||
register(name) {
|
||||
validateBrowserCompat();
|
||||
validateFlagName(name);
|
||||
outputs.push({ type: 'feature-flag', name });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.storedOutput = outputs;
|
||||
|
||||
@@ -43,4 +43,15 @@ export type WidgetOutput = {
|
||||
widget: Widget;
|
||||
};
|
||||
|
||||
export type PluginOutput = RouteOutput | RedirectRouteOutput | WidgetOutput;
|
||||
export type FeatureFlagName = string;
|
||||
|
||||
export type FeatureFlagOutput = {
|
||||
type: 'feature-flag';
|
||||
name: FeatureFlagName;
|
||||
};
|
||||
|
||||
export type PluginOutput =
|
||||
| RouteOutput
|
||||
| RedirectRouteOutput
|
||||
| WidgetOutput
|
||||
| FeatureFlagOutput;
|
||||
|
||||
@@ -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: 'Progress',
|
||||
component: Progress,
|
||||
};
|
||||
|
||||
export const progress = () => <Progress />;
|
||||
@@ -14,4 +14,12 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { StatusError, StatusFailed, StatusNA, StatusOK, StatusPending, StatusRunning, StatusWarning } from './Status';
|
||||
export {
|
||||
StatusError,
|
||||
StatusFailed,
|
||||
StatusNA,
|
||||
StatusOK,
|
||||
StatusPending,
|
||||
StatusRunning,
|
||||
StatusWarning,
|
||||
} from './Status';
|
||||
|
||||
@@ -78,7 +78,11 @@ const SupportButton: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Button color="primary" onClick={onClickHandler}>
|
||||
<Button
|
||||
data-testid="support-button"
|
||||
color="primary"
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
<HelpIcon className={classes.leftIcon} />
|
||||
Support
|
||||
</Button>
|
||||
|
||||
+29
-11
@@ -14,16 +14,28 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Component } from 'react';
|
||||
import React, { ComponentClass, Component, SFC } from 'react';
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
type Props = {
|
||||
slackChannel: string;
|
||||
onError?: (error: Error, errorInfo: string) => null;
|
||||
};
|
||||
|
||||
type State = {
|
||||
error?: Error;
|
||||
errorInfo?: string;
|
||||
};
|
||||
|
||||
const ErrorBoundary: ComponentClass<
|
||||
Props,
|
||||
State
|
||||
> = class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
onError: props.onError,
|
||||
error: undefined,
|
||||
errorInfo: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,8 +45,8 @@ export default class ErrorBoundary extends Component {
|
||||
this.setState({ error, errorInfo });
|
||||
|
||||
// Exposed for testing
|
||||
if (ErrorBoundary.onError) {
|
||||
ErrorBoundary.onError(error, errorInfo);
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +62,17 @@ export default class ErrorBoundary extends Component {
|
||||
<Error error={error} errorInfo={errorInfo} slackChannel={slackChannel} />
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Importing Error would mean importing a lot of stuff
|
||||
// will take it up in a separate PR
|
||||
const Error = ({ slackChannel }) => {
|
||||
export default ErrorBoundary;
|
||||
|
||||
type EProps = {
|
||||
error?: Error;
|
||||
errorInfo?: string;
|
||||
slackChannel: string;
|
||||
};
|
||||
|
||||
const Error: SFC<EProps> = ({ slackChannel }) => {
|
||||
return (
|
||||
<div>
|
||||
Something went wrong here. Please contact {slackChannel} for help.
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Typography, withStyles, Tooltip } from '@material-ui/core';
|
||||
import { Theme } from '../Page/Page';
|
||||
// import { Link } from 'shared/components';
|
||||
import Waves from './Waves';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
class Header extends Component {
|
||||
static propTypes = {
|
||||
type: PropTypes.string,
|
||||
typeLink: PropTypes.string,
|
||||
title: PropTypes.node.isRequired,
|
||||
tooltip: PropTypes.string,
|
||||
subtitle: PropTypes.node,
|
||||
pageTitleOverride: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
component: PropTypes.object,
|
||||
};
|
||||
|
||||
typeFragment() {
|
||||
const { type, typeLink, classes } = this.props;
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return typeLink ? (
|
||||
// <Link to={typeLink}>
|
||||
<Typography className={classes.type}>{type}</Typography>
|
||||
) : (
|
||||
// </Link>
|
||||
<Typography className={classes.type}>{type}</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
titleFragment() {
|
||||
const { title, pageTitleOverride, classes, tooltip } = this.props;
|
||||
const FinalTitle = (
|
||||
<Typography className={classes.title} variant="h4">
|
||||
{title || pageTitleOverride}
|
||||
</Typography>
|
||||
);
|
||||
if (tooltip) {
|
||||
return (
|
||||
<Tooltip title={tooltip} placement="top-start">
|
||||
{FinalTitle}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return FinalTitle;
|
||||
}
|
||||
|
||||
subtitleFragment() {
|
||||
const { subtitle, classes } = this.props;
|
||||
if (!subtitle) {
|
||||
return null;
|
||||
} else if (typeof subtitle !== 'string') {
|
||||
return subtitle;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography className={classes.subtitle} variant="subtitle1">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { title, pageTitleOverride, children, style, classes } = this.props;
|
||||
const pageTitle = pageTitleOverride || title;
|
||||
return (
|
||||
<Fragment>
|
||||
<Helmet
|
||||
titleTemplate={`${pageTitle} | %s | Backstage`}
|
||||
defaultTitle={`${pageTitle} | Backstage`}
|
||||
/>
|
||||
<Theme.Consumer>
|
||||
{theme => (
|
||||
<header style={style} className={classes.header}>
|
||||
<Waves theme={theme} />
|
||||
<div className={classes.leftItemsBox}>
|
||||
{this.typeFragment()}
|
||||
{this.titleFragment()}
|
||||
{this.subtitleFragment()}
|
||||
</div>
|
||||
<div className={classes.rightItemsBox}>{children}</div>
|
||||
</header>
|
||||
)}
|
||||
</Theme.Consumer>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = theme => ({
|
||||
header: {
|
||||
gridArea: 'pageHeader',
|
||||
padding: theme.spacing(3),
|
||||
minHeight: 118,
|
||||
width: '100%',
|
||||
boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)',
|
||||
position: 'relative',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
},
|
||||
leftItemsBox: {
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
rightItemsBox: {
|
||||
flex: '0 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
marginRight: theme.spacing(6),
|
||||
},
|
||||
title: {
|
||||
color: theme.palette.bursts.fontColor,
|
||||
lineHeight: '1.0em',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))',
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
subtitle: {
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
lineHeight: '1.0em',
|
||||
},
|
||||
type: {
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 9,
|
||||
opacity: 0.8,
|
||||
marginBottom: 10,
|
||||
color: theme.palette.bursts.fontColor,
|
||||
},
|
||||
});
|
||||
|
||||
export default withStyles(styles)(Header);
|
||||
+9
-3
@@ -36,18 +36,24 @@ describe('<Header/>', () => {
|
||||
});
|
||||
|
||||
it('should override document title', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<Header title="Title1" pageTitleOverride="Title2" />));
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<Header title="Title1" pageTitleOverride="Title2" />),
|
||||
);
|
||||
rendered.getByText('Title1');
|
||||
rendered.getByText('defaultTitle: Title2 | Backstage');
|
||||
});
|
||||
|
||||
it('should have subtitle', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<Header title="Title" subtitle="Subtitle" />));
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<Header title="Title" subtitle="Subtitle" />),
|
||||
);
|
||||
rendered.getByText('Subtitle');
|
||||
});
|
||||
|
||||
it('should have type rendered', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<Header title="Title" type="tool" />));
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<Header title="Title" type="tool" />),
|
||||
);
|
||||
rendered.getByText('tool');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Fragment, ReactNode, CSSProperties, FC } from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { Typography, Tooltip, makeStyles } from '@material-ui/core';
|
||||
import { Theme } from '../Page/Page';
|
||||
// import { Link } from 'shared/components';
|
||||
import { BackstageTheme } from '../../theme/theme';
|
||||
import Waves from './Waves';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
header: {
|
||||
gridArea: 'pageHeader',
|
||||
padding: theme.spacing(3),
|
||||
minHeight: 118,
|
||||
width: '100%',
|
||||
boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)',
|
||||
position: 'relative',
|
||||
zIndex: 100,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
},
|
||||
leftItemsBox: {
|
||||
flex: '1 1 auto',
|
||||
},
|
||||
rightItemsBox: {
|
||||
flex: '0 1 auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
marginRight: theme.spacing(6),
|
||||
},
|
||||
title: {
|
||||
color: theme.palette.bursts.fontColor,
|
||||
lineHeight: '1.0em',
|
||||
wordBreak: 'break-all',
|
||||
fontSize: 'calc(24px + 6 * ((100vw - 320px) / 680))',
|
||||
marginBottom: theme.spacing(1),
|
||||
},
|
||||
subtitle: {
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
lineHeight: '1.0em',
|
||||
},
|
||||
type: {
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 9,
|
||||
opacity: 0.8,
|
||||
marginBottom: 10,
|
||||
color: theme.palette.bursts.fontColor,
|
||||
},
|
||||
}));
|
||||
|
||||
type HeaderStyles = ReturnType<typeof useStyles>;
|
||||
|
||||
type Props = {
|
||||
component?: ReactNode;
|
||||
pageTitleOverride?: string;
|
||||
style?: CSSProperties;
|
||||
subtitle?: ReactNode;
|
||||
title: ReactNode;
|
||||
tooltip?: string;
|
||||
type?: string;
|
||||
typeLink?: string;
|
||||
};
|
||||
|
||||
type TypeFragmentProps = {
|
||||
classes: HeaderStyles;
|
||||
type?: Props['title'];
|
||||
typeLink?: Props['typeLink'];
|
||||
};
|
||||
|
||||
type TitleFragmentProps = {
|
||||
classes: HeaderStyles;
|
||||
pageTitle: string | ReactNode;
|
||||
tooltip?: Props['tooltip'];
|
||||
};
|
||||
|
||||
type SubtitleFragmentProps = {
|
||||
classes: HeaderStyles;
|
||||
subtitle?: Props['subtitle'];
|
||||
};
|
||||
|
||||
const TypeFragment: FC<TypeFragmentProps> = ({ type, typeLink, classes }) => {
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!typeLink) {
|
||||
return (
|
||||
// </Link>
|
||||
<Typography className={classes.type}>{type}</Typography>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// <Link to={typeLink}>
|
||||
<Typography className={classes.type}>{type}</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
const TitleFragment: FC<TitleFragmentProps> = ({
|
||||
pageTitle,
|
||||
classes,
|
||||
tooltip,
|
||||
}) => {
|
||||
const FinalTitle = (
|
||||
<Typography className={classes.title} variant="h4">
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return FinalTitle;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={tooltip} placement="top-start">
|
||||
{FinalTitle}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const SubtitleFragment: FC<SubtitleFragmentProps> = ({ classes, subtitle }) => {
|
||||
if (!subtitle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof subtitle !== 'string') {
|
||||
return <>{subtitle}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Typography className={classes.subtitle} variant="subtitle1">
|
||||
{subtitle}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header: FC<Props> = ({
|
||||
children,
|
||||
pageTitleOverride,
|
||||
style,
|
||||
subtitle,
|
||||
title,
|
||||
tooltip,
|
||||
type,
|
||||
typeLink,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const documentTitle = pageTitleOverride || title;
|
||||
const pageTitle = title || pageTitleOverride;
|
||||
const titleTemplate = `${documentTitle} | %s | Backstage`;
|
||||
const defaultTitle = `${documentTitle} | Backstage`;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Helmet titleTemplate={titleTemplate} defaultTitle={defaultTitle} />
|
||||
<Theme.Consumer>
|
||||
{theme => (
|
||||
<header style={style} className={classes.header}>
|
||||
<Waves theme={theme} />
|
||||
<div className={classes.leftItemsBox}>
|
||||
<TypeFragment classes={classes} type={type} typeLink={typeLink} />
|
||||
<TitleFragment
|
||||
classes={classes}
|
||||
pageTitle={pageTitle}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
<SubtitleFragment classes={classes} subtitle={subtitle} />
|
||||
</div>
|
||||
<div className={classes.rightItemsBox}>{children}</div>
|
||||
</header>
|
||||
)}
|
||||
</Theme.Consumer>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -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 React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { pageTheme } from '../Page/PageThemeProvider';
|
||||
import Waves from './Waves';
|
||||
|
||||
describe('<Waves/>', () => {
|
||||
it('should render svg', () => {
|
||||
const rendered = render(<Waves theme={pageTheme.home} />);
|
||||
rendered.getByTestId('wave-svg');
|
||||
});
|
||||
});
|
||||
+10
-5
@@ -14,10 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import { PageTheme } from '../Page';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
const useStyles = makeStyles<PageTheme>({
|
||||
wave: {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
@@ -26,11 +27,15 @@ const useStyles = makeStyles({
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
'z-index': -1,
|
||||
zIndex: -1,
|
||||
},
|
||||
});
|
||||
|
||||
const Waves = ({ theme }) => {
|
||||
type Props = {
|
||||
theme: PageTheme;
|
||||
};
|
||||
|
||||
const Waves: FC<Props> = ({ theme }) => {
|
||||
const classes = useStyles();
|
||||
const [color1, color2] = theme.gradient.colors;
|
||||
|
||||
@@ -41,6 +46,7 @@ const Waves = ({ theme }) => {
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={classes.wave}
|
||||
data-testid="wave-svg"
|
||||
>
|
||||
<rect width="1440" height="94" fill="url(#paint0_linear)" />
|
||||
<g opacity="0.8">
|
||||
@@ -84,7 +90,6 @@ const Waves = ({ theme }) => {
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor={color1} />
|
||||
<stop offset="1" stopColor={color2} />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear"
|
||||
@@ -15,10 +15,24 @@
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react';
|
||||
import { IconButton, List, ListItem, ListItemIcon, ListItemText, Popover } from '@material-ui/core';
|
||||
import {
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Popover,
|
||||
} from '@material-ui/core';
|
||||
import { default as KebabMenuIcon } from './MenuVertical';
|
||||
|
||||
const ActionItem = ({ label, secondaryLabel, icon, disabled = false, onClick, WrapperComponent = React.Fragment }) => {
|
||||
const ActionItem = ({
|
||||
label,
|
||||
secondaryLabel,
|
||||
icon,
|
||||
disabled = false,
|
||||
onClick,
|
||||
WrapperComponent = React.Fragment,
|
||||
}) => {
|
||||
return (
|
||||
<WrapperComponent>
|
||||
<ListItem
|
||||
@@ -48,7 +62,13 @@ const HeaderActionMenu = ({ actionItems }) => {
|
||||
onClick={() => setOpen(true)}
|
||||
data-testid="header-action-menu"
|
||||
ref={anchorElRef}
|
||||
style={{ color: 'white', height: 56, width: 56, marginRight: -4, padding: 0 }}
|
||||
style={{
|
||||
color: 'white',
|
||||
height: 56,
|
||||
width: 56,
|
||||
marginRight: -4,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<KebabMenuIcon titleAccess="menu" style={{ fontSize: 40 }} />
|
||||
</IconButton>
|
||||
|
||||
@@ -27,13 +27,20 @@ describe('<ComponentContextMenu />', () => {
|
||||
it('can open the menu and click menu items', () => {
|
||||
const onClickFunction = jest.fn();
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<HeaderActionMenu actionItems={[{ label: 'Some label', onClick: onClickFunction }]} />),
|
||||
wrapInThemedTestApp(
|
||||
<HeaderActionMenu
|
||||
actionItems={[{ label: 'Some label', onClick: onClickFunction }]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(rendered.queryByText('Some label')).not.toBeInTheDocument();
|
||||
expect(onClickFunction).not.toHaveBeenCalled();
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
expect(onClickFunction).not.toHaveBeenCalled();
|
||||
expect(rendered.getByTestId('header-action-item')).not.toHaveAttribute('aria-disabled', 'true');
|
||||
expect(rendered.getByTestId('header-action-item')).not.toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
fireEvent.click(rendered.queryByText('Some label'));
|
||||
expect(onClickFunction).toHaveBeenCalled();
|
||||
// We do not expect the dropdown to disappear after click
|
||||
@@ -42,11 +49,18 @@ describe('<ComponentContextMenu />', () => {
|
||||
|
||||
it('Disabled', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<HeaderActionMenu actionItems={[{ label: 'Some label', disabled: true }]} />),
|
||||
wrapInThemedTestApp(
|
||||
<HeaderActionMenu
|
||||
actionItems={[{ label: 'Some label', disabled: true }]}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
expect(rendered.getByTestId('header-action-item')).toHaveAttribute('aria-disabled', 'true');
|
||||
expect(rendered.getByTestId('header-action-item')).toHaveAttribute(
|
||||
'aria-disabled',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('Test wrapper, and secondary label', () => {
|
||||
@@ -58,7 +72,9 @@ describe('<ComponentContextMenu />', () => {
|
||||
{
|
||||
label: 'Some label',
|
||||
secondaryLabel: 'Secondary label',
|
||||
WrapperComponent: ({ children }) => <button onClick={onClickFunction}>{children}</button>,
|
||||
WrapperComponent: ({ children }) => (
|
||||
<button onClick={onClickFunction}>{children}</button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
@@ -75,7 +91,11 @@ describe('<ComponentContextMenu />', () => {
|
||||
});
|
||||
|
||||
it('should close when hitting escape', async () => {
|
||||
const rendered = render(wrapInThemedTestApp(<HeaderActionMenu actionItems={[{ label: 'Some label' }]} />));
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<HeaderActionMenu actionItems={[{ label: 'Some label' }]} />,
|
||||
),
|
||||
);
|
||||
|
||||
expect(rendered.container.getAttribute('aria-hidden')).toBeNull();
|
||||
fireEvent.click(rendered.getByTestId('header-action-menu'));
|
||||
|
||||
@@ -31,12 +31,18 @@ describe('<HeaderLabel />', () => {
|
||||
});
|
||||
|
||||
it('should have value', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<HeaderLabel label="Label" value="Value" />));
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<HeaderLabel label="Label" value="Value" />),
|
||||
);
|
||||
expect(rendered.getByText('Value')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have a link', () => {
|
||||
const rendered = render(wrapInThemedTestApp(<HeaderLabel label="Label" value="Value" url="/test" />));
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<HeaderLabel label="Label" value="Value" url="/test" />,
|
||||
),
|
||||
);
|
||||
const anchor = rendered.container.querySelector('a');
|
||||
expect(rendered.getByText('Value')).toBeInTheDocument();
|
||||
expect(anchor.href).toBe('http://localhost/test');
|
||||
|
||||
@@ -50,7 +50,8 @@ class OwnerHeaderLabel extends Component {
|
||||
const notVerified = isBadSquad && (
|
||||
<Link href="https://spotify.stackenterprise.co/a/4412/23">
|
||||
<span className={classes.notVerified}>
|
||||
<StatusError style={{ position: 'relative', top: 2 }} /> Squad not verified!
|
||||
<StatusError style={{ position: 'relative', top: 2 }} /> Squad not
|
||||
verified!
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import OwnerHeaderLabel from './OwnerHeaderLabel';
|
||||
import { wrapInThemedTestApp } from '../../testUtils';
|
||||
|
||||
const properOwner = { id: 'tools', name: 'tools', type: 'squad' };
|
||||
const badOwner = { id: 'tools-xxx', name: 'tools-xxx' };
|
||||
|
||||
describe('<OwnerHeaderLabel />', () => {
|
||||
it('should have a label', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<OwnerHeaderLabel owner={properOwner} />),
|
||||
);
|
||||
expect(rendered.getByText('Owner')).toBeInTheDocument();
|
||||
expect(rendered.getByText('tools')).toBeInTheDocument();
|
||||
expect(rendered.queryByText('Squad not verified!')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have an org link', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<OwnerHeaderLabel owner={properOwner} />),
|
||||
);
|
||||
const anchor = rendered.container.querySelector('a');
|
||||
expect(anchor.href).toBe('http://localhost/org/tools');
|
||||
});
|
||||
|
||||
it('should have WARNING label', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<OwnerHeaderLabel owner={badOwner} />),
|
||||
);
|
||||
expect(rendered.getByText('Squad not verified!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have status error label', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<OwnerHeaderLabel owner={badOwner} />),
|
||||
);
|
||||
expect(rendered.getByLabelText('Status error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<OwnerHeaderLabel owner={{}} />),
|
||||
);
|
||||
expect(rendered.getByLabelText('Status error')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -17,11 +17,23 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from '@material-ui/core';
|
||||
import { Divider, ListItemText } from '@material-ui/core';
|
||||
import { Divider, ListItemText, withStyles } from '@material-ui/core';
|
||||
import { ListItem, ListItemIcon } from '@material-ui/core';
|
||||
import ArrowIcon from '@material-ui/icons/ArrowForward';
|
||||
import grey from '@material-ui/core/colors/grey';
|
||||
import Box from '@material-ui/core/Box';
|
||||
|
||||
export default class BottomLink extends Component {
|
||||
const styles = theme => ({
|
||||
root: {
|
||||
maxWidth: 'fit-content',
|
||||
padding: theme.spacing(2, 2, 2, 2.5)
|
||||
},
|
||||
boxTitle: {
|
||||
margin: 0,
|
||||
color: grey[900]
|
||||
}
|
||||
})
|
||||
class BottomLink extends Component {
|
||||
static propTypes = {
|
||||
link: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
@@ -29,19 +41,23 @@ export default class BottomLink extends Component {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { link, title, onClick } = this.props;
|
||||
const { link, title, onClick, classes } = this.props;
|
||||
return (
|
||||
<div>
|
||||
<Divider />
|
||||
<Link href={link} onClick={onClick} highlight="none">
|
||||
<ListItem>
|
||||
<ListItem className={classes.root}>
|
||||
<ListItemText>
|
||||
<Box className={classes.boxTitle} fontWeight="fontWeightBold" m={1}>{title}</Box>
|
||||
</ListItemText>
|
||||
<ListItemIcon>
|
||||
<ArrowIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText>{title}</ListItemText>
|
||||
</ListItem>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStyles(styles)(BottomLink);
|
||||
@@ -29,7 +29,17 @@ import BottomLink from './BottomLink';
|
||||
|
||||
import textContent from 'react-addons-text-content';
|
||||
|
||||
const BoldHeader = withStyles({ title: { fontWeight: '700' } })(CardHeader);
|
||||
const styles = theme => ({
|
||||
header: {
|
||||
padding: theme.spacing(2, 2, 2, 2.5)
|
||||
}
|
||||
});
|
||||
|
||||
const BoldHeader = withStyles({
|
||||
title: { fontWeight: '700' },
|
||||
subheader: { paddingTop: '2px' },
|
||||
})(CardHeader);
|
||||
|
||||
const CardActionsTopRight = withStyles({
|
||||
root: {
|
||||
display: 'inline-block',
|
||||
@@ -183,12 +193,16 @@ class InfoCard extends Component {
|
||||
>
|
||||
<ErrorBoundary slackChannel={slackChannel}>
|
||||
{title && (
|
||||
<BoldHeader
|
||||
title={title}
|
||||
subheader={subheader}
|
||||
style={{ display: 'inline-block', ...headerStyle }}
|
||||
{...headerProps}
|
||||
/>
|
||||
<>
|
||||
<BoldHeader
|
||||
className={classes.header}
|
||||
title={title}
|
||||
subheader={subheader}
|
||||
style={{ display: 'inline-block', ...headerStyle }}
|
||||
{...headerProps}
|
||||
/>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
{actionsTopRight && (
|
||||
<CardActionsTopRight>{actionsTopRight}</CardActionsTopRight>
|
||||
@@ -212,4 +226,4 @@ class InfoCard extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default InfoCard;
|
||||
export default withStyles(styles)(InfoCard);
|
||||
|
||||
@@ -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 InfoCard from '.';
|
||||
|
||||
const cardContentStyle = { height: '200px' };
|
||||
const linkInfo = { title: 'Go to XYZ Location', link: '#' };
|
||||
|
||||
export default {
|
||||
title: 'Information Card',
|
||||
component: InfoCard,
|
||||
};
|
||||
|
||||
export const Default = () => (
|
||||
<InfoCard title="Information Card">
|
||||
<div style={cardContentStyle} />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const Subhead = () => (
|
||||
<InfoCard title="Information Card" subheader="Subhead">
|
||||
<div style={cardContentStyle} />
|
||||
</InfoCard>
|
||||
);
|
||||
|
||||
export const LinkInFooter = () => (
|
||||
<InfoCard title="Information Card" deepLink={linkInfo}>
|
||||
<div style={cardContentStyle} />
|
||||
</InfoCard>
|
||||
);
|
||||
@@ -75,7 +75,7 @@ export const gradients: Record<string, Gradient> = {
|
||||
colors: ['#69B9FF', '#ACCEEC'],
|
||||
},
|
||||
teal: {
|
||||
colors: ['#1F8A77', 'rgba(155, 240, 225, 1.0)'],
|
||||
colors: ['#005E4D', '#9BF0E1'],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
|
||||
onClick={onClick}
|
||||
underline="none"
|
||||
>
|
||||
<div className={classes.iconContainer}>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
<Icon fontSize="small" />
|
||||
</div>
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
|
||||
@@ -33,8 +33,11 @@ describe('testUtils.Keyboard', () => {
|
||||
const rendered = render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input onChange={({ target: { value } }) => typed1.push(value)} />
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<input onChange={({ target: { value } }) => typed2.push(value)} autoFocus />
|
||||
<input
|
||||
onChange={({ target: { value } }) => typed2.push(value)}
|
||||
/* eslint-disable-next-line jsx-a11y/no-autofocus */
|
||||
autoFocus
|
||||
/>
|
||||
<input onChange={({ target: { value } }) => typed3.push(value)} />
|
||||
</form>,
|
||||
);
|
||||
@@ -64,9 +67,18 @@ describe('testUtils.Keyboard', () => {
|
||||
|
||||
const rendered = render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input defaultValue="1" onChange={({ target: { value } }) => typed1.push(value)} />
|
||||
<input defaultValue="2" onChange={({ target: { value } }) => typed2.push(value)} />
|
||||
<input defaultValue="3" onChange={({ target: { value } }) => typed3.push(value)} />
|
||||
<input
|
||||
defaultValue="1"
|
||||
onChange={({ target: { value } }) => typed1.push(value)}
|
||||
/>
|
||||
<input
|
||||
defaultValue="2"
|
||||
onChange={({ target: { value } }) => typed2.push(value)}
|
||||
/>
|
||||
<input
|
||||
defaultValue="3"
|
||||
onChange={({ target: { value } }) => typed3.push(value)}
|
||||
/>
|
||||
</form>,
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user