diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 10330976d7..07ed78743c 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -41,6 +41,7 @@ jobs:
node-version: ${{ matrix.node-version }}
- name: yarn install
run: yarn install --frozen-lockfile
+ - run: yarn tsc
- run: yarn build
- name: verify app and plugin creation on Windows
working-directory: ${{ runner.temp }}
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index c19583b653..d916141588 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -56,14 +56,20 @@ jobs:
- name: yarn install
run: yarn install --frozen-lockfile
- - name: verify plugin template
- run: yarn lerna -- run diff -- --check
-
- name: lint
run: yarn lerna -- run lint --since origin/master
- - name: build
- run: yarn build
+ - name: type checking and declarations
+ run: yarn tsc --incremental false
+
+ - name: build changed packages
+ if: ${{ steps.yarn-lock.outcome == 'success' }}
+ # Need to build all dependencies as well to be able to run tests later
+ run: yarn lerna -- run build --since origin/master --include-dependencies
+
+ - name: build all packages
+ if: ${{ steps.yarn-lock.outcome == 'failure' }}
+ run: yarn lerna -- run build
- name: test changed packages
if: ${{ steps.yarn-lock.outcome == 'success' }}
@@ -73,8 +79,11 @@ jobs:
if: ${{ steps.yarn-lock.outcome == 'failure' }}
run: yarn lerna -- run test -- --coverage
- - name: yarn bundle, if app was changed
- run: git diff --quiet origin/master HEAD -- yarn.lock packages/app packages/core || yarn bundle
+ - name: verify plugin template
+ run: yarn lerna -- run diff -- --check
+
+ - name: bundle example app
+ run: yarn bundle
- name: verify storybook
run: yarn workspace storybook build-storybook
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
index 6df9738bbc..a41f25c262 100644
--- a/.github/workflows/master.yml
+++ b/.github/workflows/master.yml
@@ -54,6 +54,9 @@ jobs:
- name: lint
run: yarn lerna -- run lint
+ - name: type checking and declarations
+ run: yarn tsc --incremental false
+
- name: build
run: yarn build
diff --git a/README.md b/README.md
index d2a0bbd3a9..fe73c1257c 100644
--- a/README.md
+++ b/README.md
@@ -62,11 +62,12 @@ To run a Backstage app, you will need to have the following installed:
- [NodeJS](https://nodejs.org/en/download/) - Active LTS Release, currently v12
- [yarn](https://classic.yarnpkg.com/en/docs/install)
-After cloning this repo, open a terminal window and start the web app using the following commands from the project root:
+After cloning this repo, open a terminal window and start the example app using the following commands from the project root:
```bash
-yarn install
-yarn start
+yarn install # Install dependencies
+
+yarn start # Start dev server, use --check to enable linting and type-checks
```
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.
diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md
new file mode 100644
index 0000000000..3b8b1c5315
--- /dev/null
+++ b/docs/getting-started/app-custom-theme.md
@@ -0,0 +1,47 @@
+# Custom App Themes
+
+Backstage ships with a default theme with a light and dark mode variant. The themes are provided as
+a part of the [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) package, which also includes
+utilities for customizing the default theme, or creating completely new themes.
+
+## Creating a Custom Theme
+
+The easiest way to create a new theme is to use the `createTheme` function exported by the [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) package. You can use it to override so basic parameters of the default theme such as the color palette and font.
+
+For example, you can create a new theme based on the default light theme like this:
+
+```ts
+import { createTheme, lightTheme } from '@backstage/theme';
+
+const myTheme = createTheme({
+ palette: lightTheme.palette,
+ fontFamily: 'Comic Sans MS',
+});
+```
+
+If you want more control over the theme, and for example customize font sizes and margins, you can use the lower-level `createThemeOverrides` function exported by [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) in combination with [createMuiTheme](https://material-ui.com/customization/theming/#createmuitheme-options-args-theme) from [@material-ui/core](https://www.npmjs.com/package/@material-ui/core). See the [@backstage/theme source](https://github.com/spotify/backstage/tree/master/packages/theme/src) and the implementation of the `createTheme` function for how this is done.
+
+You can also create a theme from scratch that matches the `BackstageTheme` type exported by [@backstage/theme](https://www.npmjs.com/package/@backstage/theme). See the [material-ui docs on theming](https://material-ui.com/customization/theming/) for more information about how that can be done.
+
+## Using your Custom Theme
+
+To add a custom theme to your Backstage app, you pass it as configuration to `createApp`.
+
+For example, adding the theme that we created in the previous section can be done like this:
+
+```ts
+import { createApp } from '@backstage/core';
+
+const app = createApp({
+ apis: ...,
+ plugins: ...,
+ themes: [{
+ id: 'my-theme',
+ title: 'My Custom Theme',
+ variant: 'light',
+ theme: myTheme,
+ }]
+})
+```
+
+Note that your list of custom themes overrides the default themes. If you still want to use the default themes, they are exported as `lightTheme` and `darkTheme` from [@backstage/theme](https://www.npmjs.com/package/@backstage/theme).
diff --git a/docs/getting-started/create-a-plugin.md b/docs/getting-started/create-a-plugin.md
index 7b8a7cf29b..64412c8b89 100644
--- a/docs/getting-started/create-a-plugin.md
+++ b/docs/getting-started/create-a-plugin.md
@@ -24,6 +24,15 @@ plugin directly by navigating to `http://localhost:3000/my-plugin`._
+You can also serve the plugin in isolation by running `yarn start` in the plugin directory. Or by using the yarn workspace command, for example:
+
+```bash
+yarn workspace @backstage/plugin-welcome start # Also supports --check
+```
+
+This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
+It is only meant for local development, and the setup for it can be found inside the plugin's `dev/` directory.
+
[Next Step - Structure of a plugin](structure-of-a-plugin.md)
[Back to Getting Started](README.md)
diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md
index 7f6e2bcb44..4dd2f7c9c7 100644
--- a/docs/getting-started/development-environment.md
+++ b/docs/getting-started/development-environment.md
@@ -1,5 +1,7 @@
# Development Environment
+## Serving the Example App
+
Open a terminal window and start the web app using the following commands from the project root:
```bash
@@ -21,6 +23,40 @@ You can now view example-app in the browser.
On Your Network: http://192.168.1.224:8080
```
+## Editor
+
+The Backstage development environment does not require any specific editor, but it is intended to be used with one that has built-in linting and type-checking. The development server does not include any checks by default, but they can be enabled using the `--check` flag. Note that using the flag may consume more system resources and slow things down.
+
+## Package Scripts
+
+There are many commands to be found in the root [package.json](package.json), here are some useful ones:
+
+```python
+yarn start # Start serving the example app, use --check to include type checks and linting
+
+yarn storybook # Start local storybook, useful for working on components in @backstage/core
+
+yarn workspace @backstage/plugin-welcome start # Serve welcome plugin only, also supports --check
+
+yarn tsc # Run typecheck, use --watch for watch mode
+
+yarn build # Build published versions of packages, depends on tsc
+
+yarn lint # lint packages that have changed since later commit on origin/master
+yarn lint:all # lint all packages
+
+yarn test # test packages that have changed since later commit on origin/master
+yarn test:all # test all packages
+
+yarn clean # Remove all output folders and @backstage/cli cache
+
+yarn bundle # Build a production bundle of the example app
+
+yarn diff # Make sure all plugins are up to date with the latest plugin template
+
+yarn create-plugin # Create a new plugin
+```
+
### (Optional)Try on Docker
Run the following commands if you have Docker environment
diff --git a/package.json b/package.json
index b3a02bb388..c5412f5855 100644
--- a/package.json
+++ b/package.json
@@ -6,12 +6,13 @@
},
"scripts": {
"start": "yarn workspace example-app start",
- "bundle": "yarn build && yarn workspace example-app bundle",
+ "bundle": "yarn workspace example-app bundle",
"build": "lerna run build",
- "clean": "lerna run clean",
+ "tsc": "tsc",
+ "clean": "backstage-cli clean && lerna run clean",
"diff": "lerna run diff --",
- "test": "yarn build && lerna run test --since origin/master -- --coverage",
- "test:all": "yarn build && lerna run test -- --coverage",
+ "test": "lerna run test --since origin/master -- --coverage",
+ "test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"docker-build": "yarn bundle && docker build . -t spotify/backstage",
@@ -19,6 +20,7 @@
"remove-plugin": "backstage-cli remove-plugin",
"release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi",
"lerna": "lerna",
+ "postinstall": "patch-package",
"storybook": "yarn workspace storybook start"
},
"workspaces": {
@@ -34,6 +36,7 @@
"identity-obj-proxy": "^3.0.0",
"lerna": "^3.20.2",
"lint-staged": "^10.1.0",
+ "patch-package": "^6.2.2",
"prettier": "^2.0.5"
},
"husky": {
diff --git a/packages/app/package.json b/packages/app/package.json
index e0c534ea45..f55594ce8e 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -30,7 +30,7 @@
"@testing-library/cypress": "^6.0.0",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 3945befb73..c26336c18b 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -19,27 +19,30 @@ import PropTypes from 'prop-types';
import { Link, makeStyles, Typography } from '@material-ui/core';
import HomeIcon from '@material-ui/icons/Home';
import ExploreIcon from '@material-ui/icons/Explore';
-import AccountCircle from '@material-ui/icons/AccountCircle';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
+
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
- SidebarSpacer,
SidebarDivider,
+ SidebarSearchField,
SidebarSpace,
+ SidebarUserBadge,
SidebarThemeToggle,
} from '@backstage/core';
const useSidebarLogoStyles = makeStyles({
root: {
- height: sidebarConfig.drawerWidthClosed,
+ width: sidebarConfig.drawerWidthClosed,
+ height: 3 * sidebarConfig.logoHeight,
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
+ marginBottom: -14,
},
logoContainer: {
width: sidebarConfig.drawerWidthClosed,
@@ -48,9 +51,9 @@ const useSidebarLogoStyles = makeStyles({
justifyContent: 'center',
},
title: {
- fontSize: 24,
+ fontSize: sidebarConfig.logoHeight,
fontWeight: 'bold',
- marginLeft: 22,
+ marginLeft: 20,
whiteSpace: 'nowrap',
color: '#fff',
},
@@ -61,7 +64,7 @@ const useSidebarLogoStyles = makeStyles({
const SidebarLogo: FC<{}> = () => {
const classes = useSidebarLogoStyles();
- const isOpen = useContext(SidebarContext);
+ const { isOpen } = useContext(SidebarContext);
return (
@@ -75,21 +78,30 @@ const SidebarLogo: FC<{}> = () => {
);
};
+const handleSearch = (query: string): void => {
+ // XXX (@koroeskohr): for testing purposes
+ // eslint-disable-next-line no-console
+ console.log(query);
+};
+
const Root: FC<{}> = ({ children }) => (
-
+
+ {/* Global nav, not org-specific */}
+ {/* End global nav */}
-
+
+
{children}
diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json
deleted file mode 100644
index 43ab6d4006..0000000000
--- a/packages/app/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "extends": "@spotify/web-scripts/config/tsconfig.json",
- "compilerOptions": {
- "target": "es5",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "strict": true,
- "forceConsistentCasingInFileNames": true,
- "module": "esnext",
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react",
- "incremental": false,
- "types": ["node", "jest"]
- },
- "include": ["src"]
-}
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index 7f5f05b212..3f37a0df4c 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -14,45 +14,68 @@
* limitations under the License.
*/
-const fs = require('fs');
+const fs = require('fs-extra');
const path = require('path');
-// If the package has it's own jest config, we use that instead. It will have to
-// manually extend @spotify/web-scripts/config/jest.config.js that is desired
-if (fs.existsSync('jest.config.js')) {
- module.exports = require(path.resolve('jest.config.js'));
-} else if (fs.existsSync('jest.config.ts')) {
- module.exports = require(path.resolve('jest.config.ts'));
-} else {
- const extraOptions = {
- modulePaths: [''],
- moduleNameMapper: {
- '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
- '.+\\.(png|jpg|ttf|woff|woff2)$': 'identity-obj-proxy',
- },
+async function getConfig() {
+ // If the package has it's own jest config, we use that instead.
+ if (await fs.pathExists('jest.config.js')) {
+ return require(path.resolve('jest.config.js'));
+ } else if (await fs.pathExists('jest.config.ts')) {
+ return require(path.resolve('jest.config.ts'));
+ }
+
+ const moduleNameMapper = {
+ '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
+ };
+
+ // Only point to src/ if we're not in CI, there we just build packages first anyway
+ if (!process.env.CI) {
+ const LernaProject = require('@lerna/project');
+ const project = new LernaProject(path.resolve('.'));
+ const packages = await project.getPackages();
+
+ // To avoid having to build all deps inside the monorepo before running tests,
+ // we point directory to src/ where applicable.
+ // For example, @backstage/core = /packages/core/src/index.ts is added to moduleNameMapper
+ for (const pkg of packages) {
+ const mainSrc = pkg.get('main:src');
+ if (mainSrc) {
+ moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
+ }
+ }
+ }
+
+ const options = {
+ rootDir: path.resolve('src'),
+ coverageDirectory: path.resolve('coverage'),
+ collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
+ moduleNameMapper,
+
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
// TODO: jest is working on module support, it's possible that we can remove this in the future
transform: {
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
+ '\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'),
},
- // Default behaviour is to not apply transforms for node_modules, but we still want to tranform .esm.js files
+
+ // Default behaviour is to not apply transforms for node_modules, but we still want
+ // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'],
};
// Use src/setupTests.ts as the default location for configuring test env
if (fs.existsSync('src/setupTests.ts')) {
- extraOptions.setupFilesAfterEnv = ['/setupTests.ts'];
+ options.setupFilesAfterEnv = ['/setupTests.ts'];
}
- module.exports = {
- // We base the jest config on web-scripts, it's pretty flat so we skip any complex merging of config objects
- // Config can be found here: https://github.com/spotify/web-scripts/blob/master/packages/web-scripts/config/jest.config.js
- ...require('@spotify/web-scripts/config/jest.config.js'),
-
- ...extraOptions,
+ return {
+ ...options,
// If the package has a jest object in package.json we merge that config in. This is the recommended
// location for configuring tests.
...require(path.resolve('package.json')).jest,
};
}
+
+module.exports = getConfig();
diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json
index f905dcb771..652e990387 100644
--- a/packages/cli/config/tsconfig.json
+++ b/packages/cli/config/tsconfig.json
@@ -4,9 +4,11 @@
"compilerOptions": {
"allowJs": true,
"noEmit": false,
+ "emitDeclarationOnly": true,
"incremental": true,
"target": "ES2019",
"module": "ESNext",
+ "removeComments": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7183320136..c3d768eec2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -29,15 +29,19 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
+ "@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
"@rollup/plugin-commonjs": "^11.0.2",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@spotify/web-scripts": "^6.0.0",
+ "@sucrase/webpack-loader": "^2.0.0",
+ "bfj": "^7.0.2",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
"commander": "^4.1.1",
+ "css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"eslint-plugin-import": "^2.20.2",
@@ -50,27 +54,37 @@
"jest": "^25.1.0",
"jest-css-modules": "^2.1.0",
"jest-esm-transformer": "^1.0.0",
+ "mini-css-extract-plugin": "^0.9.0",
"ora": "^4.0.3",
+ "raw-loader": "^4.0.1",
"react": "^16.0.0",
"react-dev-utils": "^10.2.0",
- "react-scripts": "^3.4.1",
+ "react-hot-loader": "^4.12.21",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^6.0.0",
"rollup": "^2.3.2",
+ "rollup-plugin-dts": "^1.4.6",
+ "rollup-plugin-esbuild": "^1.4.1",
"rollup-plugin-image-files": "^1.4.2",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.26.0",
+ "style-loader": "^1.2.1",
+ "sucrase": "^3.14.1",
"tar": "^6.0.1",
"ts-loader": "^7.0.4",
+ "url-loader": "^4.1.0",
"webpack": "^4.41.6",
- "webpack-dev-server": "^3.10.3"
+ "webpack-dev-server": "^3.10.3",
+ "yml-loader": "^2.1.0",
+ "yn": "^4.0.0"
},
"devDependencies": {
"@types/diff": "^4.0.2",
"@types/fs-extra": "^8.1.0",
"@types/html-webpack-plugin": "^3.2.2",
"@types/inquirer": "^6.5.0",
+ "@types/mini-css-extract-plugin": "^0.9.1",
"@types/node": "^13.7.2",
"@types/ora": "^3.2.0",
"@types/react-dev-utils": "^9.0.4",
diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts
index 362f79a7fa..c654baa439 100644
--- a/packages/cli/src/commands/app/build.ts
+++ b/packages/cli/src/commands/app/build.ts
@@ -14,15 +14,12 @@
* limitations under the License.
*/
-import { run } from '../../lib/run';
+import { buildBundle } from '../../lib/bundler';
+import { Command } from 'commander';
-export default async () => {
- const args = ['build'];
-
- await run('react-scripts', args, {
- env: {
- EXTEND_ESLINT: 'true',
- SKIP_PREFLIGHT_CHECK: 'true',
- },
+export default async (cmd: Command) => {
+ await buildBundle({
+ entry: 'src/index',
+ statsJsonEnabled: cmd.stats,
});
};
diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts
index 1d0ccfc465..182e338287 100644
--- a/packages/cli/src/commands/app/serve.ts
+++ b/packages/cli/src/commands/app/serve.ts
@@ -14,20 +14,14 @@
* limitations under the License.
*/
-import { run } from '../../lib/run';
-import { createLogFunc } from '../../lib/logging';
-import { watchDeps } from '../../lib/watchDeps';
+import { serveBundle } from '../../lib/bundler';
+import { Command } from 'commander';
-export default async () => {
- // Start dynamic watch and build of dependencies, then serve the app
- await watchDeps({ build: true });
-
- await run('react-scripts', ['start'], {
- env: {
- EXTEND_ESLINT: 'true',
- SKIP_PREFLIGHT_CHECK: 'true',
- },
- // We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
- stdoutLogFunc: createLogFunc(process.stdout),
+export default async (cmd: Command) => {
+ const waitForExit = await serveBundle({
+ entry: 'src/index',
+ checksEnabled: cmd.check,
});
+
+ await waitForExit();
};
diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts
index bfb78762d9..e28cb376b9 100644
--- a/packages/cli/src/commands/create-app/createApp.ts
+++ b/packages/cli/src/commands/create-app/createApp.ts
@@ -21,7 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import os from 'os';
-import { Task, templatingTask } from '../../lib/tasks';
+import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
const exec = promisify(execCb);
@@ -57,19 +57,22 @@ async function cleanUp(tempDir: string) {
});
}
-async function buildApp(appFolder: string) {
- const commands = ['yarn install', 'yarn build'];
- for (const command of commands) {
- await Task.forItem('executing', command, async () => {
- process.chdir(appFolder);
+async function buildApp(appDir: string) {
+ const runCmd = async (cmd: string) => {
+ await Task.forItem('executing', cmd, async () => {
+ process.chdir(appDir);
- await exec(command).catch((error) => {
+ await exec(cmd).catch((error) => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
- throw new Error(`Could not execute command ${chalk.cyan(command)}`);
+ throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
});
});
- }
+ };
+
+ await installWithLocalDeps(appDir);
+ await runCmd('yarn tsc');
+ await runCmd('yarn build');
}
export async function moveApp(
@@ -86,44 +89,6 @@ export async function moveApp(
});
}
-async function addPackageResolutions(appDir: string) {
- const pkgJsonPath = resolvePath(appDir, 'package.json');
- const pkgJson = await fs.readJson(pkgJsonPath);
-
- pkgJson.resolutions = pkgJson.resolutions || {};
- pkgJson.dependencies = pkgJson.dependencies || {};
-
- const depNames = [
- 'cli',
- 'core',
- 'dev-utils',
- 'test-utils',
- 'test-utils-core',
- 'theme',
- ];
-
- for (const name of depNames) {
- await Task.forItem(
- 'adding',
- `@backstage/${name} link to package.json`,
- async () => {
- const pkgPath = paths.resolveOwnRoot('packages', name);
- // Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
- pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
- pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
-
- await fs
- .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
- .catch((error) => {
- throw new Error(
- `Failed to add resolutions to package.json: ${error.message}`,
- );
- });
- },
- );
- }
-}
-
export default async () => {
const questions: Question[] = [
{
@@ -164,12 +129,6 @@ export default async () => {
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
- // e2e testing needs special treatment
- if (process.env.BACKSTAGE_E2E_CLI_TEST) {
- Task.section('Linking packages locally for e2e tests');
- await addPackageResolutions(appDir);
- }
-
Task.section('Building the app');
await buildApp(appDir);
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index b0bf1d85b5..1441b16d05 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -28,7 +28,7 @@ import {
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
-import { Task, templatingTask } from '../../lib/tasks';
+import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, id: string) {
@@ -141,7 +141,9 @@ async function cleanUp(tempDir: string) {
}
async function buildPlugin(pluginFolder: string) {
- const commands = ['yarn install', 'yarn build'];
+ await installWithLocalDeps(paths.targetRoot);
+
+ const commands = ['yarn tsc', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts
index fcda3f424b..561d79ed55 100644
--- a/packages/cli/src/commands/plugin/build.ts
+++ b/packages/cli/src/commands/plugin/build.ts
@@ -14,85 +14,8 @@
* limitations under the License.
*/
-import { rollup, watch, OutputOptions } from 'rollup';
-import { Command } from 'commander';
-import chalk from 'chalk';
-import { withCache, getDefaultCacheOptions } from '../../lib/buildCache';
-import { paths } from '../../lib/paths';
-import conf from './rollup.config';
+import { buildPackage } from '../../lib/packager';
-function logError(error: any) {
- console.log('');
-
- if (error.code === 'PLUGIN_ERROR') {
- // typescript2 plugin has a complete message with all codeframes
- if (error.plugin === 'rpt2') {
- console.log(error.message);
- } else {
- // Log which plugin is causing errors to make it easier to identity.
- // If we see these in logs we likely want to provide some custom error
- // output for those plugins too.
- console.log(`(plugin ${error.plugin}) ${error}`);
- }
- } else {
- // Generic rollup errors, log what's available
- if (error.loc) {
- const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
- const pos = `${error.loc.line}:${error.loc.column}`;
- console.log(`${file} [${pos}]`);
- } else if (error.id) {
- console.log(paths.resolveTarget(error.id));
- }
-
- console.log(String(error));
-
- if (error.url) {
- console.log(chalk.cyan(error.url));
- }
-
- if (error.frame) {
- console.log(chalk.dim(error.frame));
- }
- }
-}
-
-export default async (cmd: Command) => {
- if (cmd.watch) {
- // We're not resolving this promise because watch() doesn't have any exit event.
- // Instead we just wait until the user sends an interrupt signal.
- await new Promise(() => {
- const watcher = watch(conf);
- watcher.on('event', (event) => {
- // START — the watcher is (re)starting
- // BUNDLE_START — building an individual bundle
- // BUNDLE_END — finished building a bundle
- // END — finished building all bundles
- // ERROR — encountered an error while bundling
-
- if (event.code === 'ERROR') {
- logError(event.error);
- } else if (event.code === 'BUNDLE_START') {
- console.log(chalk.dim(`building ${event.input}`));
- } else if (event.code === 'BUNDLE_END') {
- const { input, duration } = event;
- const s = (duration / 1000).toFixed(1);
- console.log(chalk.green(`built ${input} in ${s}s`));
- }
- });
- });
- }
-
- await withCache(getDefaultCacheOptions(), async () => {
- try {
- const bundle = await rollup({
- input: conf.input,
- plugins: conf.plugins,
- });
- await bundle.generate(conf.output as OutputOptions);
- await bundle.write(conf.output as OutputOptions);
- } catch (error) {
- logError(error);
- process.exit(1);
- }
- });
+export default async () => {
+ await buildPackage();
};
diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts
deleted file mode 100644
index 36b43bf5a7..0000000000
--- a/packages/cli/src/commands/plugin/rollup.config.ts
+++ /dev/null
@@ -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 peerDepsExternal from 'rollup-plugin-peer-deps-external';
-import typescript from 'rollup-plugin-typescript2';
-import commonjs from '@rollup/plugin-commonjs';
-import resolve from '@rollup/plugin-node-resolve';
-import postcss from 'rollup-plugin-postcss';
-import imageFiles from 'rollup-plugin-image-files';
-import json from '@rollup/plugin-json';
-import { RollupWatchOptions } from 'rollup';
-import { paths } from '../../lib/paths';
-
-export default {
- input: 'src/index.ts',
- output: {
- file: 'dist/index.esm.js',
- format: 'module',
- },
- plugins: [
- peerDepsExternal({
- includeDependencies: true,
- }),
- resolve({
- mainFields: ['browser', 'module', 'main'],
- }),
- commonjs({
- include: ['node_modules/**', '../../node_modules/**'],
- exclude: ['**/*.stories.*', '**/*.test.*'],
- }),
- postcss(),
- imageFiles(),
- json(),
- typescript({
- include: `${paths.resolveTarget('src')}/**/*.{js,jsx,ts,tsx}`,
- tsconfigOverride: {
- // The dev folder is for the local plugin serve, ignore it in the build
- // If we don't do this we get a folder structure similar to dist/{src,dev}/...
- exclude: ['dev'],
- compilerOptions: {
- // Use absolute path to src dir as root for declarations, relying on the default
- // seems to produce declaration maps that are relative to dist/ instead of src/
- // Using a relative path like ../src doesn't work either becaus it will be used as is in subdirs.
- sourceRoot: paths.resolveTarget('src'),
- },
- },
- clean: true,
- }),
- ],
-} as RollupWatchOptions;
diff --git a/packages/cli/src/commands/plugin/serve/index.ts b/packages/cli/src/commands/plugin/serve.ts
similarity index 70%
rename from packages/cli/src/commands/plugin/serve/index.ts
rename to packages/cli/src/commands/plugin/serve.ts
index 9bdd46dc84..5700992644 100644
--- a/packages/cli/src/commands/plugin/serve/index.ts
+++ b/packages/cli/src/commands/plugin/serve.ts
@@ -14,14 +14,14 @@
* limitations under the License.
*/
-import { startDevServer } from './server';
-import { watchDeps } from '../../../lib/watchDeps';
+import { serveBundle } from '../../lib/bundler';
+import { Command } from 'commander';
-export default async () => {
- await watchDeps({ build: true });
+export default async (cmd: Command) => {
+ const waitForExit = await serveBundle({
+ entry: 'dev/index',
+ checksEnabled: cmd.check,
+ });
- await startDevServer();
-
- // Wait for interrupt signal
- await new Promise(() => {});
+ await waitForExit();
};
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index cf89a3c47d..f02bf6524f 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -30,11 +30,13 @@ const main = (argv: string[]) => {
program
.command('app:build')
.description('Build an app for a production release')
+ .option('--stats', 'Write bundle stats to output directory')
.action(actionHandler(() => require('./commands/app/build')));
program
.command('app:serve')
.description('Serve an app for local development')
+ .option('--check', 'Enable type checking and linting')
.action(actionHandler(() => require('./commands/app/serve')));
program
@@ -53,13 +55,13 @@ const main = (argv: string[]) => {
program
.command('plugin:build')
- .option('--watch', 'Enable watch mode')
.description('Build a plugin')
.action(actionHandler(() => require('./commands/plugin/build')));
program
.command('plugin:serve')
.description('Serves the dev/ folder of a plugin')
+ .option('--check', 'Enable type checking and linting')
.action(actionHandler(() => require('./commands/plugin/serve')));
program
diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts
new file mode 100644
index 0000000000..f24da5b0c8
--- /dev/null
+++ b/packages/cli/src/lib/bundler/bundle.ts
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import yn from 'yn';
+import fs from 'fs-extra';
+import { resolve as resolvePath } from 'path';
+import webpack from 'webpack';
+import {
+ measureFileSizesBeforeBuild,
+ printFileSizesAfterBuild,
+} from 'react-dev-utils/FileSizeReporter';
+import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
+import { createConfig } from './config';
+import { BuildOptions } from './types';
+import { resolveBundlingPaths } from './paths';
+import chalk from 'chalk';
+
+// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
+const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
+const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
+
+export async function buildBundle(options: BuildOptions) {
+ const { statsJsonEnabled } = options;
+
+ const paths = resolveBundlingPaths(options);
+ const config = createConfig(paths, {
+ ...options,
+ checksEnabled: false,
+ isDev: false,
+ });
+ const compiler = webpack(config);
+
+ const isCi = yn(process.env.CI, { default: false });
+
+ const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
+ await fs.emptyDir(paths.targetDist);
+
+ const { stats } = await build(compiler, isCi).catch((error) => {
+ console.log(chalk.red('Failed to compile.\n'));
+ throw new Error(`Failed to compile.\n${error.message || error}`);
+ });
+
+ if (statsJsonEnabled) {
+ // No @types/bfj
+ await require('bfj').write(
+ resolvePath(paths.targetDist, 'bundle-stats.json'),
+ stats.toJson(),
+ );
+ }
+
+ printFileSizesAfterBuild(
+ stats,
+ previousFileSizes,
+ paths.targetDist,
+ WARN_AFTER_BUNDLE_GZIP_SIZE,
+ WARN_AFTER_CHUNK_GZIP_SIZE,
+ );
+}
+
+async function build(compiler: webpack.Compiler, isCi: boolean) {
+ const stats = await new Promise((resolve, reject) => {
+ compiler.run((err, buildStats) => {
+ if (err) {
+ if (err.message) {
+ const { errors } = formatWebpackMessages({
+ errors: [err.message],
+ warnings: new Array(),
+ } as webpack.Stats.ToJsonOutput);
+
+ throw new Error(errors[0]);
+ } else {
+ reject(err);
+ }
+ } else {
+ resolve(buildStats);
+ }
+ });
+ });
+
+ const { errors, warnings } = formatWebpackMessages(
+ stats.toJson({ all: false, warnings: true, errors: true }),
+ );
+
+ if (errors.length) {
+ // Only keep the first error. Others are often indicative
+ // of the same problem, but confuse the reader with noise.
+ throw new Error(errors[0]);
+ }
+ if (isCi && warnings.length) {
+ console.log(
+ chalk.yellow(
+ '\nTreating warnings as errors because process.env.CI = true.\n',
+ ),
+ );
+ throw new Error(warnings.join('\n\n'));
+ }
+
+ return { stats };
+}
diff --git a/packages/cli/src/commands/plugin/serve/config.ts b/packages/cli/src/lib/bundler/config.ts
similarity index 54%
rename from packages/cli/src/commands/plugin/serve/config.ts
rename to packages/cli/src/lib/bundler/config.ts
index 47d7918270..a35c22e3bd 100644
--- a/packages/cli/src/commands/plugin/serve/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -15,91 +15,28 @@
*/
import webpack from 'webpack';
-import HtmlWebpackPlugin from 'html-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
-import { Paths } from './paths';
+import { BundlingPaths } from './paths';
+import { transforms } from './transforms';
+import { optimization } from './optimization';
+import { BundlingOptions } from './types';
// import checkRequiredFiles from 'react-dev-utils/checkRequiredFiles';
// import ModuleNotFoundPlugin from 'react-dev-utils/ModuleNotFoundPlugin';
// import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
-export function createConfig(paths: Paths): webpack.Configuration {
- return {
- mode: 'development',
- profile: false,
- bail: false,
- devtool: 'cheap-module-eval-source-map',
- context: paths.targetPath,
- entry: [
- `${require.resolve('webpack-dev-server/client')}?/`,
- require.resolve('webpack/hot/dev-server'),
- paths.targetDevEntry,
- ],
- resolve: {
- extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
- plugins: [
- new ModuleScopePlugin(
- [paths.targetSrc, paths.targetDev],
- [paths.targetPackageJson],
- ),
- ],
- },
- module: {
- rules: [
- {
- test: /\.(tsx?|jsx?|mjs)$/,
- enforce: 'pre',
- include: [paths.targetSrc, paths.targetDev],
- use: {
- loader: 'eslint-loader',
- options: {
- emitWarning: true,
- },
- },
- },
- {
- test: /\.(tsx?|jsx?|mjs)$/,
- include: [paths.targetSrc, paths.targetDev],
- exclude: /node_modules/,
- loader: 'ts-loader',
- options: {
- // disable type checker - handled by ForkTsCheckerWebpackPlugin
- transpileOnly: true,
- },
- },
- {
- test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
- loader: 'url-loader',
- include: paths.targetAssets,
- options: {
- limit: 10000,
- name: 'static/media/[name].[hash:8].[ext]',
- },
- },
- {
- test: /\.ya?ml$/,
- use: 'yml-loader',
- },
- {
- include: /\.(md)$/,
- use: 'raw-loader',
- },
- {
- test: /\.css$/i,
- use: ['style-loader', 'css-loader'],
- },
- ],
- },
- output: {
- publicPath: '/',
- filename: 'bundle.js',
- },
- plugins: [
- new HtmlWebpackPlugin({
- template: paths.targetHtml,
- }),
+export function createConfig(
+ paths: BundlingPaths,
+ options: BundlingOptions,
+): webpack.Configuration {
+ const { checksEnabled, isDev } = options;
+
+ const { plugins, loaders } = transforms(paths, options);
+
+ if (checksEnabled) {
+ plugins.push(
new ForkTsCheckerWebpackPlugin({
tsconfig: paths.targetTsConfig,
eslint: true,
@@ -111,8 +48,45 @@ export function createConfig(paths: Paths): webpack.Configuration {
},
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
- new webpack.HotModuleReplacementPlugin(),
- ],
+ );
+ }
+
+ return {
+ mode: isDev ? 'development' : 'production',
+ profile: false,
+ bail: false,
+ performance: {
+ hints: false, // we check the gzip size instead
+ },
+ devtool: isDev ? 'cheap-module-eval-source-map' : 'source-map',
+ context: paths.targetPath,
+ entry: [require.resolve('react-hot-loader/patch'), paths.targetEntry],
+ resolve: {
+ extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
+ mainFields: ['main:src', 'browser', 'module', 'main'],
+ plugins: [
+ new ModuleScopePlugin(
+ [paths.targetSrc, paths.targetDev],
+ [paths.targetPackageJson],
+ ),
+ ],
+ alias: {
+ 'react-dom': '@hot-loader/react-dom',
+ },
+ },
+ module: {
+ rules: loaders,
+ },
+ output: {
+ path: paths.targetDist,
+ publicPath: '/',
+ filename: isDev ? '[name].js' : '[name].[hash:8].js',
+ chunkFilename: isDev
+ ? '[name].chunk.js'
+ : '[name].[chunkhash:8].chunk.js',
+ },
+ optimization: optimization(options),
+ plugins,
node: {
module: 'empty',
dgram: 'empty',
diff --git a/packages/cli/src/lib/bundler/index.ts b/packages/cli/src/lib/bundler/index.ts
new file mode 100644
index 0000000000..cdfb5f3897
--- /dev/null
+++ b/packages/cli/src/lib/bundler/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { buildBundle } from './bundle';
+export { serveBundle } from './server';
diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts
new file mode 100644
index 0000000000..03168b376d
--- /dev/null
+++ b/packages/cli/src/lib/bundler/optimization.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Options } from 'webpack';
+import { BundlingOptions } from './types';
+
+export const optimization = (
+ options: BundlingOptions,
+): Options.Optimization => {
+ const { isDev } = options;
+
+ return {
+ minimize: !isDev,
+ runtimeChunk: 'single',
+ splitChunks: {
+ automaticNameDelimiter: '-',
+ cacheGroups: {
+ default: false,
+ // Put all vendor code needed for initial page load in individual files if they're big
+ // enough, if they're smaller they end up in the main
+ packages: {
+ chunks: 'initial',
+ test: /[\\/]node_modules[\\/]/,
+ name(module: any) {
+ // get the name. E.g. node_modules/packageName/not/this/part.js
+ // or node_modules/packageName
+ const packageName = module.context.match(
+ /[\\/]node_modules[\\/](.*?)([\\/]|$)/,
+ )[1];
+
+ // npm package names are URL-safe, but some servers don't like @ symbols
+ return packageName.replace('@', '');
+ },
+ filename: isDev
+ ? 'module-[name].js'
+ : 'module-[name].[chunkhash:8].js',
+ priority: 10,
+ minSize: 100000,
+ minChunks: 1,
+ maxAsyncRequests: Infinity,
+ maxInitialRequests: Infinity,
+ } as any, // filename is not included in type, but we need it
+ // Group together the smallest modules
+ vendor: {
+ chunks: 'initial',
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendor',
+ priority: 5,
+ enforce: true,
+ },
+ },
+ },
+ };
+};
diff --git a/packages/cli/src/commands/plugin/serve/paths.ts b/packages/cli/src/lib/bundler/paths.ts
similarity index 72%
rename from packages/cli/src/commands/plugin/serve/paths.ts
rename to packages/cli/src/lib/bundler/paths.ts
index 56c3f6ffb7..ee24e9f4d2 100644
--- a/packages/cli/src/commands/plugin/serve/paths.ts
+++ b/packages/cli/src/lib/bundler/paths.ts
@@ -15,9 +15,16 @@
*/
import { existsSync } from 'fs';
-import { paths } from '../../../lib/paths';
+import { paths } from '../paths';
+
+export type BundlingPathsOptions = {
+ // bundle entrypoint, e.g. 'src/index'
+ entry: string;
+};
+
+export function resolveBundlingPaths(options: BundlingPathsOptions) {
+ const { entry } = options;
-export function getPaths() {
const resolveTargetModule = (path: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
const filePath = paths.resolveTarget(`${path}.${ext}`);
@@ -28,7 +35,7 @@ export function getPaths() {
return paths.resolveTarget(`${path}.js`);
};
- let targetHtml = paths.resolveTarget('dev/index.html');
+ let targetHtml = paths.resolveTarget(`${entry}.html`);
if (!existsSync(targetHtml)) {
targetHtml = paths.resolveOwn('templates/serve_index.html');
}
@@ -36,14 +43,15 @@ export function getPaths() {
return {
targetHtml,
targetPath: paths.resolveTarget('.'),
+ targetDist: paths.resolveTarget('dist'),
targetAssets: paths.resolveTarget('assets'),
targetSrc: paths.resolveTarget('src'),
targetDev: paths.resolveTarget('dev'),
- targetDevEntry: resolveTargetModule('dev/index'),
- targetTsConfig: paths.resolveTarget('tsconfig.json'),
+ targetEntry: resolveTargetModule(entry),
+ targetTsConfig: paths.resolveTargetRoot('tsconfig.json'),
targetNodeModules: paths.resolveTarget('node_modules'),
targetPackageJson: paths.resolveTarget('package.json'),
};
}
-export type Paths = ReturnType;
+export type BundlingPaths = ReturnType;
diff --git a/packages/cli/src/commands/plugin/serve/server.ts b/packages/cli/src/lib/bundler/server.ts
similarity index 62%
rename from packages/cli/src/commands/plugin/serve/server.ts
rename to packages/cli/src/lib/bundler/server.ts
index 066f741406..3aaf5968f5 100644
--- a/packages/cli/src/commands/plugin/serve/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -14,33 +14,37 @@
* limitations under the License.
*/
+import yn from 'yn';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
-import { getPaths } from './paths';
import { createConfig } from './config';
+import { ServeOptions } from './types';
+import { resolveBundlingPaths } from './paths';
-export async function startDevServer() {
+export async function serveBundle(options: ServeOptions) {
const host = process.env.HOST ?? '0.0.0.0';
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
const port = await choosePort(host, defaultPort);
if (!port) {
- return;
+ throw new Error(`Invalid or no port set: '${port}'`);
}
- const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
+ const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
const urls = prepareUrls(protocol, host, port);
- const paths = getPaths();
- const config = createConfig(paths);
+ const paths = resolveBundlingPaths(options);
+ const config = createConfig(paths, { ...options, isDev: true });
const compiler = webpack(config);
+
const server = new WebpackDevServer(compiler, {
hot: true,
publicPath: '/',
historyApiFallback: true,
- quiet: true,
+ clientLogLevel: 'warning',
+ stats: 'errors-warnings',
https: protocol === 'https',
host,
port,
@@ -57,4 +61,19 @@ export async function startDevServer() {
resolve();
});
});
+
+ const waitForExit = async () => {
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
+ process.on(signal, () => {
+ server.close();
+ // exit instead of resolve. The process is shutting down and resolving a promise here logs an error
+ process.exit();
+ });
+ }
+
+ // Block indefinitely and wait for the interrupt signal
+ return new Promise(() => {});
+ };
+
+ return waitForExit;
}
diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts
new file mode 100644
index 0000000000..0856b2fc9d
--- /dev/null
+++ b/packages/cli/src/lib/bundler/transforms.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import webpack, { Module, Plugin } from 'webpack';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import MiniCssExtractPlugin from 'mini-css-extract-plugin';
+import { BundlingOptions } from './types';
+import { BundlingPaths } from './paths';
+
+type Transforms = {
+ loaders: Module['rules'];
+ plugins: Plugin[];
+};
+
+export const transforms = (
+ paths: BundlingPaths,
+ options: BundlingOptions,
+): Transforms => {
+ const { isDev } = options;
+
+ const loaders = [
+ {
+ test: /\.(tsx?)$/,
+ exclude: /node_modules/,
+ loader: require.resolve('@sucrase/webpack-loader'),
+ options: {
+ transforms: ['typescript', 'jsx', 'react-hot-loader'],
+ },
+ },
+ {
+ test: /\.(jsx?|mjs)$/,
+ exclude: /node_modules/,
+ loader: require.resolve('@sucrase/webpack-loader'),
+ options: {
+ transforms: ['jsx', 'react-hot-loader'],
+ },
+ },
+ {
+ test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
+ loader: require.resolve('url-loader'),
+ options: {
+ limit: 10000,
+ name: 'static/media/[name].[hash:8].[ext]',
+ },
+ },
+ {
+ test: /\.ya?ml$/,
+ use: require.resolve('yml-loader'),
+ },
+ {
+ include: /\.(md)$/,
+ use: require.resolve('raw-loader'),
+ },
+ {
+ test: /\.css$/i,
+ use: [
+ isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader,
+ {
+ loader: require.resolve('css-loader'),
+ options: {
+ sourceMap: true,
+ },
+ },
+ ],
+ },
+ ];
+
+ const plugins = new Array();
+
+ plugins.push(
+ new HtmlWebpackPlugin({
+ template: paths.targetHtml,
+ }),
+ );
+
+ if (isDev) {
+ plugins.push(new webpack.HotModuleReplacementPlugin());
+ } else {
+ plugins.push(
+ new MiniCssExtractPlugin({
+ filename: '[name].[contenthash:8].css',
+ chunkFilename: '[name].[id].[contenthash:8].css',
+ }),
+ );
+ }
+
+ return { loaders, plugins };
+};
diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts
new file mode 100644
index 0000000000..4182226d69
--- /dev/null
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BundlingPathsOptions } from './paths';
+
+export type BundlingOptions = {
+ checksEnabled: boolean;
+ isDev: boolean;
+};
+
+export type ServeOptions = BundlingPathsOptions & {
+ checksEnabled: boolean;
+};
+
+export type BuildOptions = BundlingPathsOptions & {
+ statsJsonEnabled: boolean;
+};
diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/packager/config.ts
new file mode 100644
index 0000000000..df91f8067a
--- /dev/null
+++ b/packages/cli/src/lib/packager/config.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'fs-extra';
+import { relative as relativePath } from 'path';
+import peerDepsExternal from 'rollup-plugin-peer-deps-external';
+import commonjs from '@rollup/plugin-commonjs';
+import resolve from '@rollup/plugin-node-resolve';
+import postcss from 'rollup-plugin-postcss';
+import esbuild from 'rollup-plugin-esbuild';
+import imageFiles from 'rollup-plugin-image-files';
+import dts from 'rollup-plugin-dts';
+import json from '@rollup/plugin-json';
+import { RollupOptions } from 'rollup';
+
+import { paths } from '../paths';
+
+export const makeConfigs = async (): Promise => {
+ const typesInput = paths.resolveTargetRoot(
+ 'dist',
+ relativePath(paths.targetRoot, paths.targetDir),
+ 'src/index.d.ts',
+ );
+
+ const declarationsExist = await fs.pathExists(typesInput);
+ if (!declarationsExist) {
+ const path = relativePath(paths.targetDir, typesInput);
+ throw new Error(
+ `No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`,
+ );
+ }
+
+ return [
+ {
+ input: 'src/index.ts',
+ output: {
+ file: 'dist/index.esm.js',
+ format: 'module',
+ },
+ plugins: [
+ peerDepsExternal({
+ includeDependencies: true,
+ }),
+ resolve({
+ mainFields: ['browser', 'module', 'main'],
+ }),
+ commonjs({
+ include: ['node_modules/**', '../../node_modules/**'],
+ exclude: ['**/*.stories.*', '**/*.test.*'],
+ }),
+ postcss(),
+ imageFiles(),
+ json(),
+ esbuild({
+ target: 'es2019',
+ }),
+ ],
+ },
+ {
+ input: typesInput,
+ output: {
+ file: 'dist/index.d.ts',
+ format: 'es',
+ },
+ plugins: [dts()],
+ },
+ ];
+};
diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts
new file mode 100644
index 0000000000..e639599af9
--- /dev/null
+++ b/packages/cli/src/lib/packager/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { buildPackage } from './packager';
diff --git a/packages/cli/src/lib/packager/packager.ts b/packages/cli/src/lib/packager/packager.ts
new file mode 100644
index 0000000000..d3e88d8c3d
--- /dev/null
+++ b/packages/cli/src/lib/packager/packager.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { rollup, RollupOptions } from 'rollup';
+import chalk from 'chalk';
+import { relative as relativePath } from 'path';
+import { paths } from '../paths';
+import { makeConfigs } from './config';
+
+function formatErrorMessage(error: any) {
+ let msg = '';
+
+ if (error.code === 'PLUGIN_ERROR') {
+ if (error.plugin === 'esbuild') {
+ msg += `${error.message}\n\n`;
+ for (const { text, location } of error.errors) {
+ const { line, column } = location;
+ const path = relativePath(paths.targetDir, error.id);
+ const loc = chalk.cyan(`${path}:${line}:${column}`);
+
+ if (text === 'Unexpected "<"' && error.id.endsWith('.js')) {
+ msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`;
+ } else {
+ msg += `${loc}: ${text}`;
+ }
+ }
+ } else {
+ // Log which plugin is causing errors to make it easier to identity.
+ // If we see these in logs we likely want to provide some custom error
+ // output for those plugins too.
+ msg += `(plugin ${error.plugin}) ${error}\n`;
+ }
+ } else {
+ // Generic rollup errors, log what's available
+ if (error.loc) {
+ const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
+ const pos = `${error.loc.line}:${error.loc.column}`;
+ msg += `${file} [${pos}]\n`;
+ } else if (error.id) {
+ msg += `${paths.resolveTarget(error.id)}\n`;
+ }
+
+ msg += `${error}\n`;
+
+ if (error.url) {
+ msg += `${chalk.cyan(error.url)}\n`;
+ }
+
+ if (error.frame) {
+ msg += `${chalk.dim(error.frame)}\n`;
+ }
+ }
+ return msg;
+}
+
+async function build(config: RollupOptions) {
+ try {
+ const bundle = await rollup(config);
+ if (config.output) {
+ for (const output of [config.output].flat()) {
+ await bundle.generate(output);
+ await bundle.write(output);
+ }
+ }
+ } catch (error) {
+ throw new Error(formatErrorMessage(error));
+ }
+}
+
+export const buildPackage = async () => {
+ const configs = await makeConfigs();
+ await Promise.all(configs.map(build));
+};
diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts
index 0753301b78..721e1a355b 100644
--- a/packages/cli/src/lib/tasks.ts
+++ b/packages/cli/src/lib/tasks.ts
@@ -18,8 +18,12 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
-import { basename, dirname } from 'path';
+import { resolve as resolvePath, basename, dirname } from 'path';
import recursive from 'recursive-readdir';
+import { promisify } from 'util';
+import { exec as execCb } from 'child_process';
+import { paths } from './paths';
+const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
@@ -69,7 +73,7 @@ export async function templatingTask(
destinationDir: string,
context: any,
) {
- const files = await recursive(templateDir).catch(error => {
+ const files = await recursive(templateDir).catch((error) => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
@@ -85,7 +89,7 @@ export async function templatingTask(
const compiled = handlebars.compile(template.toString());
const contents = compiled({ name: basename(destination), ...context });
- await fs.writeFile(destination, contents).catch(error => {
+ await fs.writeFile(destination, contents).catch((error) => {
throw new Error(
`Failed to create file: ${destination}: ${error.message}`,
);
@@ -93,7 +97,7 @@ export async function templatingTask(
});
} else {
await Task.forItem('copying', basename(file), async () => {
- await fs.copyFile(file, destinationFile).catch(error => {
+ await fs.copyFile(file, destinationFile).catch((error) => {
const destination = destinationFile;
throw new Error(
`Failed to copy file to ${destination} : ${error.message}`,
@@ -103,3 +107,99 @@ export async function templatingTask(
}
}
}
+
+// List of local packages that we need to modify as a part of an E2E test
+const PATCH_PACKAGES = [
+ 'cli',
+ 'core',
+ 'dev-utils',
+ 'test-utils',
+ 'test-utils-core',
+ 'theme',
+];
+
+// This runs a `yarn install` task, but with special treatment for e2e tests
+export async function installWithLocalDeps(dir: string) {
+ // This makes us install any package inside this repo as a local file dependency.
+ // For example, instead of trying to fetch @backstage/core from npm, we point it
+ // to /packages/core. This makes yarn use a simple file copy to install it instead.
+ if (process.env.BACKSTAGE_E2E_CLI_TEST) {
+ Task.section('Linking packages locally for e2e tests');
+
+ const pkgJsonPath = resolvePath(dir, 'package.json');
+ const pkgJson = await fs.readJson(pkgJsonPath);
+
+ pkgJson.resolutions = pkgJson.resolutions || {};
+ pkgJson.dependencies = pkgJson.dependencies || {};
+
+ if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
+ for (const name of PATCH_PACKAGES) {
+ await Task.forItem(
+ 'adding',
+ `@backstage/${name} link to package.json`,
+ async () => {
+ const pkgPath = paths.resolveOwnRoot('packages', name);
+ // Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
+ pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
+ pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
+
+ await fs
+ .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
+ .catch((error) => {
+ throw new Error(
+ `Failed to add resolutions to package.json: ${error.message}`,
+ );
+ });
+ },
+ );
+ }
+ }
+ }
+
+ await Task.forItem('executing', 'yarn install', async () => {
+ await exec('yarn install', { cwd: dir }).catch((error) => {
+ process.stdout.write(error.stderr);
+ process.stdout.write(error.stdout);
+ throw new Error(
+ `Could not execute command ${chalk.cyan('yarn install')}`,
+ );
+ });
+ });
+
+ // This takes care of pointing all the installed packages from this repo to
+ // dist instead of the local src.
+ // For example node_modules/@backstage/core/packages.json is rewritten to point
+ // types to dist/index.d.ts and the main:src field is removed.
+ // Without this we get type checking errors in the e2e test
+ if (process.env.BACKSTAGE_E2E_CLI_TEST) {
+ Task.section('Patchling local dependencies for e2e tests');
+
+ for (const name of PATCH_PACKAGES) {
+ await Task.forItem(
+ 'patching',
+ `node_modules/@backstage/${name} package.json`,
+ async () => {
+ const depJsonPath = resolvePath(
+ dir,
+ 'node_modules/@backstage',
+ name,
+ 'package.json',
+ );
+ const depJson = await fs.readJson(depJsonPath);
+
+ // We want dist to be used for e2e tests
+ delete depJson['main:src'];
+ depJson.types = 'dist/index.d.ts';
+
+ await fs
+ .writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
+ .catch((error) => {
+ throw new Error(
+ `Failed to add resolutions to package.json: ${error.message}`,
+ );
+ });
+ },
+ );
+ }
+ }
+}
diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs
index 231194a54e..589e20e298 100644
--- a/packages/cli/templates/default-app/package.json.hbs
+++ b/packages/cli/templates/default-app/package.json.hbs
@@ -7,14 +7,18 @@
},
"scripts": {
"start": "yarn workspace app start",
- "bundle": "yarn build && yarn workspace app bundle",
+ "bundle": "yarn workspace app bundle",
"build": "lerna run build",
- "test": "yarn build && lerna run test --since origin/master -- --coverage",
- "test:all": "yarn build && lerna run test -- --coverage",
+ "tsc": "tsc",
+ "clean": "backstage-cli clean && lerna run clean",
+ "diff": "lerna run diff --",
+ "test": "lerna run test --since origin/master -- --coverage",
+ "test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"create-plugin": "backstage-cli create-plugin",
- "clean": "lerna run clean"
+ "remove-plugin": "backstage-cli remove-plugin",
+ "postinstall": "patch-package"
},
"workspaces": {
"packages": [
@@ -25,6 +29,7 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"lerna": "^3.20.2",
+ "patch-package": "^6.2.2",
"prettier": "^1.19.1"
}
}
diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs
index 5596eb9151..b0cf41117f 100644
--- a/packages/cli/templates/default-app/packages/app/package.json.hbs
+++ b/packages/cli/templates/default-app/packages/app/package.json.hbs
@@ -18,7 +18,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
diff --git a/packages/cli/templates/default-app/patches/README.md b/packages/cli/templates/default-app/patches/README.md
new file mode 100644
index 0000000000..980729c4cd
--- /dev/null
+++ b/packages/cli/templates/default-app/patches/README.md
@@ -0,0 +1,11 @@
+# patches
+
+This folder contains patches for dependency type declarations. Typescript doesn't provide a way to override of fix types that are installed as a part of the dependent package itself.
+
+Do not add any more patches here, these patches were added as a part of getting the entire repo type-checked, and we depended on some packages with bad types.
+
+As soon as the below issues are fixed, we should remove the patching functionality completely.
+
+## material-table
+
+Fixed in master but not released yet, tracked here: https://github.com/mbrn/material-table/pull/1624
diff --git a/packages/cli/templates/default-app/patches/material-table+1.57.2.patch b/packages/cli/templates/default-app/patches/material-table+1.57.2.patch
new file mode 100644
index 0000000000..5751243775
--- /dev/null
+++ b/packages/cli/templates/default-app/patches/material-table+1.57.2.patch
@@ -0,0 +1,12 @@
+diff --git a/node_modules/material-table/types/index.d.ts b/node_modules/material-table/types/index.d.ts
+index 06b700b..5b3c765 100644
+--- a/node_modules/material-table/types/index.d.ts
++++ b/node_modules/material-table/types/index.d.ts
+@@ -228,7 +228,6 @@ export interface Options {
+ showTitle?: boolean;
+ showTextRowsSelected?: boolean;
+ search?: boolean;
+- searchText?: string;
+ searchFieldAlignment?: 'left' | 'right';
+ searchFieldStyle?: React.CSSProperties;
+ searchText?: string;
diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
index c814473826..dcd0d7eaa7 100644
--- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
+++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
@@ -2,6 +2,7 @@
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"private": true,
"scripts": {
diff --git a/packages/cli/templates/default-app/tsconfig.json b/packages/cli/templates/default-app/tsconfig.json
index f278b2777d..52cdd1e825 100644
--- a/packages/cli/templates/default-app/tsconfig.json
+++ b/packages/cli/templates/default-app/tsconfig.json
@@ -1,3 +1,8 @@
{
- "extends": "@backstage/cli/config/tsconfig.json"
+ "extends": "@backstage/cli/config/tsconfig.json",
+ "include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"],
+ "exclude": ["**/node_modules"],
+ "compilerOptions": {
+ "outDir": "dist"
+ }
}
diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs
index 0a0ecc7e76..910bbddd56 100644
--- a/packages/cli/templates/default-plugin/package.json.hbs
+++ b/packages/cli/templates/default-plugin/package.json.hbs
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -33,7 +34,7 @@
"@backstage/dev-utils": "^{{version}}",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
diff --git a/packages/cli/templates/default-plugin/tsconfig.json b/packages/cli/templates/default-plugin/tsconfig.json
deleted file mode 100644
index b663b01fa2..0000000000
--- a/packages/cli/templates/default-plugin/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src", "dev"],
- "compilerOptions": {}
-}
diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json
index 1ef5cdace5..ae42e652e6 100644
--- a/packages/cli/tsconfig.build.json
+++ b/packages/cli/tsconfig.build.json
@@ -1,8 +1,11 @@
{
- "extends": "./tsconfig.json",
+ "extends": "./config/tsconfig.json",
+ "include": ["src"],
"exclude": ["**/*.test.*"],
"compilerOptions": {
"outDir": "dist",
+ "emitDeclarationOnly": false,
+ "removeComments": true,
"module": "CommonJS"
}
}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/cli/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/packages/core/package.json b/packages/core/package.json
index 295b95050c..17f85eb41d 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -17,6 +17,7 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -30,6 +31,13 @@
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
+ "@types/classnames": "^2.2.9",
+ "@types/google-protobuf": "^3.7.2",
+ "@types/jest": "^25.2.1",
+ "@types/node": "^12.0.0",
+ "@types/react-helmet": "^5.0.15",
+ "@types/react-sparklines": "^1.7.0",
+ "@types/zen-observable": "^0.8.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"lodash": "^4.17.15",
@@ -53,14 +61,7 @@
"@backstage/test-utils-core": "^0.1.1-alpha.5",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
- "@types/classnames": "^2.2.9",
- "@types/google-protobuf": "^3.7.2",
- "@types/jest": "^25.2.1",
- "@types/node": "^12.0.0",
- "@types/react-helmet": "^5.0.15",
- "@types/react-sparklines": "^1.7.0",
- "@types/zen-observable": "^0.8.0"
+ "@testing-library/user-event": "^10.2.4"
},
"files": [
"dist/**/*.{js,d.ts}"
diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts
index cc1146b336..7f6659e641 100644
--- a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts
+++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts
@@ -14,8 +14,9 @@
* limitations under the License.
*/
-import Observable from 'zen-observable';
import { AppThemeApi, AppTheme } from '../../definitions';
+import { BehaviorSubject } from '../lib';
+import { Observable } from '../../../types';
const STORAGE_KEY = 'theme';
@@ -50,34 +51,17 @@ export class AppThemeSelector implements AppThemeApi {
return selector;
}
- private readonly themes: AppTheme[];
-
private activeThemeId: string | undefined;
+ private readonly subject = new BehaviorSubject(undefined);
- private readonly observable: Observable;
- private readonly subscribers = new Set<
- ZenObservable.SubscriptionObserver
- >();
-
- constructor(themes: AppTheme[]) {
- this.themes = themes;
-
- this.observable = new Observable((subscriber) => {
- subscriber.next(this.activeThemeId);
-
- this.subscribers.add(subscriber);
- return () => {
- this.subscribers.delete(subscriber);
- };
- });
- }
+ constructor(private readonly themes: AppTheme[]) {}
getInstalledThemes(): AppTheme[] {
return this.themes.slice();
}
activeThemeId$(): Observable {
- return this.observable;
+ return this.subject;
}
getActiveThemeId(): string | undefined {
@@ -86,6 +70,6 @@ export class AppThemeSelector implements AppThemeApi {
setActiveThemeId(themeId?: string): void {
this.activeThemeId = themeId;
- this.subscribers.forEach((subscriber) => subscriber.next(themeId));
+ this.subject.next(themeId);
}
}
diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts
index 6348262022..96aada1864 100644
--- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts
+++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts
@@ -14,8 +14,9 @@
* limitations under the License.
*/
-import Observable from 'zen-observable';
import { OAuthScopes } from '../../definitions';
+import { BehaviorSubject } from '../lib';
+import { Observable } from '../../../types';
type RequestQueueEntry = {
scopes: OAuthScopes;
@@ -44,16 +45,15 @@ export type OAuthPendingRequestsApi = {
export class OAuthPendingRequests
implements OAuthPendingRequestsApi {
private requests: RequestQueueEntry[] = [];
- private listeners: ZenObservable.SubscriptionObserver<
- PendingRequest
- >[] = [];
+ private subject = new BehaviorSubject>(
+ this.getCurrentPending(),
+ );
request(scopes: OAuthScopes): Promise {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
- const pending = this.getCurrentPending();
- this.listeners.forEach((listener) => listener.next(pending));
+ this.subject.next(this.getCurrentPending());
});
}
@@ -66,26 +66,18 @@ export class OAuthPendingRequests
return true;
});
- const pending = this.getCurrentPending();
- this.listeners.forEach((listener) => listener.next(pending));
+ this.subject.next(this.getCurrentPending());
}
reject(error: Error) {
this.requests.forEach((request) => request.reject(error));
this.requests = [];
- const pending = this.getCurrentPending();
- this.listeners.forEach((listener) => listener.next(pending));
+ this.subject.next(this.getCurrentPending());
}
pending(): Observable> {
- return new Observable((subscriber) => {
- this.listeners.push(subscriber);
- subscriber.next(this.getCurrentPending());
- return () => {
- this.listeners = this.listeners.filter((l) => l !== subscriber);
- };
- });
+ return this.subject;
}
private getCurrentPending(): PendingRequest {
diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts
index 02d89b9546..632ec059eb 100644
--- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts
+++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts
@@ -21,8 +21,9 @@ import {
AuthRequester,
AuthRequesterOptions,
} from '../../definitions';
-import Observable from 'zen-observable';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
+import { BehaviorSubject } from '../lib';
+import { Observable } from '../../../types';
/**
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
@@ -32,24 +33,10 @@ import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
* them all together into a single request for each OAuth provider.
*/
export class OAuthRequestManager implements OAuthRequestApi {
- private readonly request$: Observable;
- private readonly observers = new Set<
- ZenObservable.SubscriptionObserver
- >();
+ private readonly subject = new BehaviorSubject([]);
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
- constructor() {
- this.request$ = new Observable((observer) => {
- observer.next(this.currentRequests);
-
- this.observers.add(observer);
- return () => {
- this.observers.delete(observer);
- };
- }).map((requests) => requests.filter(Boolean)); // Convert from sparse array to array of present items only
- }
-
createAuthRequester(options: AuthRequesterOptions): AuthRequester {
const handler = new OAuthPendingRequests();
@@ -66,7 +53,8 @@ export class OAuthRequestManager implements OAuthRequestApi {
newRequests[index] = request;
}
this.currentRequests = newRequests;
- this.observers.forEach((observer) => observer.next(newRequests));
+ // Convert from sparse array to array of present items only
+ this.subject.next(newRequests.filter(Boolean));
},
});
@@ -100,7 +88,7 @@ export class OAuthRequestManager implements OAuthRequestApi {
}
authRequest$(): Observable {
- return this.request$;
+ return this.subject;
}
async showLoginPopup(options: LoginPopupOptions): Promise {
diff --git a/packages/core/src/api/apis/implementations/lib/index.ts b/packages/core/src/api/apis/implementations/lib/index.ts
new file mode 100644
index 0000000000..aa2202858c
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/lib/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './subjects';
diff --git a/packages/core/src/api/apis/implementations/lib/subjects.test.ts b/packages/core/src/api/apis/implementations/lib/subjects.test.ts
new file mode 100644
index 0000000000..31ed26d97d
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/lib/subjects.test.ts
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { PublishSubject, BehaviorSubject } from './subjects';
+
+function observerSpy() {
+ return {
+ next: jest.fn(),
+ error: jest.fn(),
+ complete: jest.fn(),
+ };
+}
+
+describe('PublishSubject', () => {
+ it('should be completed', async () => {
+ const subj = new PublishSubject();
+ subj.complete();
+
+ const spy = observerSpy();
+ subj.subscribe(spy);
+ await 'a tick';
+
+ expect(spy.next).not.toHaveBeenCalled();
+ expect(spy.error).not.toHaveBeenCalled();
+ expect(spy.complete).toHaveBeenCalledTimes(1);
+
+ expect(() => subj.next(1)).toThrow('PublishSubject is closed');
+ expect(() => subj.error(new Error())).toThrow('PublishSubject is closed');
+ expect(() => subj.complete()).toThrow('PublishSubject is closed');
+
+ expect(subj.closed).toBe(true);
+ });
+
+ it('should forward values to all subscribers', async () => {
+ const subj = new PublishSubject();
+
+ const spy1 = observerSpy();
+ const spy2 = observerSpy();
+ subj.subscribe(spy1);
+ const sub = subj.subscribe(spy2);
+
+ subj.next(42);
+
+ expect(spy1.next).toHaveBeenCalledTimes(1);
+ expect(spy1.next).toHaveBeenCalledWith(42);
+ expect(spy2.next).toHaveBeenCalledTimes(1);
+ expect(spy2.next).toHaveBeenCalledWith(42);
+
+ sub.unsubscribe();
+
+ subj.next(1337);
+
+ expect(spy1.next).toHaveBeenCalledTimes(2);
+ expect(spy1.next).toHaveBeenCalledWith(1337);
+ expect(spy2.next).toHaveBeenCalledTimes(1);
+
+ expect(spy1.error).not.toHaveBeenCalled();
+ expect(spy2.error).not.toHaveBeenCalled();
+ expect(spy1.complete).not.toHaveBeenCalled();
+ expect(spy2.complete).not.toHaveBeenCalled();
+
+ expect(subj.closed).toBe(false);
+ });
+
+ it('should forward errors', async () => {
+ const subj = new PublishSubject();
+
+ const spy1 = observerSpy();
+ subj.subscribe(spy1);
+
+ const error = new Error('NOPE');
+ subj.error(error);
+ expect(spy1.error).toHaveBeenCalledWith(error);
+ expect(spy1.next).not.toHaveBeenCalled();
+ expect(spy1.complete).not.toHaveBeenCalled();
+
+ const spy2 = observerSpy();
+ subj.subscribe(spy2);
+ await 'a tick';
+ expect(spy2.error).toHaveBeenCalledWith(error);
+ expect(spy2.next).not.toHaveBeenCalled();
+ expect(spy2.complete).not.toHaveBeenCalled();
+
+ expect(subj.closed).toBe(true);
+ });
+});
+
+describe('BehaviorSubject', () => {
+ it('should be completed', async () => {
+ const subj = new BehaviorSubject(0);
+ subj.complete();
+
+ const next = jest.fn();
+ const complete = jest.fn();
+ subj.subscribe({ next, complete });
+ await 'a tick';
+
+ expect(complete).toHaveBeenCalledTimes(1);
+
+ expect(() => subj.next(1)).toThrow('BehaviorSubject is closed');
+ expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed');
+ expect(() => subj.complete()).toThrow('BehaviorSubject is closed');
+
+ expect(subj.closed).toBe(true);
+ });
+
+ it('should forward values to all subscribers', async () => {
+ const subj = new BehaviorSubject(0);
+
+ const obs1 = jest.fn();
+ const obs2 = jest.fn();
+ subj.subscribe(obs1);
+ const sub = subj.subscribe(obs2);
+ await 'a tick';
+
+ expect(obs1).toHaveBeenCalledTimes(1);
+ expect(obs1).toHaveBeenCalledWith(0);
+ expect(obs2).toHaveBeenCalledTimes(1);
+ expect(obs2).toHaveBeenCalledWith(0);
+
+ subj.next(42);
+
+ expect(obs1).toHaveBeenCalledTimes(2);
+ expect(obs1).toHaveBeenCalledWith(42);
+ expect(obs2).toHaveBeenCalledTimes(2);
+ expect(obs2).toHaveBeenCalledWith(42);
+
+ sub.unsubscribe();
+
+ subj.next(1337);
+
+ expect(obs1).toHaveBeenCalledTimes(3);
+ expect(obs1).toHaveBeenCalledWith(1337);
+ expect(obs2).toHaveBeenCalledTimes(2);
+
+ expect(subj.closed).toBe(false);
+ });
+
+ it('should forward errors', async () => {
+ const subj = new BehaviorSubject(0);
+
+ const spy1 = observerSpy();
+ subj.subscribe(spy1);
+ await 'a tick';
+
+ expect(spy1.error).not.toHaveBeenCalled();
+ expect(spy1.next).toHaveBeenCalledWith(0);
+
+ const error = new Error('NOPE');
+ subj.error(error);
+ expect(spy1.error).toHaveBeenCalledWith(error);
+ expect(spy1.next).toHaveBeenCalledWith(0);
+ expect(spy1.next).toHaveBeenCalledTimes(1);
+ expect(spy1.complete).not.toHaveBeenCalled();
+
+ const spy2 = observerSpy();
+ subj.subscribe(spy2);
+ await 'a tick';
+ expect(spy2.error).toHaveBeenCalledWith(error);
+ expect(spy2.next).not.toHaveBeenCalled();
+ expect(spy2.complete).not.toHaveBeenCalled();
+
+ expect(subj.closed).toBe(true);
+ });
+});
diff --git a/packages/core/src/api/apis/implementations/lib/subjects.ts b/packages/core/src/api/apis/implementations/lib/subjects.ts
new file mode 100644
index 0000000000..b33df70a65
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/lib/subjects.ts
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Observable } from '../../../types';
+import ObservableImpl from 'zen-observable';
+
+// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects.
+// If we add a more complete Observables library they should be replaced.
+
+/**
+ * A basic implementation of ReactiveX publish subjects.
+ *
+ * A subject is a convenient way to create an observable when you want
+ * to fan out a single value to all subscribers.
+ *
+ * See http://reactivex.io/documentation/subject.html
+ */
+export class PublishSubject
+ implements Observable, ZenObservable.SubscriptionObserver {
+ private isClosed = false;
+ private terminatingError?: Error;
+
+ private readonly observable = new ObservableImpl((subscriber) => {
+ if (this.isClosed) {
+ if (this.terminatingError) {
+ subscriber.error(this.terminatingError);
+ } else {
+ subscriber.complete();
+ }
+ return () => {};
+ }
+
+ this.subscribers.add(subscriber);
+ return () => {
+ this.subscribers.delete(subscriber);
+ };
+ });
+
+ private readonly subscribers = new Set<
+ ZenObservable.SubscriptionObserver
+ >();
+
+ get closed() {
+ return this.isClosed;
+ }
+
+ next(value: T) {
+ if (this.isClosed) {
+ throw new Error('PublishSubject is closed');
+ }
+ this.subscribers.forEach((subscriber) => subscriber.next(value));
+ }
+
+ error(error: Error) {
+ if (this.isClosed) {
+ throw new Error('PublishSubject is closed');
+ }
+ this.isClosed = true;
+ this.terminatingError = error;
+ this.subscribers.forEach((subscriber) => subscriber.error(error));
+ }
+
+ complete() {
+ if (this.isClosed) {
+ throw new Error('PublishSubject is closed');
+ }
+ this.isClosed = true;
+ this.subscribers.forEach((subscriber) => subscriber.complete());
+ }
+
+ subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription;
+ subscribe(
+ onNext: ZenObservable.Observer | ((value: T) => void),
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription {
+ const observer =
+ typeof onNext === 'function'
+ ? {
+ next: onNext,
+ error: onError,
+ complete: onComplete,
+ }
+ : onNext;
+
+ return this.observable.subscribe(observer);
+ }
+}
+
+/**
+ * A basic implementation of ReactiveX behavior subjects.
+ *
+ * A subject is a convenient way to create an observable when you want
+ * to fan out a single value to all subscribers.
+ *
+ * The BehaviorSubject will emit the most recently emitted value or error
+ * whenever a new observer subscribes to the subject.
+ *
+ * See http://reactivex.io/documentation/subject.html
+ */
+export class BehaviorSubject
+ implements Observable, ZenObservable.SubscriptionObserver {
+ private isClosed = false;
+ private currentValue: T;
+ private terminatingError?: Error;
+
+ constructor(value: T) {
+ this.currentValue = value;
+ }
+
+ private readonly observable = new ObservableImpl((subscriber) => {
+ if (this.isClosed) {
+ if (this.terminatingError) {
+ subscriber.error(this.terminatingError);
+ } else {
+ subscriber.complete();
+ }
+ return () => {};
+ }
+
+ subscriber.next(this.currentValue);
+
+ this.subscribers.add(subscriber);
+ return () => {
+ this.subscribers.delete(subscriber);
+ };
+ });
+
+ private readonly subscribers = new Set<
+ ZenObservable.SubscriptionObserver
+ >();
+
+ get closed() {
+ return this.isClosed;
+ }
+
+ next(value: T) {
+ if (this.isClosed) {
+ throw new Error('BehaviorSubject is closed');
+ }
+ this.currentValue = value;
+ this.subscribers.forEach((subscriber) => subscriber.next(value));
+ }
+
+ error(error: Error) {
+ if (this.isClosed) {
+ throw new Error('BehaviorSubject is closed');
+ }
+ this.isClosed = true;
+ this.terminatingError = error;
+ this.subscribers.forEach((subscriber) => subscriber.error(error));
+ }
+
+ complete() {
+ if (this.isClosed) {
+ throw new Error('BehaviorSubject is closed');
+ }
+ this.isClosed = true;
+ this.subscribers.forEach((subscriber) => subscriber.complete());
+ }
+
+ subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription;
+ subscribe(
+ onNext: ZenObservable.Observer | ((value: T) => void),
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription {
+ const observer =
+ typeof onNext === 'function'
+ ? {
+ next: onNext,
+ error: onError,
+ complete: onComplete,
+ }
+ : onNext;
+
+ return this.observable.subscribe(observer);
+ }
+}
diff --git a/packages/core/src/api/navTargets/index.ts b/packages/core/src/api/navTargets/index.ts
index b090af277a..c5d0cc4289 100644
--- a/packages/core/src/api/navTargets/index.ts
+++ b/packages/core/src/api/navTargets/index.ts
@@ -14,9 +14,5 @@
* limitations under the License.
*/
-export type {
- NavTarget,
- NavTargetConfig,
- NavTargetOverrideConfig,
-} from './types';
+export * from './types';
export { createNavTarget } from './NavTarget';
diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.js b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
similarity index 98%
rename from packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.js
rename to packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
index d7f1e2387a..36079854fd 100644
--- a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.js
+++ b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
@@ -25,7 +25,7 @@ describe('', () => {
jest.spyOn(window.performance, 'now').mockReturnValue(5);
jest
.spyOn(window, 'requestAnimationFrame')
- .mockImplementation(cb => cb(20));
+ .mockImplementation((cb) => cb(20));
});
afterEach(() => {
diff --git a/packages/core/src/components/ProgressBars/CircleProgress.test.js b/packages/core/src/components/ProgressBars/CircleProgress.test.jsx
similarity index 100%
rename from packages/core/src/components/ProgressBars/CircleProgress.test.js
rename to packages/core/src/components/ProgressBars/CircleProgress.test.jsx
diff --git a/packages/core/src/components/ProgressBars/ProgressCard.test.js b/packages/core/src/components/ProgressBars/ProgressCard.test.jsx
similarity index 100%
rename from packages/core/src/components/ProgressBars/ProgressCard.test.js
rename to packages/core/src/components/ProgressBars/ProgressCard.test.jsx
diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.js b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.jsx
similarity index 94%
rename from packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.js
rename to packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.jsx
index b9aa962969..a6e27eb29e 100644
--- a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.js
+++ b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.jsx
@@ -36,7 +36,7 @@ describe('', () => {
,
);
const keys = Object.keys(metadata);
- keys.forEach(value => {
+ keys.forEach((value) => {
expect(getByText(startCase(value))).toBeInTheDocument();
expect(getByText(metadata[value])).toBeInTheDocument();
});
@@ -49,7 +49,7 @@ describe('', () => {
);
const keys = Object.keys(metadata);
- keys.forEach(value => {
+ keys.forEach((value) => {
expect(getByText(startCase(value))).toBeInTheDocument();
expect(getByText(metadata[value].toString())).toBeInTheDocument();
});
@@ -61,10 +61,10 @@ describe('', () => {
,
);
const keys = Object.keys(metadata);
- keys.forEach(value => {
+ keys.forEach((value) => {
expect(getByText(startCase(value))).toBeInTheDocument();
});
- metadata.arrayField.forEach(value => {
+ metadata.arrayField.forEach((value) => {
expect(getByText(value)).toBeInTheDocument();
});
});
@@ -85,7 +85,7 @@ describe('', () => {
);
const keys = Object.keys(metadata.config);
- keys.forEach(value => {
+ keys.forEach((value) => {
expect(
getByText(startCase(value), { exact: false }),
).toBeInTheDocument();
diff --git a/packages/core/src/layout/ErrorPage/MicDrop.js b/packages/core/src/layout/ErrorPage/MicDrop.jsx
similarity index 100%
rename from packages/core/src/layout/ErrorPage/MicDrop.js
rename to packages/core/src/layout/ErrorPage/MicDrop.jsx
diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx
index b32101a270..a03a33ce09 100644
--- a/packages/core/src/layout/Sidebar/Bar.tsx
+++ b/packages/core/src/layout/Sidebar/Bar.tsx
@@ -20,7 +20,7 @@ import React, { FC, useRef, useState } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
-const useStyles = makeStyles(theme => ({
+const useStyles = makeStyles((theme) => ({
root: {
zIndex: 1000,
position: 'relative',
@@ -115,7 +115,7 @@ export const Sidebar: FC = ({
onBlur={handleClose}
data-testid="sidebar-root"
>
-
+
);
};
@@ -111,4 +220,5 @@ export const SidebarDivider = styled('hr')({
width: '100%',
background: '#383838',
border: 'none',
+ margin: 0,
});
diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
new file mode 100644
index 0000000000..1b9ba5bbab
--- /dev/null
+++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ Sidebar,
+ SidebarIntro,
+ SidebarItem,
+ SidebarDivider,
+ SidebarSearchField,
+ SidebarSpace,
+ SidebarUserBadge,
+} from '.';
+import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
+import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
+import { MemoryRouter } from 'react-router-dom';
+
+export default {
+ title: 'Sidebar',
+ component: Sidebar,
+ decorators: [
+ (storyFn: () => JSX.Element) => (
+ {storyFn()}
+ ),
+ ],
+};
+
+const handleSearch = (input: string) => {
+ // eslint-disable-next-line no-console
+ console.log(input);
+};
+
+export const SampleSidebar = () => (
+
+ {/* */}
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/core/src/layout/Sidebar/UserBadge.tsx b/packages/core/src/layout/Sidebar/UserBadge.tsx
new file mode 100644
index 0000000000..1614558ab9
--- /dev/null
+++ b/packages/core/src/layout/Sidebar/UserBadge.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { FC, useContext } from 'react';
+import { Avatar, makeStyles, Theme, Typography } from '@material-ui/core';
+import People from '@material-ui/icons/People';
+import { sidebarConfig, SidebarContext } from './config';
+import { SidebarItem } from './Items';
+
+const useStyles = makeStyles(() => {
+ const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
+ return {
+ root: {
+ width: drawerWidthOpen,
+ display: 'flex',
+ alignItems: 'center',
+ color: '#b5b5b5',
+ paddingLeft: 18,
+ paddingTop: 14,
+ paddingBottom: 14,
+ },
+ avatar: {
+ width: userBadgeDiameter,
+ height: userBadgeDiameter,
+ marginRight: 8,
+ },
+ };
+});
+
+type Props = {
+ imageUrl: string;
+ name: string;
+ hideName?: boolean;
+};
+
+export const UserBadge: FC = ({ imageUrl, name, hideName = false }) => {
+ const classes = useStyles();
+
+ return (
+