Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Nikita Nek Dudnik
2020-05-18 14:41:45 +02:00
111 changed files with 2445 additions and 2716 deletions
+1
View File
@@ -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 }}
+16 -7
View File
@@ -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
+3
View File
@@ -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
+4 -3
View File
@@ -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.
+47
View File
@@ -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).
+9
View File
@@ -24,6 +24,15 @@ plugin directly by navigating to `http://localhost:3000/my-plugin`._
<img src='https://github.com/spotify/backstage/raw/master/docs/getting-started/my-plugin_screenshot.png' width='600' alt='my plugin'>
</p>
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)
@@ -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
+7 -4
View File
@@ -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": {
+1 -1
View File
@@ -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",
+20 -8
View File
@@ -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 (
<div className={classes.root}>
@@ -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 }) => (
<SidebarPage>
<Sidebar>
<SidebarLogo />
<SidebarSpacer />
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
{/* Global nav, not org-specific */}
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={ExploreIcon} to="/explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarItem icon={AccountTreeIcon} to="/catalog" text="Catalog" />
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
<SidebarDivider />
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
<SidebarUserBadge />
</Sidebar>
{children}
</SidebarPage>
-21
View File
@@ -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"]
}
+45 -22
View File
@@ -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: ['<rootDir>'],
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 = <repo-root>/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 = ['<rootDir>/setupTests.ts'];
options.setupFilesAfterEnv = ['<rootDir>/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();
+2
View File
@@ -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"],
+16 -2
View File
@@ -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",
+6 -9
View File
@@ -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,
});
};
+8 -14
View File
@@ -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();
};
@@ -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);
@@ -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);
+3 -80
View File
@@ -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();
};
@@ -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;
@@ -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();
};
+3 -1
View File
@@ -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
+112
View File
@@ -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<webpack.Stats>((resolve, reject) => {
compiler.run((err, buildStats) => {
if (err) {
if (err.message) {
const { errors } = formatWebpackMessages({
errors: [err.message],
warnings: new Array<string>(),
} 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 };
}
@@ -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',
+18
View File
@@ -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';
@@ -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,
},
},
},
};
};
@@ -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<typeof getPaths>;
export type BundlingPaths = ReturnType<typeof resolveBundlingPaths>;
@@ -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;
}
+101
View File
@@ -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<Plugin>();
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 };
};
+30
View File
@@ -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;
};
+81
View File
@@ -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<RollupOptions[]> => {
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()],
},
];
};
+17
View File
@@ -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';
+86
View File
@@ -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));
};
+104 -4
View File
@@ -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 <repo-root>/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}`,
);
});
},
);
}
}
}
@@ -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"
}
}
@@ -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",
@@ -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
@@ -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;
@@ -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": {
@@ -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"
}
}
@@ -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",
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+4 -1
View File
@@ -1,8 +1,11 @@
{
"extends": "./tsconfig.json",
"extends": "./config/tsconfig.json",
"include": ["src"],
"exclude": ["**/*.test.*"],
"compilerOptions": {
"outDir": "dist",
"emitDeclarationOnly": false,
"removeComments": true,
"module": "CommonJS"
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}
+9 -8
View File
@@ -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}"
@@ -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<string | undefined>(undefined);
private readonly observable: Observable<string | undefined>;
private readonly subscribers = new Set<
ZenObservable.SubscriptionObserver<string | undefined>
>();
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<string | undefined> {
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);
}
}
@@ -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<ResultType> = {
scopes: OAuthScopes;
@@ -44,16 +45,15 @@ export type OAuthPendingRequestsApi<ResultType> = {
export class OAuthPendingRequests<ResultType>
implements OAuthPendingRequestsApi<ResultType> {
private requests: RequestQueueEntry<ResultType>[] = [];
private listeners: ZenObservable.SubscriptionObserver<
PendingRequest<ResultType>
>[] = [];
private subject = new BehaviorSubject<PendingRequest<ResultType>>(
this.getCurrentPending(),
);
request(scopes: OAuthScopes): Promise<ResultType> {
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<ResultType>
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<PendingRequest<ResultType>> {
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<ResultType> {
@@ -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<PendingAuthRequest[]>;
private readonly observers = new Set<
ZenObservable.SubscriptionObserver<PendingAuthRequest[]>
>();
private readonly subject = new BehaviorSubject<PendingAuthRequest[]>([]);
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
constructor() {
this.request$ = new Observable<PendingAuthRequest[]>((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<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
const handler = new OAuthPendingRequests<T>();
@@ -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<PendingAuthRequest[]> {
return this.request$;
return this.subject;
}
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
@@ -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';
@@ -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<number>();
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<number>();
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<number>();
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<number>(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);
});
});
@@ -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<T>
implements Observable<T>, ZenObservable.SubscriptionObserver<T> {
private isClosed = false;
private terminatingError?: Error;
private readonly observable = new ObservableImpl<T>((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<T>
>();
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<T>): ZenObservable.Subscription;
subscribe(
onNext: (value: T) => void,
onError?: (error: any) => void,
onComplete?: () => void,
): ZenObservable.Subscription;
subscribe(
onNext: ZenObservable.Observer<T> | ((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<T>
implements Observable<T>, ZenObservable.SubscriptionObserver<T> {
private isClosed = false;
private currentValue: T;
private terminatingError?: Error;
constructor(value: T) {
this.currentValue = value;
}
private readonly observable = new ObservableImpl<T>((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<T>
>();
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<T>): ZenObservable.Subscription;
subscribe(
onNext: (value: T) => void,
onError?: (error: any) => void,
onComplete?: () => void,
): ZenObservable.Subscription;
subscribe(
onNext: ZenObservable.Observer<T> | ((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);
}
}
+1 -5
View File
@@ -14,9 +14,5 @@
* limitations under the License.
*/
export type {
NavTarget,
NavTargetConfig,
NavTargetOverrideConfig,
} from './types';
export * from './types';
export { createNavTarget } from './NavTarget';
@@ -25,7 +25,7 @@ describe('<HorizontalScrollGrid />', () => {
jest.spyOn(window.performance, 'now').mockReturnValue(5);
jest
.spyOn(window, 'requestAnimationFrame')
.mockImplementation(cb => cb(20));
.mockImplementation((cb) => cb(20));
});
afterEach(() => {
@@ -36,7 +36,7 @@ describe('<StructuredMetadataTable />', () => {
<StructuredMetadataTable metadata={metadata} />,
);
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('<StructuredMetadataTable />', () => {
);
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('<StructuredMetadataTable />', () => {
<StructuredMetadataTable metadata={metadata} />,
);
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('<StructuredMetadataTable />', () => {
);
const keys = Object.keys(metadata.config);
keys.forEach(value => {
keys.forEach((value) => {
expect(
getByText(startCase(value), { exact: false }),
).toBeInTheDocument();
+2 -2
View File
@@ -20,7 +20,7 @@ import React, { FC, useRef, useState } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
const useStyles = makeStyles<BackstageTheme>((theme) => ({
root: {
zIndex: 1000,
position: 'relative',
@@ -115,7 +115,7 @@ export const Sidebar: FC<Props> = ({
onBlur={handleClose}
data-testid="sidebar-root"
>
<SidebarContext.Provider value={state === State.Open}>
<SidebarContext.Provider value={{ isOpen: state === State.Open }}>
<div
className={clsx(classes.drawer, {
[classes.drawerPeek]: state === State.Peek,
+173
View File
@@ -0,0 +1,173 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES 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, useState } from 'react';
import { useLocalStorage } from 'react-use';
import { Link, Typography, makeStyles, Collapse } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { BackstageTheme } from '@backstage/theme';
import {
SIDEBAR_INTRO_LOCAL_STORAGE,
SidebarContext,
sidebarConfig,
} from './config';
import { SidebarDivider } from './Items';
const useStyles = makeStyles<BackstageTheme>((theme) => ({
introCard: {
color: '#b5b5b5',
// XXX (@koroeskohr): should I be using a Mui theme variable?
fontSize: 12,
width: sidebarConfig.drawerWidthOpen,
marginTop: 18,
marginBottom: 12,
paddingLeft: sidebarConfig.iconPadding,
paddingRight: sidebarConfig.iconPadding,
},
introDismiss: {
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
marginTop: 12,
},
introDismissLink: {
color: '#dddddd',
display: 'flex',
alignItems: 'center',
marginBottom: 4,
'&:hover': {
color: theme.palette.linkHover,
transition: theme.transitions.create('color', {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.shortest,
}),
},
},
introDismissText: {
fontSize: '0.7rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: 1,
},
introDismissIcon: {
width: 18,
height: 18,
marginRight: 12,
},
}));
type IntroCardProps = {
text: string;
onClose: () => void;
};
export const IntroCard: FC<IntroCardProps> = (props) => {
const classes = useStyles();
const { text, onClose } = props;
const handleClose = () => onClose();
return (
<div className={classes.introCard}>
<Typography variant="subtitle2">{text}</Typography>
<div className={classes.introDismiss}>
<Link
component="button"
onClick={handleClose}
underline="none"
className={classes.introDismissLink}
>
<CloseIcon className={classes.introDismissIcon} />
<Typography component="span" className={classes.introDismissText}>
Dismiss
</Typography>
</Link>
</div>
</div>
);
};
type SidebarIntroLocalStorage = {
starredItemsDismissed: boolean;
recentlyViewedItemsDismissed: boolean;
};
type SidebarIntroCardProps = {
text: string;
onDismiss: () => void;
};
const SidebarIntroCard: FC<SidebarIntroCardProps> = (props) => {
const { text, onDismiss } = props;
const [collapsing, setCollapsing] = useState(false);
const startDismissing = () => {
setCollapsing(true);
};
return (
<Collapse in={!collapsing} onExited={onDismiss}>
<IntroCard text={text} onClose={startDismissing} />
</Collapse>
);
};
const starredIntroText = `Fun fact! As you explore all the awesome plugins in Backstage, you can actually pin them to this side nav.
Keep an eye out for the little star icon () next to the plugin name and give it a click!`;
const recentlyViewedIntroText =
'And your recently viewed plugins will pop up here!';
export const SidebarIntro: FC = () => {
const { isOpen } = useContext(SidebarContext);
const [
{ starredItemsDismissed, recentlyViewedItemsDismissed },
setDismissedIntro,
] = useLocalStorage<SidebarIntroLocalStorage>(SIDEBAR_INTRO_LOCAL_STORAGE, {
starredItemsDismissed: false,
recentlyViewedItemsDismissed: false,
});
const dismissStarred = () => {
setDismissedIntro((state) => ({ ...state, starredItemsDismissed: true }));
};
const dismissRecentlyViewed = () => {
setDismissedIntro((state) => ({
...state,
recentlyViewedItemsDismissed: true,
}));
};
if (!isOpen) {
return null;
}
return (
<>
{!starredItemsDismissed && (
<>
<SidebarIntroCard
text={starredIntroText}
onDismiss={dismissStarred}
/>
<SidebarDivider />
</>
)}
{!recentlyViewedItemsDismissed && (
<SidebarIntroCard
text={recentlyViewedIntroText}
onDismiss={dismissRecentlyViewed}
/>
)}
</>
);
};
+159 -49
View File
@@ -14,87 +14,196 @@
* limitations under the License.
*/
import { Link, makeStyles, styled, Theme, Typography } from '@material-ui/core';
import {
makeStyles,
styled,
TextField,
Theme,
Typography,
Badge,
} from '@material-ui/core';
import SearchIcon from '@material-ui/icons/Search';
import clsx from 'clsx';
import React, { FC, useContext } from 'react';
import React, { FC, useContext, useState, KeyboardEventHandler } from 'react';
import { NavLink } from 'react-router-dom';
import { sidebarConfig, SidebarContext } from './config';
import { IconComponent } from '../../icons';
const useStyles = makeStyles<Theme>((theme) => ({
root: {
color: '#b5b5b5',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
height: 40,
cursor: 'pointer',
},
closed: {
width: sidebarConfig.drawerWidthClosed,
justifyContent: 'center',
},
open: {
width: sidebarConfig.drawerWidthOpen,
},
label: {
fontWeight: 'bolder',
whiteSpace: 'nowrap',
lineHeight: 1.0,
marginLeft: theme.spacing(1),
},
iconContainer: {
height: '100%',
width: sidebarConfig.drawerWidthClosed,
marginRight: -theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
}));
const useStyles = makeStyles<Theme>((theme) => {
const {
selectedIndicatorWidth,
drawerWidthClosed,
drawerWidthOpen,
iconContainerWidth,
iconSize,
} = sidebarConfig;
return {
root: {
color: '#b5b5b5',
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
height: 48,
cursor: 'pointer',
},
closed: {
width: drawerWidthClosed,
justifyContent: 'center',
},
open: {
width: drawerWidthOpen,
},
label: {
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
fontWeight: 'bold',
whiteSpace: 'nowrap',
lineHeight: 1.0,
},
iconContainer: {
boxSizing: 'border-box',
height: '100%',
width: iconContainerWidth,
marginRight: -theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
icon: {
width: iconSize,
height: iconSize,
},
searchRoot: {
marginBottom: 12,
},
searchField: {
color: '#b5b5b5',
fontWeight: 'bold',
fontSize: theme.typography.fontSize,
},
searchContainer: {
width: drawerWidthOpen - iconContainerWidth,
},
selected: {
'&$root': {
borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`,
color: '#ffffff',
},
'&$closed': {
width: drawerWidthClosed - selectedIndicatorWidth,
},
'& $iconContainer': {
marginLeft: -selectedIndicatorWidth,
},
},
};
});
type SidebarItemProps = {
icon: IconComponent;
text: string;
text?: string;
to?: string;
disableSelected?: boolean;
hasNotifications?: boolean;
onClick?: () => void;
};
export const SidebarItem: FC<SidebarItemProps> = ({
icon: Icon,
text,
to,
to = '#',
disableSelected = false,
hasNotifications = false,
onClick,
children,
}) => {
const classes = useStyles();
const isOpen = useContext(SidebarContext);
// XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component
// depend on the current location, and at least have it being optionally forced to selected.
// Still waiting on a Q answered to fine tune the implementation
const { isOpen } = useContext(SidebarContext);
const itemIcon = (
<Badge
color="secondary"
variant="dot"
overlap="circle"
invisible={!hasNotifications}
>
<Icon fontSize="small" className={classes.icon} />
</Badge>
);
if (!isOpen) {
return (
<Link
<NavLink
className={clsx(classes.root, classes.closed)}
href={to}
activeClassName={classes.selected}
isActive={(match) => Boolean(match && !disableSelected)}
exact
to={to}
onClick={onClick}
underline="none"
>
<Icon fontSize="small" />
</Link>
{itemIcon}
</NavLink>
);
}
return (
<Link
<NavLink
className={clsx(classes.root, classes.open)}
href={to}
activeClassName={classes.selected}
isActive={(match) => Boolean(match && !disableSelected)}
exact
to={to}
onClick={onClick}
underline="none"
>
<div data-testid="login-button" className={classes.iconContainer}>
<Icon fontSize="small" />
{itemIcon}
</div>
<Typography variant="subtitle1" className={classes.label}>
{text}
</Typography>
</Link>
{text && (
<Typography variant="subtitle2" className={classes.label}>
{text}
</Typography>
)}
{children}
</NavLink>
);
};
type SidebarSearchFieldProps = {
onSearch: (input: string) => void;
};
export const SidebarSearchField: FC<SidebarSearchFieldProps> = (props) => {
const [input, setInput] = useState('');
const classes = useStyles();
const handleEnter: KeyboardEventHandler = (ev) => {
if (ev.key === 'Enter') {
props.onSearch(input);
}
};
const handleInput = (ev: React.ChangeEvent<HTMLInputElement>) => {
setInput(ev.target.value);
};
return (
<div className={classes.searchRoot}>
<SidebarItem icon={SearchIcon} disableSelected>
<TextField
placeholder="Search"
onChange={handleInput}
onKeyDown={handleEnter}
className={classes.searchContainer}
InputProps={{
disableUnderline: true,
className: classes.searchField,
}}
/>
</SidebarItem>
</div>
);
};
@@ -111,4 +220,5 @@ export const SidebarDivider = styled('hr')({
width: '100%',
background: '#383838',
border: 'none',
margin: 0,
});
@@ -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) => (
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
),
],
};
const handleSearch = (input: string) => {
// eslint-disable-next-line no-console
console.log(input);
};
export const SampleSidebar = () => (
<Sidebar>
{/* <SidebarLogo /> */}
<SidebarSearchField onSearch={handleSearch} />
<SidebarDivider />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Home" />
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
<SidebarDivider />
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserBadge />
</Sidebar>
);
@@ -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<Theme>(() => {
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<Props> = ({ imageUrl, name, hideName = false }) => {
const classes = useStyles();
return (
<div className={classes.root}>
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
</div>
);
};
export const SidebarUserBadge: FC = () => {
const { isOpen } = useContext(SidebarContext);
const isUserLoggedIn = false;
return isUserLoggedIn ? (
<UserBadge
imageUrl="https://via.placeholder.com/200/200"
name="Victor Viale"
hideName={!isOpen}
/>
) : (
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
);
};
+28 -5
View File
@@ -16,11 +16,34 @@
import { createContext } from 'react';
const drawerWidthClosed = 72;
const iconPadding = 24;
const userBadgePadding = 18;
export const sidebarConfig = {
drawerWidthClosed: 64,
drawerWidthOpen: 220,
defaultOpenDelayMs: 400,
defaultCloseDelayMs: 200,
drawerWidthClosed,
drawerWidthOpen: 224,
// As per NN/g's guidance on timing for exposing hidden content
// See https://www.nngroup.com/articles/timing-exposing-content/
defaultOpenDelayMs: 300,
defaultCloseDelayMs: 0,
defaultFadeDuration: 200,
logoHeight: 32,
iconContainerWidth: drawerWidthClosed,
iconSize: drawerWidthClosed - iconPadding * 2,
iconPadding,
selectedIndicatorWidth: 3,
userBadgePadding,
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
};
export const SidebarContext = createContext<boolean>(false);
export const SIDEBAR_INTRO_LOCAL_STORAGE =
'@backstage/core/sidebar-intro-dismissed';
export type SidebarContextType = {
isOpen: boolean;
};
export const SidebarContext = createContext<SidebarContextType>({
isOpen: false,
});
@@ -17,5 +17,7 @@
export * from './Bar';
export * from './Page';
export * from './Items';
export * from './Intro';
export * from './UserBadge';
export * from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}
+3 -1
View File
@@ -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",
@@ -35,11 +36,12 @@
"@material-ui/icons": "^4.9.1",
"@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",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0"
},
+3 -2
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { hot } from 'react-hot-loader/root';
import React, { FC, ComponentType } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
@@ -90,10 +91,10 @@ class DevAppBuilder {
}
/**
* Build and render directory to #root element
* Build and render directory to #root element, with react hot loading.
*/
render(): void {
const DevApp = this.build();
const DevApp = hot(this.build());
const paths = this.findPluginPaths(this.plugins);
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}
+20 -21
View File
@@ -15,15 +15,10 @@ module.exports = {
webpackFinal: async (config) => {
const coreSrc = path.resolve(__dirname, '../../core/src');
config.resolve.alias = {
...config.resolve.alias,
// Resolves imports of @backstage/core inside the storybook config, pointing to src
'@backstage/core': coreSrc,
// Point to dist version of theme and any other packages that might be needed in the future
'@backstage/theme': path.resolve(__dirname, '../../theme'),
};
// Mirror config in packages/cli/src/lib/bundler
config.resolve.mainFields = ['main:src', 'browser', 'module', 'main'];
// Remove the default babel-loader for js files, we're using ts-loader instead
// Remove the default babel-loader for js files, we're using sucrase instead
const [jsLoader] = config.module.rules.splice(0, 1);
if (jsLoader.use[0].loader !== 'babel-loader') {
throw new Error(
@@ -33,20 +28,24 @@ module.exports = {
config.resolve.extensions.push('.ts', '.tsx');
// Use ts-loader for all JS/TS files
config.module.rules.push({
test: /\.(ts|tsx|mjs|js|jsx)$/,
include: [__dirname, coreSrc],
exclude: /node_modules/,
use: [
{
loader: require.resolve('ts-loader'),
options: {
transpileOnly: true,
},
config.module.rules.push(
{
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'],
},
},
);
// Disable ProgressPlugin which logs verbose webpack build progress. Warnings and Errors are still logged.
config.plugins = config.plugins.filter(
+2 -2
View File
@@ -4,8 +4,8 @@
"description": "Storybook build for core package",
"private": true,
"scripts": {
"start": "backstage-cli watch-deps --build -- start-storybook -p 6006",
"build-storybook": "backstage-cli watch-deps --build -- build-storybook --output-dir dist"
"start": "start-storybook -p 6006",
"build-storybook": "build-storybook --output-dir dist"
},
"workspaces": {
"nohoist": [
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": [".storybook/**/*"],
"compilerOptions": {}
}
+1
View File
@@ -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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}
+2 -1
View File
@@ -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",
@@ -33,7 +34,7 @@
"@material-ui/core": "^4.9.1",
"@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",
"react": "^16.12.0",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}
+1
View File
@@ -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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src"],
"compilerOptions": {}
}
+15
View File
@@ -0,0 +1,15 @@
# 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.
## graphiql
Some bad TS config in the alpha release - tracked here: https://github.com/graphql/graphiql/issues/1530
## material-table
Fixed in master but not released yet, tracked here: https://github.com/mbrn/material-table/pull/1624
+49
View File
@@ -0,0 +1,49 @@
diff --git a/node_modules/graphiql/dist/components/HistoryQuery.d.ts b/node_modules/graphiql/dist/components/HistoryQuery.d.ts
index c903bde..761b76b 100644
--- a/node_modules/graphiql/dist/components/HistoryQuery.d.ts
+++ b/node_modules/graphiql/dist/components/HistoryQuery.d.ts
@@ -1,5 +1,5 @@
import React from 'react';
-import { QueryStoreItem } from 'src/utility/QueryStore';
+import { QueryStoreItem } from '../utility/QueryStore';
export declare type HandleEditLabelFn = (query?: string, variables?: string, operationName?: string, label?: string, favorite?: boolean) => void;
export declare type HandleToggleFavoriteFn = (query?: string, variables?: string, operationName?: string, label?: string, favorite?: boolean) => void;
export declare type HandleSelectQueryFn = (query?: string, variables?: string, operationName?: string, label?: string) => void;
diff --git a/node_modules/graphiql/dist/components/QueryEditor.d.ts b/node_modules/graphiql/dist/components/QueryEditor.d.ts
index b508e92..35e7fb7 100644
--- a/node_modules/graphiql/dist/components/QueryEditor.d.ts
+++ b/node_modules/graphiql/dist/components/QueryEditor.d.ts
@@ -1,7 +1,7 @@
import React from 'react';
import * as CM from 'codemirror';
import { GraphQLSchema, GraphQLType } from 'graphql';
-import { SizerComponent } from 'src/utility/CodeMirrorSizer';
+import { SizerComponent } from '../utility/CodeMirrorSizer';
declare type QueryEditorProps = {
schema?: GraphQLSchema;
value?: string;
diff --git a/node_modules/graphiql/dist/components/QueryHistory.d.ts b/node_modules/graphiql/dist/components/QueryHistory.d.ts
index 409af29..9362657 100644
--- a/node_modules/graphiql/dist/components/QueryHistory.d.ts
+++ b/node_modules/graphiql/dist/components/QueryHistory.d.ts
@@ -1,7 +1,7 @@
import React from 'react';
import QueryStore, { QueryStoreItem } from '../utility/QueryStore';
import { HandleEditLabelFn, HandleToggleFavoriteFn, HandleSelectQueryFn } from './HistoryQuery';
-import StorageAPI from 'src/utility/StorageAPI';
+import StorageAPI from '../utility/StorageAPI';
declare type QueryHistoryProps = {
query?: string;
variables?: string;
diff --git a/node_modules/graphiql/dist/components/ResultViewer.d.ts b/node_modules/graphiql/dist/components/ResultViewer.d.ts
index 55976f5..11b725a 100644
--- a/node_modules/graphiql/dist/components/ResultViewer.d.ts
+++ b/node_modules/graphiql/dist/components/ResultViewer.d.ts
@@ -1,6 +1,6 @@
import React, { Component, FunctionComponent } from 'react';
import * as CM from 'codemirror';
-import { SizerComponent } from 'src/utility/CodeMirrorSizer';
+import { SizerComponent } from '../utility/CodeMirrorSizer';
import { ImagePreview as ImagePreviewComponent } from './ImagePreview';
declare type ResultViewerProps = {
value?: string;
+12
View File
@@ -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;
+3
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.5",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -9,6 +10,8 @@
"build": "tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
+1 -1
View File
@@ -28,7 +28,7 @@ export async function createRouter(
const logger = options.logger.child({ plugin: 'auth' });
const router = Router();
router.get('/ping', async (req, res) => {
router.get('/ping', async (_req, res) => {
res.status(200).send('pong');
});
+3
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.5",
"main": "dist",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"scripts": {
@@ -9,6 +10,8 @@
"build": "tsc",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
@@ -36,7 +36,7 @@ export async function createRouter(
if (itemsCatalog) {
// Components
router
.get('/components', async (req, res) => {
.get('/components', async (_req, res) => {
const components = await itemsCatalog.components();
res.status(200).send(components);
})
@@ -55,7 +55,7 @@ export async function createRouter(
const output = await locationsCatalog.addLocation(input);
res.status(201).send(output);
})
.get('/locations', async (req, res) => {
.get('/locations', async (_req, res) => {
const output = await locationsCatalog.locations();
res.status(200).send(output);
})
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.5",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -31,7 +32,7 @@
"@backstage/test-utils": "^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",
"@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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.5",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -32,7 +33,7 @@
"@backstage/test-utils": "^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",
"@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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+3 -1
View File
@@ -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",
@@ -46,7 +47,8 @@
"@backstage/test-utils": "^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",
"@testing-library/user-event": "^10.2.4",
"@types/codemirror": "^0.0.93",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
@@ -22,7 +22,7 @@ import { ApiProvider, ApiRegistry } from '@backstage/core';
import { renderWithEffects } from '@backstage/test-utils';
import { GraphQLBrowseApi, graphQlBrowseApiRef } from '../../lib/api';
jest.mock('components/GraphiQLBrowser', () => ({
jest.mock('../GraphiQLBrowser', () => ({
GraphiQLBrowser: () => '<GraphiQLBrowser />',
}));
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-home-page",
"version": "0.1.1-alpha.5",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -30,7 +31,7 @@
"@backstage/dev-utils": "^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",
"@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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-lighthouse",
"version": "0.1.1-alpha.5",
"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/test-utils": "^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",
"@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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-register-component",
"version": "0.1.1-alpha.5",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -31,7 +32,7 @@
"@backstage/dev-utils": "^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",
"@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",
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-scaffolder",
"version": "0.1.1-alpha.5",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -30,7 +31,7 @@
"@backstage/dev-utils": "^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",
"@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",
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../../tsconfig.json",
"include": ["src", "dev"],
"compilerOptions": {}
}
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-tech-radar",
"version": "0.1.1-alpha.5",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -34,7 +35,7 @@
"@backstage/dev-utils": "^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",
"@testing-library/user-event": "^10.2.4",
"@types/color": "^3.0.1",
"@types/d3-force": "^1.2.1",
"@types/jest": "^25.2.1",

Some files were not shown because too many files have changed in this diff Show More