diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml
index 10330976d7..07ed78743c 100644
--- a/.github/workflows/cli.yml
+++ b/.github/workflows/cli.yml
@@ -41,6 +41,7 @@ jobs:
node-version: ${{ matrix.node-version }}
- name: yarn install
run: yarn install --frozen-lockfile
+ - run: yarn tsc
- run: yarn build
- name: verify app and plugin creation on Windows
working-directory: ${{ runner.temp }}
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index c19583b653..d916141588 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -56,14 +56,20 @@ jobs:
- name: yarn install
run: yarn install --frozen-lockfile
- - name: verify plugin template
- run: yarn lerna -- run diff -- --check
-
- name: lint
run: yarn lerna -- run lint --since origin/master
- - name: build
- run: yarn build
+ - name: type checking and declarations
+ run: yarn tsc --incremental false
+
+ - name: build changed packages
+ if: ${{ steps.yarn-lock.outcome == 'success' }}
+ # Need to build all dependencies as well to be able to run tests later
+ run: yarn lerna -- run build --since origin/master --include-dependencies
+
+ - name: build all packages
+ if: ${{ steps.yarn-lock.outcome == 'failure' }}
+ run: yarn lerna -- run build
- name: test changed packages
if: ${{ steps.yarn-lock.outcome == 'success' }}
@@ -73,8 +79,11 @@ jobs:
if: ${{ steps.yarn-lock.outcome == 'failure' }}
run: yarn lerna -- run test -- --coverage
- - name: yarn bundle, if app was changed
- run: git diff --quiet origin/master HEAD -- yarn.lock packages/app packages/core || yarn bundle
+ - name: verify plugin template
+ run: yarn lerna -- run diff -- --check
+
+ - name: bundle example app
+ run: yarn bundle
- name: verify storybook
run: yarn workspace storybook build-storybook
diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml
index 6df9738bbc..a41f25c262 100644
--- a/.github/workflows/master.yml
+++ b/.github/workflows/master.yml
@@ -54,6 +54,9 @@ jobs:
- name: lint
run: yarn lerna -- run lint
+ - name: type checking and declarations
+ run: yarn tsc --incremental false
+
- name: build
run: yarn build
diff --git a/README.md b/README.md
index d2a0bbd3a9..fe73c1257c 100644
--- a/README.md
+++ b/README.md
@@ -62,11 +62,12 @@ To run a Backstage app, you will need to have the following installed:
- [NodeJS](https://nodejs.org/en/download/) - Active LTS Release, currently v12
- [yarn](https://classic.yarnpkg.com/en/docs/install)
-After cloning this repo, open a terminal window and start the web app using the following commands from the project root:
+After cloning this repo, open a terminal window and start the example app using the following commands from the project root:
```bash
-yarn install
-yarn start
+yarn install # Install dependencies
+
+yarn start # Start dev server, use --check to enable linting and type-checks
```
The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal.
diff --git a/docs/getting-started/app-custom-theme.md b/docs/getting-started/app-custom-theme.md
new file mode 100644
index 0000000000..3b8b1c5315
--- /dev/null
+++ b/docs/getting-started/app-custom-theme.md
@@ -0,0 +1,47 @@
+# Custom App Themes
+
+Backstage ships with a default theme with a light and dark mode variant. The themes are provided as
+a part of the [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) package, which also includes
+utilities for customizing the default theme, or creating completely new themes.
+
+## Creating a Custom Theme
+
+The easiest way to create a new theme is to use the `createTheme` function exported by the [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) package. You can use it to override so basic parameters of the default theme such as the color palette and font.
+
+For example, you can create a new theme based on the default light theme like this:
+
+```ts
+import { createTheme, lightTheme } from '@backstage/theme';
+
+const myTheme = createTheme({
+ palette: lightTheme.palette,
+ fontFamily: 'Comic Sans MS',
+});
+```
+
+If you want more control over the theme, and for example customize font sizes and margins, you can use the lower-level `createThemeOverrides` function exported by [@backstage/theme](https://www.npmjs.com/package/@backstage/theme) in combination with [createMuiTheme](https://material-ui.com/customization/theming/#createmuitheme-options-args-theme) from [@material-ui/core](https://www.npmjs.com/package/@material-ui/core). See the [@backstage/theme source](https://github.com/spotify/backstage/tree/master/packages/theme/src) and the implementation of the `createTheme` function for how this is done.
+
+You can also create a theme from scratch that matches the `BackstageTheme` type exported by [@backstage/theme](https://www.npmjs.com/package/@backstage/theme). See the [material-ui docs on theming](https://material-ui.com/customization/theming/) for more information about how that can be done.
+
+## Using your Custom Theme
+
+To add a custom theme to your Backstage app, you pass it as configuration to `createApp`.
+
+For example, adding the theme that we created in the previous section can be done like this:
+
+```ts
+import { createApp } from '@backstage/core';
+
+const app = createApp({
+ apis: ...,
+ plugins: ...,
+ themes: [{
+ id: 'my-theme',
+ title: 'My Custom Theme',
+ variant: 'light',
+ theme: myTheme,
+ }]
+})
+```
+
+Note that your list of custom themes overrides the default themes. If you still want to use the default themes, they are exported as `lightTheme` and `darkTheme` from [@backstage/theme](https://www.npmjs.com/package/@backstage/theme).
diff --git a/docs/getting-started/create-a-plugin.md b/docs/getting-started/create-a-plugin.md
index 7b8a7cf29b..64412c8b89 100644
--- a/docs/getting-started/create-a-plugin.md
+++ b/docs/getting-started/create-a-plugin.md
@@ -24,6 +24,15 @@ plugin directly by navigating to `http://localhost:3000/my-plugin`._
+You can also serve the plugin in isolation by running `yarn start` in the plugin directory. Or by using the yarn workspace command, for example:
+
+```bash
+yarn workspace @backstage/plugin-welcome start # Also supports --check
+```
+
+This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
+It is only meant for local development, and the setup for it can be found inside the plugin's `dev/` directory.
+
[Next Step - Structure of a plugin](structure-of-a-plugin.md)
[Back to Getting Started](README.md)
diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md
index 7f6e2bcb44..4dd2f7c9c7 100644
--- a/docs/getting-started/development-environment.md
+++ b/docs/getting-started/development-environment.md
@@ -1,5 +1,7 @@
# Development Environment
+## Serving the Example App
+
Open a terminal window and start the web app using the following commands from the project root:
```bash
@@ -21,6 +23,40 @@ You can now view example-app in the browser.
On Your Network: http://192.168.1.224:8080
```
+## Editor
+
+The Backstage development environment does not require any specific editor, but it is intended to be used with one that has built-in linting and type-checking. The development server does not include any checks by default, but they can be enabled using the `--check` flag. Note that using the flag may consume more system resources and slow things down.
+
+## Package Scripts
+
+There are many commands to be found in the root [package.json](package.json), here are some useful ones:
+
+```python
+yarn start # Start serving the example app, use --check to include type checks and linting
+
+yarn storybook # Start local storybook, useful for working on components in @backstage/core
+
+yarn workspace @backstage/plugin-welcome start # Serve welcome plugin only, also supports --check
+
+yarn tsc # Run typecheck, use --watch for watch mode
+
+yarn build # Build published versions of packages, depends on tsc
+
+yarn lint # lint packages that have changed since later commit on origin/master
+yarn lint:all # lint all packages
+
+yarn test # test packages that have changed since later commit on origin/master
+yarn test:all # test all packages
+
+yarn clean # Remove all output folders and @backstage/cli cache
+
+yarn bundle # Build a production bundle of the example app
+
+yarn diff # Make sure all plugins are up to date with the latest plugin template
+
+yarn create-plugin # Create a new plugin
+```
+
### (Optional)Try on Docker
Run the following commands if you have Docker environment
diff --git a/package.json b/package.json
index f98ac41ad2..e5028bfd2f 100644
--- a/package.json
+++ b/package.json
@@ -6,12 +6,13 @@
},
"scripts": {
"start": "yarn workspace example-app start",
- "bundle": "yarn build && yarn workspace example-app bundle",
+ "bundle": "yarn workspace example-app bundle",
"build": "lerna run build",
- "clean": "lerna run clean",
+ "tsc": "tsc",
+ "clean": "backstage-cli clean && lerna run clean",
"diff": "lerna run diff --",
- "test": "yarn build && lerna run test --since origin/master -- --coverage",
- "test:all": "yarn build && lerna run test -- --coverage",
+ "test": "lerna run test --since origin/master -- --coverage",
+ "test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"docker-build": "yarn bundle && docker build . -t spotify/backstage",
@@ -19,6 +20,7 @@
"remove-plugin": "backstage-cli remove-plugin",
"release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi",
"lerna": "lerna",
+ "postinstall": "patch-package",
"storybook": "yarn workspace storybook start"
},
"workspaces": {
@@ -33,6 +35,7 @@
"husky": "^4.2.3",
"lerna": "^3.20.2",
"lint-staged": "^10.1.0",
+ "patch-package": "^6.2.2",
"prettier": "^2.0.5"
},
"husky": {
diff --git a/packages/app/package.json b/packages/app/package.json
index e3b71820b6..cca428da76 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -26,9 +26,9 @@
},
"devDependencies": {
"@testing-library/cypress": "^6.0.0",
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index dd4bf42c2c..fc2e876468 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -14,8 +14,6 @@
* limitations under the License.
*/
-import { CssBaseline, ThemeProvider } from '@material-ui/core';
-import { lightTheme, darkTheme } from '@backstage/theme';
import { createApp } from '@backstage/core';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
@@ -23,7 +21,6 @@ import Root from './components/Root';
import AlertDisplay from './components/AlertDisplay';
import * as plugins from './plugins';
import apis, { alertApiForwarder } from './apis';
-import { ThemeContextType, ThemeContext, useThemeType } from './ThemeContext';
const app = createApp({
apis,
@@ -33,50 +30,15 @@ const app = createApp({
const AppProvider = app.getProvider();
const AppComponent = app.getRootComponent();
-const App: FC<{}> = () => {
- const [theme, toggleTheme] = useThemeType(
- localStorage.getItem('theme') || 'auto',
- );
-
- let backstageTheme = lightTheme;
- switch (theme) {
- case 'light':
- backstageTheme = lightTheme;
- break;
- case 'dark':
- backstageTheme = darkTheme;
- break;
- default:
- if (!window.matchMedia) {
- backstageTheme = lightTheme;
- break;
- }
- backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
- ? darkTheme
- : lightTheme;
- break;
- }
-
- const themeContext: ThemeContextType = {
- theme,
- toggleTheme,
- };
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-};
+const App: FC<{}> = () => (
+
+
+
+
+
+
+
+
+);
export default App;
diff --git a/packages/app/src/ThemeContext.tsx b/packages/app/src/ThemeContext.tsx
deleted file mode 100644
index 3d5a04f22a..0000000000
--- a/packages/app/src/ThemeContext.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import React, { useState, useEffect } from 'react';
-export type ThemeContextType = {
- theme: string;
- toggleTheme: () => void;
-};
-export const ThemeContext = React.createContext({
- theme: 'light',
- toggleTheme: () => {},
-});
-
-export function useThemeType(themeId: string): [string, () => void] {
- const [theme, setTheme] = useState(themeId);
- useEffect(() => {
- if (!window.matchMedia) {
- return () => {};
- }
- const mql = window.matchMedia('(prefers-color-scheme: dark)');
- const darkListener = (event: MediaQueryListEvent) => {
- if (localStorage.getItem('theme') === 'auto') {
- if (event.matches) {
- setTheme('dark');
- } else {
- setTheme('light');
- }
- setTheme('auto');
- }
- };
- mql.addListener(darkListener);
- return () => {
- mql.removeListener(darkListener);
- };
- });
- function toggleTheme() {
- if (theme === 'light') {
- setTheme('dark');
- localStorage.setItem('theme', 'dark');
- } else if (theme === 'dark') {
- if (!window.matchMedia) {
- setTheme('light');
- localStorage.setItem('theme', 'light');
- setTheme('light');
- return;
- }
- setTheme('auto');
- localStorage.setItem('theme', 'auto');
- setTheme('auto');
- } else {
- setTheme('light');
- localStorage.setItem('theme', 'light');
- }
- }
- return [theme, toggleTheme];
-}
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 55cba439bd..c26336c18b 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -19,27 +19,30 @@ import PropTypes from 'prop-types';
import { Link, makeStyles, Typography } from '@material-ui/core';
import HomeIcon from '@material-ui/icons/Home';
import ExploreIcon from '@material-ui/icons/Explore';
-import AccountCircle from '@material-ui/icons/AccountCircle';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import AccountTreeIcon from '@material-ui/icons/AccountTree';
+
import {
Sidebar,
SidebarPage,
sidebarConfig,
SidebarContext,
SidebarItem,
- SidebarSpacer,
SidebarDivider,
+ SidebarSearchField,
SidebarSpace,
+ SidebarUserBadge,
+ SidebarThemeToggle,
} from '@backstage/core';
-import ToggleThemeSidebarItem from './ToggleThemeSidebarItem';
const useSidebarLogoStyles = makeStyles({
root: {
- height: sidebarConfig.drawerWidthClosed,
+ width: sidebarConfig.drawerWidthClosed,
+ height: 3 * sidebarConfig.logoHeight,
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
+ marginBottom: -14,
},
logoContainer: {
width: sidebarConfig.drawerWidthClosed,
@@ -48,9 +51,9 @@ const useSidebarLogoStyles = makeStyles({
justifyContent: 'center',
},
title: {
- fontSize: 24,
+ fontSize: sidebarConfig.logoHeight,
fontWeight: 'bold',
- marginLeft: 22,
+ marginLeft: 20,
whiteSpace: 'nowrap',
color: '#fff',
},
@@ -61,7 +64,7 @@ const useSidebarLogoStyles = makeStyles({
const SidebarLogo: FC<{}> = () => {
const classes = useSidebarLogoStyles();
- const isOpen = useContext(SidebarContext);
+ const { isOpen } = useContext(SidebarContext);
return (
@@ -75,21 +78,30 @@ const SidebarLogo: FC<{}> = () => {
);
};
+const handleSearch = (query: string): void => {
+ // XXX (@koroeskohr): for testing purposes
+ // eslint-disable-next-line no-console
+ console.log(query);
+};
+
const Root: FC<{}> = ({ children }) => (
-
+
+ {/* Global nav, not org-specific */}
+ {/* End global nav */}
-
-
+
+
+
{children}
diff --git a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx
deleted file mode 100644
index 6c0cd09244..0000000000
--- a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import React, { FC } from 'react';
-import { SidebarItem } from '@backstage/core';
-import { ThemeContext } from '../../ThemeContext';
-import WbSunnyIcon from '@material-ui/icons/WbSunny';
-import Brightness2Icon from '@material-ui/icons/Brightness2';
-import ToggleOnIcon from '@material-ui/icons/ToggleOn';
-const ToggleThemeSidebarItem: FC<{}> = () => {
- return (
-
- {themeContext => {
- let text = 'Auto';
- let icon = ToggleOnIcon;
- switch (themeContext.theme) {
- case 'dark':
- text = 'Dark mode';
- icon = Brightness2Icon;
- break;
- case 'light':
- text = 'Light mode';
- icon = WbSunnyIcon;
- break;
- default:
- text = 'Auto';
- icon = ToggleOnIcon;
- break;
- }
-
- return (
-
- );
- }}
-
- );
-};
-
-export default ToggleThemeSidebarItem;
diff --git a/packages/app/src/setupTests.ts b/packages/app/src/setupTests.ts
index 17ff0c096d..c717b2753b 100644
--- a/packages/app/src/setupTests.ts
+++ b/packages/app/src/setupTests.ts
@@ -18,4 +18,4 @@
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json
deleted file mode 100644
index 43ab6d4006..0000000000
--- a/packages/app/tsconfig.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "extends": "@spotify/web-scripts/config/tsconfig.json",
- "compilerOptions": {
- "target": "es5",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "esModuleInterop": true,
- "allowSyntheticDefaultImports": true,
- "strict": true,
- "forceConsistentCasingInFileNames": true,
- "module": "esnext",
- "moduleResolution": "node",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react",
- "incremental": false,
- "types": ["node", "jest"]
- },
- "include": ["src"]
-}
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 3a24067da4..8643cb3c25 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -19,6 +19,7 @@
"@backstage/backend-common": "^0.1.1-alpha.5",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.5",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.5",
+ "@backstage/plugin-auth-backend": "^0.1.1-alpha.5",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 8a7a4c9b72..ed458dedb3 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -35,6 +35,7 @@ import helmet from 'helmet';
import knex from 'knex';
import catalog from './plugins/catalog';
import scaffolder from './plugins/scaffolder';
+import auth from './plugins/auth';
import { PluginEnvironment } from './types';
const DEFAULT_PORT = 7000;
@@ -63,6 +64,7 @@ async function main() {
app.use(requestLoggingHandler());
app.use('/catalog', await catalog(createEnv('catalog')));
app.use('/scaffolder', await scaffolder(createEnv('scaffolder')));
+ app.use('/auth', await auth(createEnv('auth')));
app.use(notFoundHandler());
app.use(errorHandler());
diff --git a/packages/backend/src/plugins/auth.ts b/packages/backend/src/plugins/auth.ts
new file mode 100644
index 0000000000..c2e349f640
--- /dev/null
+++ b/packages/backend/src/plugins/auth.ts
@@ -0,0 +1,22 @@
+/*
+ * 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 { createRouter } from '@backstage/plugin-auth-backend';
+import { PluginEnvironment } from '../types';
+
+export default async function ({ logger }: PluginEnvironment) {
+ return await createRouter({ logger });
+}
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 6de2cf75d5..0c6bfbe8e7 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -57,6 +57,19 @@ module.exports = {
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
],
+
+ // Importing the entire MUI icons packages kills build performance as the list of icons is huge.
+ 'no-restricted-imports': [
+ 2,
+ {
+ paths: [
+ {
+ name: '@material-ui/icons',
+ message: "Please import '@material-ui/icons/' instead.",
+ },
+ ],
+ },
+ ],
},
overrides: [
{
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index 77f345a0dd..3f37a0df4c 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -14,44 +14,68 @@
* limitations under the License.
*/
-const fs = require('fs');
+const fs = require('fs-extra');
const path = require('path');
-// If the package has it's own jest config, we use that instead. It will have to
-// manually extend @spotify/web-scripts/config/jest.config.js that is desired
-if (fs.existsSync('jest.config.js')) {
- module.exports = require(path.resolve('jest.config.js'));
-} else if (fs.existsSync('jest.config.ts')) {
- module.exports = require(path.resolve('jest.config.ts'));
-} else {
- const extraOptions = {
- modulePaths: [''],
- moduleNameMapper: {
- '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
- },
+async function getConfig() {
+ // If the package has it's own jest config, we use that instead.
+ if (await fs.pathExists('jest.config.js')) {
+ return require(path.resolve('jest.config.js'));
+ } else if (await fs.pathExists('jest.config.ts')) {
+ return require(path.resolve('jest.config.ts'));
+ }
+
+ const moduleNameMapper = {
+ '\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
+ };
+
+ // Only point to src/ if we're not in CI, there we just build packages first anyway
+ if (!process.env.CI) {
+ const LernaProject = require('@lerna/project');
+ const project = new LernaProject(path.resolve('.'));
+ const packages = await project.getPackages();
+
+ // To avoid having to build all deps inside the monorepo before running tests,
+ // we point directory to src/ where applicable.
+ // For example, @backstage/core = /packages/core/src/index.ts is added to moduleNameMapper
+ for (const pkg of packages) {
+ const mainSrc = pkg.get('main:src');
+ if (mainSrc) {
+ moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
+ }
+ }
+ }
+
+ const options = {
+ rootDir: path.resolve('src'),
+ coverageDirectory: path.resolve('coverage'),
+ collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
+ moduleNameMapper,
+
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
// TODO: jest is working on module support, it's possible that we can remove this in the future
transform: {
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
+ '\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'),
},
- // Default behaviour is to not apply transforms for node_modules, but we still want to tranform .esm.js files
+
+ // Default behaviour is to not apply transforms for node_modules, but we still want
+ // to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'],
};
// Use src/setupTests.ts as the default location for configuring test env
if (fs.existsSync('src/setupTests.ts')) {
- extraOptions.setupFilesAfterEnv = ['/setupTests.ts'];
+ options.setupFilesAfterEnv = ['/setupTests.ts'];
}
- module.exports = {
- // We base the jest config on web-scripts, it's pretty flat so we skip any complex merging of config objects
- // Config can be found here: https://github.com/spotify/web-scripts/blob/master/packages/web-scripts/config/jest.config.js
- ...require('@spotify/web-scripts/config/jest.config.js'),
-
- ...extraOptions,
+ return {
+ ...options,
// If the package has a jest object in package.json we merge that config in. This is the recommended
// location for configuring tests.
...require(path.resolve('package.json')).jest,
};
}
+
+module.exports = getConfig();
diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json
index f905dcb771..652e990387 100644
--- a/packages/cli/config/tsconfig.json
+++ b/packages/cli/config/tsconfig.json
@@ -4,9 +4,11 @@
"compilerOptions": {
"allowJs": true,
"noEmit": false,
+ "emitDeclarationOnly": true,
"incremental": true,
"target": "ES2019",
"module": "ESNext",
+ "removeComments": false,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 7183320136..c3d768eec2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -29,15 +29,19 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
+ "@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
"@rollup/plugin-commonjs": "^11.0.2",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@spotify/web-scripts": "^6.0.0",
+ "@sucrase/webpack-loader": "^2.0.0",
+ "bfj": "^7.0.2",
"chalk": "^4.0.0",
"chokidar": "^3.3.1",
"commander": "^4.1.1",
+ "css-loader": "^3.5.3",
"dashify": "^2.0.0",
"diff": "^4.0.2",
"eslint-plugin-import": "^2.20.2",
@@ -50,27 +54,37 @@
"jest": "^25.1.0",
"jest-css-modules": "^2.1.0",
"jest-esm-transformer": "^1.0.0",
+ "mini-css-extract-plugin": "^0.9.0",
"ora": "^4.0.3",
+ "raw-loader": "^4.0.1",
"react": "^16.0.0",
"react-dev-utils": "^10.2.0",
- "react-scripts": "^3.4.1",
+ "react-hot-loader": "^4.12.21",
"recursive-readdir": "^2.2.2",
"replace-in-file": "^6.0.0",
"rollup": "^2.3.2",
+ "rollup-plugin-dts": "^1.4.6",
+ "rollup-plugin-esbuild": "^1.4.1",
"rollup-plugin-image-files": "^1.4.2",
"rollup-plugin-peer-deps-external": "^2.2.2",
"rollup-plugin-postcss": "^3.1.1",
"rollup-plugin-typescript2": "^0.26.0",
+ "style-loader": "^1.2.1",
+ "sucrase": "^3.14.1",
"tar": "^6.0.1",
"ts-loader": "^7.0.4",
+ "url-loader": "^4.1.0",
"webpack": "^4.41.6",
- "webpack-dev-server": "^3.10.3"
+ "webpack-dev-server": "^3.10.3",
+ "yml-loader": "^2.1.0",
+ "yn": "^4.0.0"
},
"devDependencies": {
"@types/diff": "^4.0.2",
"@types/fs-extra": "^8.1.0",
"@types/html-webpack-plugin": "^3.2.2",
"@types/inquirer": "^6.5.0",
+ "@types/mini-css-extract-plugin": "^0.9.1",
"@types/node": "^13.7.2",
"@types/ora": "^3.2.0",
"@types/react-dev-utils": "^9.0.4",
diff --git a/packages/cli/src/commands/app/build.ts b/packages/cli/src/commands/app/build.ts
index 362f79a7fa..c654baa439 100644
--- a/packages/cli/src/commands/app/build.ts
+++ b/packages/cli/src/commands/app/build.ts
@@ -14,15 +14,12 @@
* limitations under the License.
*/
-import { run } from '../../lib/run';
+import { buildBundle } from '../../lib/bundler';
+import { Command } from 'commander';
-export default async () => {
- const args = ['build'];
-
- await run('react-scripts', args, {
- env: {
- EXTEND_ESLINT: 'true',
- SKIP_PREFLIGHT_CHECK: 'true',
- },
+export default async (cmd: Command) => {
+ await buildBundle({
+ entry: 'src/index',
+ statsJsonEnabled: cmd.stats,
});
};
diff --git a/packages/cli/src/commands/app/serve.ts b/packages/cli/src/commands/app/serve.ts
index 1d0ccfc465..182e338287 100644
--- a/packages/cli/src/commands/app/serve.ts
+++ b/packages/cli/src/commands/app/serve.ts
@@ -14,20 +14,14 @@
* limitations under the License.
*/
-import { run } from '../../lib/run';
-import { createLogFunc } from '../../lib/logging';
-import { watchDeps } from '../../lib/watchDeps';
+import { serveBundle } from '../../lib/bundler';
+import { Command } from 'commander';
-export default async () => {
- // Start dynamic watch and build of dependencies, then serve the app
- await watchDeps({ build: true });
-
- await run('react-scripts', ['start'], {
- env: {
- EXTEND_ESLINT: 'true',
- SKIP_PREFLIGHT_CHECK: 'true',
- },
- // We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
- stdoutLogFunc: createLogFunc(process.stdout),
+export default async (cmd: Command) => {
+ const waitForExit = await serveBundle({
+ entry: 'src/index',
+ checksEnabled: cmd.check,
});
+
+ await waitForExit();
};
diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts
index bfb78762d9..e28cb376b9 100644
--- a/packages/cli/src/commands/create-app/createApp.ts
+++ b/packages/cli/src/commands/create-app/createApp.ts
@@ -21,7 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer';
import { exec as execCb } from 'child_process';
import { resolve as resolvePath } from 'path';
import os from 'os';
-import { Task, templatingTask } from '../../lib/tasks';
+import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
const exec = promisify(execCb);
@@ -57,19 +57,22 @@ async function cleanUp(tempDir: string) {
});
}
-async function buildApp(appFolder: string) {
- const commands = ['yarn install', 'yarn build'];
- for (const command of commands) {
- await Task.forItem('executing', command, async () => {
- process.chdir(appFolder);
+async function buildApp(appDir: string) {
+ const runCmd = async (cmd: string) => {
+ await Task.forItem('executing', cmd, async () => {
+ process.chdir(appDir);
- await exec(command).catch((error) => {
+ await exec(cmd).catch((error) => {
process.stdout.write(error.stderr);
process.stdout.write(error.stdout);
- throw new Error(`Could not execute command ${chalk.cyan(command)}`);
+ throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
});
});
- }
+ };
+
+ await installWithLocalDeps(appDir);
+ await runCmd('yarn tsc');
+ await runCmd('yarn build');
}
export async function moveApp(
@@ -86,44 +89,6 @@ export async function moveApp(
});
}
-async function addPackageResolutions(appDir: string) {
- const pkgJsonPath = resolvePath(appDir, 'package.json');
- const pkgJson = await fs.readJson(pkgJsonPath);
-
- pkgJson.resolutions = pkgJson.resolutions || {};
- pkgJson.dependencies = pkgJson.dependencies || {};
-
- const depNames = [
- 'cli',
- 'core',
- 'dev-utils',
- 'test-utils',
- 'test-utils-core',
- 'theme',
- ];
-
- for (const name of depNames) {
- await Task.forItem(
- 'adding',
- `@backstage/${name} link to package.json`,
- async () => {
- const pkgPath = paths.resolveOwnRoot('packages', name);
- // Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
- pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
- pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
-
- await fs
- .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
- .catch((error) => {
- throw new Error(
- `Failed to add resolutions to package.json: ${error.message}`,
- );
- });
- },
- );
- }
-}
-
export default async () => {
const questions: Question[] = [
{
@@ -164,12 +129,6 @@ export default async () => {
Task.section('Moving to final location');
await moveApp(tempDir, appDir, answers.name);
- // e2e testing needs special treatment
- if (process.env.BACKSTAGE_E2E_CLI_TEST) {
- Task.section('Linking packages locally for e2e tests');
- await addPackageResolutions(appDir);
- }
-
Task.section('Building the app');
await buildApp(appDir);
diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts
index b0bf1d85b5..1441b16d05 100644
--- a/packages/cli/src/commands/create-plugin/createPlugin.ts
+++ b/packages/cli/src/commands/create-plugin/createPlugin.ts
@@ -28,7 +28,7 @@ import {
} from '../../lib/codeowners';
import { paths } from '../../lib/paths';
import { version } from '../../lib/version';
-import { Task, templatingTask } from '../../lib/tasks';
+import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
const exec = promisify(execCb);
async function checkExists(rootDir: string, id: string) {
@@ -141,7 +141,9 @@ async function cleanUp(tempDir: string) {
}
async function buildPlugin(pluginFolder: string) {
- const commands = ['yarn install', 'yarn build'];
+ await installWithLocalDeps(paths.targetRoot);
+
+ const commands = ['yarn tsc', 'yarn build'];
for (const command of commands) {
await Task.forItem('executing', command, async () => {
process.chdir(pluginFolder);
diff --git a/packages/cli/src/commands/plugin/build.ts b/packages/cli/src/commands/plugin/build.ts
index fcda3f424b..561d79ed55 100644
--- a/packages/cli/src/commands/plugin/build.ts
+++ b/packages/cli/src/commands/plugin/build.ts
@@ -14,85 +14,8 @@
* limitations under the License.
*/
-import { rollup, watch, OutputOptions } from 'rollup';
-import { Command } from 'commander';
-import chalk from 'chalk';
-import { withCache, getDefaultCacheOptions } from '../../lib/buildCache';
-import { paths } from '../../lib/paths';
-import conf from './rollup.config';
+import { buildPackage } from '../../lib/packager';
-function logError(error: any) {
- console.log('');
-
- if (error.code === 'PLUGIN_ERROR') {
- // typescript2 plugin has a complete message with all codeframes
- if (error.plugin === 'rpt2') {
- console.log(error.message);
- } else {
- // Log which plugin is causing errors to make it easier to identity.
- // If we see these in logs we likely want to provide some custom error
- // output for those plugins too.
- console.log(`(plugin ${error.plugin}) ${error}`);
- }
- } else {
- // Generic rollup errors, log what's available
- if (error.loc) {
- const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
- const pos = `${error.loc.line}:${error.loc.column}`;
- console.log(`${file} [${pos}]`);
- } else if (error.id) {
- console.log(paths.resolveTarget(error.id));
- }
-
- console.log(String(error));
-
- if (error.url) {
- console.log(chalk.cyan(error.url));
- }
-
- if (error.frame) {
- console.log(chalk.dim(error.frame));
- }
- }
-}
-
-export default async (cmd: Command) => {
- if (cmd.watch) {
- // We're not resolving this promise because watch() doesn't have any exit event.
- // Instead we just wait until the user sends an interrupt signal.
- await new Promise(() => {
- const watcher = watch(conf);
- watcher.on('event', (event) => {
- // START — the watcher is (re)starting
- // BUNDLE_START — building an individual bundle
- // BUNDLE_END — finished building a bundle
- // END — finished building all bundles
- // ERROR — encountered an error while bundling
-
- if (event.code === 'ERROR') {
- logError(event.error);
- } else if (event.code === 'BUNDLE_START') {
- console.log(chalk.dim(`building ${event.input}`));
- } else if (event.code === 'BUNDLE_END') {
- const { input, duration } = event;
- const s = (duration / 1000).toFixed(1);
- console.log(chalk.green(`built ${input} in ${s}s`));
- }
- });
- });
- }
-
- await withCache(getDefaultCacheOptions(), async () => {
- try {
- const bundle = await rollup({
- input: conf.input,
- plugins: conf.plugins,
- });
- await bundle.generate(conf.output as OutputOptions);
- await bundle.write(conf.output as OutputOptions);
- } catch (error) {
- logError(error);
- process.exit(1);
- }
- });
+export default async () => {
+ await buildPackage();
};
diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts
deleted file mode 100644
index 36b43bf5a7..0000000000
--- a/packages/cli/src/commands/plugin/rollup.config.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import peerDepsExternal from 'rollup-plugin-peer-deps-external';
-import typescript from 'rollup-plugin-typescript2';
-import commonjs from '@rollup/plugin-commonjs';
-import resolve from '@rollup/plugin-node-resolve';
-import postcss from 'rollup-plugin-postcss';
-import imageFiles from 'rollup-plugin-image-files';
-import json from '@rollup/plugin-json';
-import { RollupWatchOptions } from 'rollup';
-import { paths } from '../../lib/paths';
-
-export default {
- input: 'src/index.ts',
- output: {
- file: 'dist/index.esm.js',
- format: 'module',
- },
- plugins: [
- peerDepsExternal({
- includeDependencies: true,
- }),
- resolve({
- mainFields: ['browser', 'module', 'main'],
- }),
- commonjs({
- include: ['node_modules/**', '../../node_modules/**'],
- exclude: ['**/*.stories.*', '**/*.test.*'],
- }),
- postcss(),
- imageFiles(),
- json(),
- typescript({
- include: `${paths.resolveTarget('src')}/**/*.{js,jsx,ts,tsx}`,
- tsconfigOverride: {
- // The dev folder is for the local plugin serve, ignore it in the build
- // If we don't do this we get a folder structure similar to dist/{src,dev}/...
- exclude: ['dev'],
- compilerOptions: {
- // Use absolute path to src dir as root for declarations, relying on the default
- // seems to produce declaration maps that are relative to dist/ instead of src/
- // Using a relative path like ../src doesn't work either becaus it will be used as is in subdirs.
- sourceRoot: paths.resolveTarget('src'),
- },
- },
- clean: true,
- }),
- ],
-} as RollupWatchOptions;
diff --git a/packages/cli/src/commands/plugin/serve/index.ts b/packages/cli/src/commands/plugin/serve.ts
similarity index 70%
rename from packages/cli/src/commands/plugin/serve/index.ts
rename to packages/cli/src/commands/plugin/serve.ts
index 9bdd46dc84..5700992644 100644
--- a/packages/cli/src/commands/plugin/serve/index.ts
+++ b/packages/cli/src/commands/plugin/serve.ts
@@ -14,14 +14,14 @@
* limitations under the License.
*/
-import { startDevServer } from './server';
-import { watchDeps } from '../../../lib/watchDeps';
+import { serveBundle } from '../../lib/bundler';
+import { Command } from 'commander';
-export default async () => {
- await watchDeps({ build: true });
+export default async (cmd: Command) => {
+ const waitForExit = await serveBundle({
+ entry: 'dev/index',
+ checksEnabled: cmd.check,
+ });
- await startDevServer();
-
- // Wait for interrupt signal
- await new Promise(() => {});
+ await waitForExit();
};
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index cf89a3c47d..d09ad61ed5 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -30,11 +30,13 @@ const main = (argv: string[]) => {
program
.command('app:build')
.description('Build an app for a production release')
+ .option('--stats', 'Write bundle stats to output directory')
.action(actionHandler(() => require('./commands/app/build')));
program
.command('app:serve')
.description('Serve an app for local development')
+ .option('--check', 'Enable type checking and linting')
.action(actionHandler(() => require('./commands/app/serve')));
program
@@ -53,13 +55,13 @@ const main = (argv: string[]) => {
program
.command('plugin:build')
- .option('--watch', 'Enable watch mode')
.description('Build a plugin')
.action(actionHandler(() => require('./commands/plugin/build')));
program
.command('plugin:serve')
.description('Serves the dev/ folder of a plugin')
+ .option('--check', 'Enable type checking and linting')
.action(actionHandler(() => require('./commands/plugin/serve')));
program
@@ -155,7 +157,7 @@ function actionHandler(
};
}
-process.on('unhandledRejection', (rejection) => {
+process.on('unhandledRejection', rejection => {
if (rejection instanceof Error) {
exitWithError(rejection);
} else {
diff --git a/packages/cli/src/lib/bundler/bundle.ts b/packages/cli/src/lib/bundler/bundle.ts
new file mode 100644
index 0000000000..c7860c0f42
--- /dev/null
+++ b/packages/cli/src/lib/bundler/bundle.ts
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import yn from 'yn';
+import fs from 'fs-extra';
+import { resolve as resolvePath } from 'path';
+import webpack from 'webpack';
+import {
+ measureFileSizesBeforeBuild,
+ printFileSizesAfterBuild,
+} from 'react-dev-utils/FileSizeReporter';
+import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
+import { createConfig } from './config';
+import { BuildOptions } from './types';
+import { resolveBundlingPaths } from './paths';
+import chalk from 'chalk';
+
+// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
+const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
+const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
+
+export async function buildBundle(options: BuildOptions) {
+ const { statsJsonEnabled } = options;
+
+ const paths = resolveBundlingPaths(options);
+ const config = createConfig(paths, {
+ ...options,
+ checksEnabled: false,
+ isDev: false,
+ });
+ const compiler = webpack(config);
+
+ const isCi = yn(process.env.CI, { default: false });
+
+ const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
+ await fs.emptyDir(paths.targetDist);
+
+ const { stats } = await build(compiler, isCi).catch(error => {
+ console.log(chalk.red('Failed to compile.\n'));
+ throw new Error(`Failed to compile.\n${error.message || error}`);
+ });
+
+ if (statsJsonEnabled) {
+ // No @types/bfj
+ await require('bfj').write(
+ resolvePath(paths.targetDist, 'bundle-stats.json'),
+ stats.toJson(),
+ );
+ }
+
+ printFileSizesAfterBuild(
+ stats,
+ previousFileSizes,
+ paths.targetDist,
+ WARN_AFTER_BUNDLE_GZIP_SIZE,
+ WARN_AFTER_CHUNK_GZIP_SIZE,
+ );
+}
+
+async function build(compiler: webpack.Compiler, isCi: boolean) {
+ const stats = await new Promise((resolve, reject) => {
+ compiler.run((err, buildStats) => {
+ if (err) {
+ if (err.message) {
+ const { errors } = formatWebpackMessages({
+ errors: [err.message],
+ warnings: new Array(),
+ } as webpack.Stats.ToJsonOutput);
+
+ throw new Error(errors[0]);
+ } else {
+ reject(err);
+ }
+ } else {
+ resolve(buildStats);
+ }
+ });
+ });
+
+ const { errors, warnings } = formatWebpackMessages(
+ stats.toJson({ all: false, warnings: true, errors: true }),
+ );
+
+ if (errors.length) {
+ // Only keep the first error. Others are often indicative
+ // of the same problem, but confuse the reader with noise.
+ throw new Error(errors[0]);
+ }
+ if (isCi && warnings.length) {
+ console.log(
+ chalk.yellow(
+ '\nTreating warnings as errors because process.env.CI = true.\n',
+ ),
+ );
+ throw new Error(warnings.join('\n\n'));
+ }
+
+ return { stats };
+}
diff --git a/packages/cli/src/commands/plugin/serve/config.ts b/packages/cli/src/lib/bundler/config.ts
similarity index 54%
rename from packages/cli/src/commands/plugin/serve/config.ts
rename to packages/cli/src/lib/bundler/config.ts
index 47d7918270..a35c22e3bd 100644
--- a/packages/cli/src/commands/plugin/serve/config.ts
+++ b/packages/cli/src/lib/bundler/config.ts
@@ -15,91 +15,28 @@
*/
import webpack from 'webpack';
-import HtmlWebpackPlugin from 'html-webpack-plugin';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
-import { Paths } from './paths';
+import { BundlingPaths } from './paths';
+import { transforms } from './transforms';
+import { optimization } from './optimization';
+import { BundlingOptions } from './types';
// import checkRequiredFiles from 'react-dev-utils/checkRequiredFiles';
// import ModuleNotFoundPlugin from 'react-dev-utils/ModuleNotFoundPlugin';
// import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware';
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
-export function createConfig(paths: Paths): webpack.Configuration {
- return {
- mode: 'development',
- profile: false,
- bail: false,
- devtool: 'cheap-module-eval-source-map',
- context: paths.targetPath,
- entry: [
- `${require.resolve('webpack-dev-server/client')}?/`,
- require.resolve('webpack/hot/dev-server'),
- paths.targetDevEntry,
- ],
- resolve: {
- extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
- plugins: [
- new ModuleScopePlugin(
- [paths.targetSrc, paths.targetDev],
- [paths.targetPackageJson],
- ),
- ],
- },
- module: {
- rules: [
- {
- test: /\.(tsx?|jsx?|mjs)$/,
- enforce: 'pre',
- include: [paths.targetSrc, paths.targetDev],
- use: {
- loader: 'eslint-loader',
- options: {
- emitWarning: true,
- },
- },
- },
- {
- test: /\.(tsx?|jsx?|mjs)$/,
- include: [paths.targetSrc, paths.targetDev],
- exclude: /node_modules/,
- loader: 'ts-loader',
- options: {
- // disable type checker - handled by ForkTsCheckerWebpackPlugin
- transpileOnly: true,
- },
- },
- {
- test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
- loader: 'url-loader',
- include: paths.targetAssets,
- options: {
- limit: 10000,
- name: 'static/media/[name].[hash:8].[ext]',
- },
- },
- {
- test: /\.ya?ml$/,
- use: 'yml-loader',
- },
- {
- include: /\.(md)$/,
- use: 'raw-loader',
- },
- {
- test: /\.css$/i,
- use: ['style-loader', 'css-loader'],
- },
- ],
- },
- output: {
- publicPath: '/',
- filename: 'bundle.js',
- },
- plugins: [
- new HtmlWebpackPlugin({
- template: paths.targetHtml,
- }),
+export function createConfig(
+ paths: BundlingPaths,
+ options: BundlingOptions,
+): webpack.Configuration {
+ const { checksEnabled, isDev } = options;
+
+ const { plugins, loaders } = transforms(paths, options);
+
+ if (checksEnabled) {
+ plugins.push(
new ForkTsCheckerWebpackPlugin({
tsconfig: paths.targetTsConfig,
eslint: true,
@@ -111,8 +48,45 @@ export function createConfig(paths: Paths): webpack.Configuration {
},
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
}),
- new webpack.HotModuleReplacementPlugin(),
- ],
+ );
+ }
+
+ return {
+ mode: isDev ? 'development' : 'production',
+ profile: false,
+ bail: false,
+ performance: {
+ hints: false, // we check the gzip size instead
+ },
+ devtool: isDev ? 'cheap-module-eval-source-map' : 'source-map',
+ context: paths.targetPath,
+ entry: [require.resolve('react-hot-loader/patch'), paths.targetEntry],
+ resolve: {
+ extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
+ mainFields: ['main:src', 'browser', 'module', 'main'],
+ plugins: [
+ new ModuleScopePlugin(
+ [paths.targetSrc, paths.targetDev],
+ [paths.targetPackageJson],
+ ),
+ ],
+ alias: {
+ 'react-dom': '@hot-loader/react-dom',
+ },
+ },
+ module: {
+ rules: loaders,
+ },
+ output: {
+ path: paths.targetDist,
+ publicPath: '/',
+ filename: isDev ? '[name].js' : '[name].[hash:8].js',
+ chunkFilename: isDev
+ ? '[name].chunk.js'
+ : '[name].[chunkhash:8].chunk.js',
+ },
+ optimization: optimization(options),
+ plugins,
node: {
module: 'empty',
dgram: 'empty',
diff --git a/packages/cli/src/lib/bundler/index.ts b/packages/cli/src/lib/bundler/index.ts
new file mode 100644
index 0000000000..cdfb5f3897
--- /dev/null
+++ b/packages/cli/src/lib/bundler/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { buildBundle } from './bundle';
+export { serveBundle } from './server';
diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts
new file mode 100644
index 0000000000..03168b376d
--- /dev/null
+++ b/packages/cli/src/lib/bundler/optimization.ts
@@ -0,0 +1,67 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Options } from 'webpack';
+import { BundlingOptions } from './types';
+
+export const optimization = (
+ options: BundlingOptions,
+): Options.Optimization => {
+ const { isDev } = options;
+
+ return {
+ minimize: !isDev,
+ runtimeChunk: 'single',
+ splitChunks: {
+ automaticNameDelimiter: '-',
+ cacheGroups: {
+ default: false,
+ // Put all vendor code needed for initial page load in individual files if they're big
+ // enough, if they're smaller they end up in the main
+ packages: {
+ chunks: 'initial',
+ test: /[\\/]node_modules[\\/]/,
+ name(module: any) {
+ // get the name. E.g. node_modules/packageName/not/this/part.js
+ // or node_modules/packageName
+ const packageName = module.context.match(
+ /[\\/]node_modules[\\/](.*?)([\\/]|$)/,
+ )[1];
+
+ // npm package names are URL-safe, but some servers don't like @ symbols
+ return packageName.replace('@', '');
+ },
+ filename: isDev
+ ? 'module-[name].js'
+ : 'module-[name].[chunkhash:8].js',
+ priority: 10,
+ minSize: 100000,
+ minChunks: 1,
+ maxAsyncRequests: Infinity,
+ maxInitialRequests: Infinity,
+ } as any, // filename is not included in type, but we need it
+ // Group together the smallest modules
+ vendor: {
+ chunks: 'initial',
+ test: /[\\/]node_modules[\\/]/,
+ name: 'vendor',
+ priority: 5,
+ enforce: true,
+ },
+ },
+ },
+ };
+};
diff --git a/packages/cli/src/commands/plugin/serve/paths.ts b/packages/cli/src/lib/bundler/paths.ts
similarity index 72%
rename from packages/cli/src/commands/plugin/serve/paths.ts
rename to packages/cli/src/lib/bundler/paths.ts
index 56c3f6ffb7..ee24e9f4d2 100644
--- a/packages/cli/src/commands/plugin/serve/paths.ts
+++ b/packages/cli/src/lib/bundler/paths.ts
@@ -15,9 +15,16 @@
*/
import { existsSync } from 'fs';
-import { paths } from '../../../lib/paths';
+import { paths } from '../paths';
+
+export type BundlingPathsOptions = {
+ // bundle entrypoint, e.g. 'src/index'
+ entry: string;
+};
+
+export function resolveBundlingPaths(options: BundlingPathsOptions) {
+ const { entry } = options;
-export function getPaths() {
const resolveTargetModule = (path: string) => {
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
const filePath = paths.resolveTarget(`${path}.${ext}`);
@@ -28,7 +35,7 @@ export function getPaths() {
return paths.resolveTarget(`${path}.js`);
};
- let targetHtml = paths.resolveTarget('dev/index.html');
+ let targetHtml = paths.resolveTarget(`${entry}.html`);
if (!existsSync(targetHtml)) {
targetHtml = paths.resolveOwn('templates/serve_index.html');
}
@@ -36,14 +43,15 @@ export function getPaths() {
return {
targetHtml,
targetPath: paths.resolveTarget('.'),
+ targetDist: paths.resolveTarget('dist'),
targetAssets: paths.resolveTarget('assets'),
targetSrc: paths.resolveTarget('src'),
targetDev: paths.resolveTarget('dev'),
- targetDevEntry: resolveTargetModule('dev/index'),
- targetTsConfig: paths.resolveTarget('tsconfig.json'),
+ targetEntry: resolveTargetModule(entry),
+ targetTsConfig: paths.resolveTargetRoot('tsconfig.json'),
targetNodeModules: paths.resolveTarget('node_modules'),
targetPackageJson: paths.resolveTarget('package.json'),
};
}
-export type Paths = ReturnType;
+export type BundlingPaths = ReturnType;
diff --git a/packages/cli/src/commands/plugin/serve/server.ts b/packages/cli/src/lib/bundler/server.ts
similarity index 62%
rename from packages/cli/src/commands/plugin/serve/server.ts
rename to packages/cli/src/lib/bundler/server.ts
index 066f741406..3aaf5968f5 100644
--- a/packages/cli/src/commands/plugin/serve/server.ts
+++ b/packages/cli/src/lib/bundler/server.ts
@@ -14,33 +14,37 @@
* limitations under the License.
*/
+import yn from 'yn';
import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import openBrowser from 'react-dev-utils/openBrowser';
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
-import { getPaths } from './paths';
import { createConfig } from './config';
+import { ServeOptions } from './types';
+import { resolveBundlingPaths } from './paths';
-export async function startDevServer() {
+export async function serveBundle(options: ServeOptions) {
const host = process.env.HOST ?? '0.0.0.0';
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
const port = await choosePort(host, defaultPort);
if (!port) {
- return;
+ throw new Error(`Invalid or no port set: '${port}'`);
}
- const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
+ const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
const urls = prepareUrls(protocol, host, port);
- const paths = getPaths();
- const config = createConfig(paths);
+ const paths = resolveBundlingPaths(options);
+ const config = createConfig(paths, { ...options, isDev: true });
const compiler = webpack(config);
+
const server = new WebpackDevServer(compiler, {
hot: true,
publicPath: '/',
historyApiFallback: true,
- quiet: true,
+ clientLogLevel: 'warning',
+ stats: 'errors-warnings',
https: protocol === 'https',
host,
port,
@@ -57,4 +61,19 @@ export async function startDevServer() {
resolve();
});
});
+
+ const waitForExit = async () => {
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
+ process.on(signal, () => {
+ server.close();
+ // exit instead of resolve. The process is shutting down and resolving a promise here logs an error
+ process.exit();
+ });
+ }
+
+ // Block indefinitely and wait for the interrupt signal
+ return new Promise(() => {});
+ };
+
+ return waitForExit;
}
diff --git a/packages/cli/src/lib/bundler/transforms.ts b/packages/cli/src/lib/bundler/transforms.ts
new file mode 100644
index 0000000000..0856b2fc9d
--- /dev/null
+++ b/packages/cli/src/lib/bundler/transforms.ts
@@ -0,0 +1,101 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import webpack, { Module, Plugin } from 'webpack';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+import MiniCssExtractPlugin from 'mini-css-extract-plugin';
+import { BundlingOptions } from './types';
+import { BundlingPaths } from './paths';
+
+type Transforms = {
+ loaders: Module['rules'];
+ plugins: Plugin[];
+};
+
+export const transforms = (
+ paths: BundlingPaths,
+ options: BundlingOptions,
+): Transforms => {
+ const { isDev } = options;
+
+ const loaders = [
+ {
+ test: /\.(tsx?)$/,
+ exclude: /node_modules/,
+ loader: require.resolve('@sucrase/webpack-loader'),
+ options: {
+ transforms: ['typescript', 'jsx', 'react-hot-loader'],
+ },
+ },
+ {
+ test: /\.(jsx?|mjs)$/,
+ exclude: /node_modules/,
+ loader: require.resolve('@sucrase/webpack-loader'),
+ options: {
+ transforms: ['jsx', 'react-hot-loader'],
+ },
+ },
+ {
+ test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
+ loader: require.resolve('url-loader'),
+ options: {
+ limit: 10000,
+ name: 'static/media/[name].[hash:8].[ext]',
+ },
+ },
+ {
+ test: /\.ya?ml$/,
+ use: require.resolve('yml-loader'),
+ },
+ {
+ include: /\.(md)$/,
+ use: require.resolve('raw-loader'),
+ },
+ {
+ test: /\.css$/i,
+ use: [
+ isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader,
+ {
+ loader: require.resolve('css-loader'),
+ options: {
+ sourceMap: true,
+ },
+ },
+ ],
+ },
+ ];
+
+ const plugins = new Array();
+
+ plugins.push(
+ new HtmlWebpackPlugin({
+ template: paths.targetHtml,
+ }),
+ );
+
+ if (isDev) {
+ plugins.push(new webpack.HotModuleReplacementPlugin());
+ } else {
+ plugins.push(
+ new MiniCssExtractPlugin({
+ filename: '[name].[contenthash:8].css',
+ chunkFilename: '[name].[id].[contenthash:8].css',
+ }),
+ );
+ }
+
+ return { loaders, plugins };
+};
diff --git a/packages/cli/src/lib/bundler/types.ts b/packages/cli/src/lib/bundler/types.ts
new file mode 100644
index 0000000000..4182226d69
--- /dev/null
+++ b/packages/cli/src/lib/bundler/types.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { BundlingPathsOptions } from './paths';
+
+export type BundlingOptions = {
+ checksEnabled: boolean;
+ isDev: boolean;
+};
+
+export type ServeOptions = BundlingPathsOptions & {
+ checksEnabled: boolean;
+};
+
+export type BuildOptions = BundlingPathsOptions & {
+ statsJsonEnabled: boolean;
+};
diff --git a/packages/cli/src/lib/packager/config.ts b/packages/cli/src/lib/packager/config.ts
new file mode 100644
index 0000000000..df91f8067a
--- /dev/null
+++ b/packages/cli/src/lib/packager/config.ts
@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import fs from 'fs-extra';
+import { relative as relativePath } from 'path';
+import peerDepsExternal from 'rollup-plugin-peer-deps-external';
+import commonjs from '@rollup/plugin-commonjs';
+import resolve from '@rollup/plugin-node-resolve';
+import postcss from 'rollup-plugin-postcss';
+import esbuild from 'rollup-plugin-esbuild';
+import imageFiles from 'rollup-plugin-image-files';
+import dts from 'rollup-plugin-dts';
+import json from '@rollup/plugin-json';
+import { RollupOptions } from 'rollup';
+
+import { paths } from '../paths';
+
+export const makeConfigs = async (): Promise => {
+ const typesInput = paths.resolveTargetRoot(
+ 'dist',
+ relativePath(paths.targetRoot, paths.targetDir),
+ 'src/index.d.ts',
+ );
+
+ const declarationsExist = await fs.pathExists(typesInput);
+ if (!declarationsExist) {
+ const path = relativePath(paths.targetDir, typesInput);
+ throw new Error(
+ `No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`,
+ );
+ }
+
+ return [
+ {
+ input: 'src/index.ts',
+ output: {
+ file: 'dist/index.esm.js',
+ format: 'module',
+ },
+ plugins: [
+ peerDepsExternal({
+ includeDependencies: true,
+ }),
+ resolve({
+ mainFields: ['browser', 'module', 'main'],
+ }),
+ commonjs({
+ include: ['node_modules/**', '../../node_modules/**'],
+ exclude: ['**/*.stories.*', '**/*.test.*'],
+ }),
+ postcss(),
+ imageFiles(),
+ json(),
+ esbuild({
+ target: 'es2019',
+ }),
+ ],
+ },
+ {
+ input: typesInput,
+ output: {
+ file: 'dist/index.d.ts',
+ format: 'es',
+ },
+ plugins: [dts()],
+ },
+ ];
+};
diff --git a/packages/cli/src/lib/packager/index.ts b/packages/cli/src/lib/packager/index.ts
new file mode 100644
index 0000000000..e639599af9
--- /dev/null
+++ b/packages/cli/src/lib/packager/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { buildPackage } from './packager';
diff --git a/packages/cli/src/lib/packager/packager.ts b/packages/cli/src/lib/packager/packager.ts
new file mode 100644
index 0000000000..d3e88d8c3d
--- /dev/null
+++ b/packages/cli/src/lib/packager/packager.ts
@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { rollup, RollupOptions } from 'rollup';
+import chalk from 'chalk';
+import { relative as relativePath } from 'path';
+import { paths } from '../paths';
+import { makeConfigs } from './config';
+
+function formatErrorMessage(error: any) {
+ let msg = '';
+
+ if (error.code === 'PLUGIN_ERROR') {
+ if (error.plugin === 'esbuild') {
+ msg += `${error.message}\n\n`;
+ for (const { text, location } of error.errors) {
+ const { line, column } = location;
+ const path = relativePath(paths.targetDir, error.id);
+ const loc = chalk.cyan(`${path}:${line}:${column}`);
+
+ if (text === 'Unexpected "<"' && error.id.endsWith('.js')) {
+ msg += `${loc}: ${text}, JavaScript files with JSX should use a .jsx extension`;
+ } else {
+ msg += `${loc}: ${text}`;
+ }
+ }
+ } else {
+ // Log which plugin is causing errors to make it easier to identity.
+ // If we see these in logs we likely want to provide some custom error
+ // output for those plugins too.
+ msg += `(plugin ${error.plugin}) ${error}\n`;
+ }
+ } else {
+ // Generic rollup errors, log what's available
+ if (error.loc) {
+ const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
+ const pos = `${error.loc.line}:${error.loc.column}`;
+ msg += `${file} [${pos}]\n`;
+ } else if (error.id) {
+ msg += `${paths.resolveTarget(error.id)}\n`;
+ }
+
+ msg += `${error}\n`;
+
+ if (error.url) {
+ msg += `${chalk.cyan(error.url)}\n`;
+ }
+
+ if (error.frame) {
+ msg += `${chalk.dim(error.frame)}\n`;
+ }
+ }
+ return msg;
+}
+
+async function build(config: RollupOptions) {
+ try {
+ const bundle = await rollup(config);
+ if (config.output) {
+ for (const output of [config.output].flat()) {
+ await bundle.generate(output);
+ await bundle.write(output);
+ }
+ }
+ } catch (error) {
+ throw new Error(formatErrorMessage(error));
+ }
+}
+
+export const buildPackage = async () => {
+ const configs = await makeConfigs();
+ await Promise.all(configs.map(build));
+};
diff --git a/packages/cli/src/lib/tasks.ts b/packages/cli/src/lib/tasks.ts
index 0753301b78..721e1a355b 100644
--- a/packages/cli/src/lib/tasks.ts
+++ b/packages/cli/src/lib/tasks.ts
@@ -18,8 +18,12 @@ import chalk from 'chalk';
import fs from 'fs-extra';
import handlebars from 'handlebars';
import ora from 'ora';
-import { basename, dirname } from 'path';
+import { resolve as resolvePath, basename, dirname } from 'path';
import recursive from 'recursive-readdir';
+import { promisify } from 'util';
+import { exec as execCb } from 'child_process';
+import { paths } from './paths';
+const exec = promisify(execCb);
const TASK_NAME_MAX_LENGTH = 14;
@@ -69,7 +73,7 @@ export async function templatingTask(
destinationDir: string,
context: any,
) {
- const files = await recursive(templateDir).catch(error => {
+ const files = await recursive(templateDir).catch((error) => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
@@ -85,7 +89,7 @@ export async function templatingTask(
const compiled = handlebars.compile(template.toString());
const contents = compiled({ name: basename(destination), ...context });
- await fs.writeFile(destination, contents).catch(error => {
+ await fs.writeFile(destination, contents).catch((error) => {
throw new Error(
`Failed to create file: ${destination}: ${error.message}`,
);
@@ -93,7 +97,7 @@ export async function templatingTask(
});
} else {
await Task.forItem('copying', basename(file), async () => {
- await fs.copyFile(file, destinationFile).catch(error => {
+ await fs.copyFile(file, destinationFile).catch((error) => {
const destination = destinationFile;
throw new Error(
`Failed to copy file to ${destination} : ${error.message}`,
@@ -103,3 +107,99 @@ export async function templatingTask(
}
}
}
+
+// List of local packages that we need to modify as a part of an E2E test
+const PATCH_PACKAGES = [
+ 'cli',
+ 'core',
+ 'dev-utils',
+ 'test-utils',
+ 'test-utils-core',
+ 'theme',
+];
+
+// This runs a `yarn install` task, but with special treatment for e2e tests
+export async function installWithLocalDeps(dir: string) {
+ // This makes us install any package inside this repo as a local file dependency.
+ // For example, instead of trying to fetch @backstage/core from npm, we point it
+ // to /packages/core. This makes yarn use a simple file copy to install it instead.
+ if (process.env.BACKSTAGE_E2E_CLI_TEST) {
+ Task.section('Linking packages locally for e2e tests');
+
+ const pkgJsonPath = resolvePath(dir, 'package.json');
+ const pkgJson = await fs.readJson(pkgJsonPath);
+
+ pkgJson.resolutions = pkgJson.resolutions || {};
+ pkgJson.dependencies = pkgJson.dependencies || {};
+
+ if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
+ for (const name of PATCH_PACKAGES) {
+ await Task.forItem(
+ 'adding',
+ `@backstage/${name} link to package.json`,
+ async () => {
+ const pkgPath = paths.resolveOwnRoot('packages', name);
+ // Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
+ pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
+ pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
+
+ await fs
+ .writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
+ .catch((error) => {
+ throw new Error(
+ `Failed to add resolutions to package.json: ${error.message}`,
+ );
+ });
+ },
+ );
+ }
+ }
+ }
+
+ await Task.forItem('executing', 'yarn install', async () => {
+ await exec('yarn install', { cwd: dir }).catch((error) => {
+ process.stdout.write(error.stderr);
+ process.stdout.write(error.stdout);
+ throw new Error(
+ `Could not execute command ${chalk.cyan('yarn install')}`,
+ );
+ });
+ });
+
+ // This takes care of pointing all the installed packages from this repo to
+ // dist instead of the local src.
+ // For example node_modules/@backstage/core/packages.json is rewritten to point
+ // types to dist/index.d.ts and the main:src field is removed.
+ // Without this we get type checking errors in the e2e test
+ if (process.env.BACKSTAGE_E2E_CLI_TEST) {
+ Task.section('Patchling local dependencies for e2e tests');
+
+ for (const name of PATCH_PACKAGES) {
+ await Task.forItem(
+ 'patching',
+ `node_modules/@backstage/${name} package.json`,
+ async () => {
+ const depJsonPath = resolvePath(
+ dir,
+ 'node_modules/@backstage',
+ name,
+ 'package.json',
+ );
+ const depJson = await fs.readJson(depJsonPath);
+
+ // We want dist to be used for e2e tests
+ delete depJson['main:src'];
+ depJson.types = 'dist/index.d.ts';
+
+ await fs
+ .writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
+ .catch((error) => {
+ throw new Error(
+ `Failed to add resolutions to package.json: ${error.message}`,
+ );
+ });
+ },
+ );
+ }
+ }
+}
diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs
index 231194a54e..589e20e298 100644
--- a/packages/cli/templates/default-app/package.json.hbs
+++ b/packages/cli/templates/default-app/package.json.hbs
@@ -7,14 +7,18 @@
},
"scripts": {
"start": "yarn workspace app start",
- "bundle": "yarn build && yarn workspace app bundle",
+ "bundle": "yarn workspace app bundle",
"build": "lerna run build",
- "test": "yarn build && lerna run test --since origin/master -- --coverage",
- "test:all": "yarn build && lerna run test -- --coverage",
+ "tsc": "tsc",
+ "clean": "backstage-cli clean && lerna run clean",
+ "diff": "lerna run diff --",
+ "test": "lerna run test --since origin/master -- --coverage",
+ "test:all": "lerna run test -- --coverage",
"lint": "lerna run lint --since origin/master --",
"lint:all": "lerna run lint --",
"create-plugin": "backstage-cli create-plugin",
- "clean": "lerna run clean"
+ "remove-plugin": "backstage-cli remove-plugin",
+ "postinstall": "patch-package"
},
"workspaces": {
"packages": [
@@ -25,6 +29,7 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"lerna": "^3.20.2",
+ "patch-package": "^6.2.2",
"prettier": "^1.19.1"
}
}
diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs
index 5596eb9151..33d995a2c1 100644
--- a/packages/cli/templates/default-app/packages/app/package.json.hbs
+++ b/packages/cli/templates/default-app/packages/app/package.json.hbs
@@ -16,9 +16,9 @@
"react-use": "^14.2.0"
},
"devDependencies": {
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/react-router-dom": "^5.1.3",
diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx
index e8b82ceecd..c32efd2a6b 100644
--- a/packages/cli/templates/default-app/packages/app/src/App.tsx
+++ b/packages/cli/templates/default-app/packages/app/src/App.tsx
@@ -1,6 +1,5 @@
-import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core';
+import { makeStyles } from '@material-ui/core';
import { createApp } from '@backstage/core';
-import { lightTheme } from '@backstage/theme';
import React, { FC } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import * as plugins from './plugins';
@@ -34,13 +33,9 @@ const App: FC<{}> = () => {
useStyles();
return (
-
-
-
-
-
-
-
+
+
+
);
};
diff --git a/packages/cli/templates/default-app/packages/app/src/setupTests.ts b/packages/cli/templates/default-app/packages/app/src/setupTests.ts
index 666127af39..7b0828bfa8 100644
--- a/packages/cli/templates/default-app/packages/app/src/setupTests.ts
+++ b/packages/cli/templates/default-app/packages/app/src/setupTests.ts
@@ -1 +1 @@
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/cli/templates/default-app/patches/README.md b/packages/cli/templates/default-app/patches/README.md
new file mode 100644
index 0000000000..980729c4cd
--- /dev/null
+++ b/packages/cli/templates/default-app/patches/README.md
@@ -0,0 +1,11 @@
+# patches
+
+This folder contains patches for dependency type declarations. Typescript doesn't provide a way to override of fix types that are installed as a part of the dependent package itself.
+
+Do not add any more patches here, these patches were added as a part of getting the entire repo type-checked, and we depended on some packages with bad types.
+
+As soon as the below issues are fixed, we should remove the patching functionality completely.
+
+## material-table
+
+Fixed in master but not released yet, tracked here: https://github.com/mbrn/material-table/pull/1624
diff --git a/packages/cli/templates/default-app/patches/material-table+1.57.2.patch b/packages/cli/templates/default-app/patches/material-table+1.57.2.patch
new file mode 100644
index 0000000000..5751243775
--- /dev/null
+++ b/packages/cli/templates/default-app/patches/material-table+1.57.2.patch
@@ -0,0 +1,12 @@
+diff --git a/node_modules/material-table/types/index.d.ts b/node_modules/material-table/types/index.d.ts
+index 06b700b..5b3c765 100644
+--- a/node_modules/material-table/types/index.d.ts
++++ b/node_modules/material-table/types/index.d.ts
+@@ -228,7 +228,6 @@ export interface Options {
+ showTitle?: boolean;
+ showTextRowsSelected?: boolean;
+ search?: boolean;
+- searchText?: string;
+ searchFieldAlignment?: 'left' | 'right';
+ searchFieldStyle?: React.CSSProperties;
+ searchText?: string;
diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
index c814473826..b18839bab6 100644
--- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
+++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
@@ -2,6 +2,7 @@
"name": "plugin-welcome",
"version": "0.0.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"private": true,
"scripts": {
@@ -27,7 +28,7 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"@backstage/dev-utils": "^{{version}}",
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
diff --git a/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts b/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts
index 666127af39..7b0828bfa8 100644
--- a/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts
+++ b/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts
@@ -1 +1 @@
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/cli/templates/default-app/tsconfig.json b/packages/cli/templates/default-app/tsconfig.json
index f278b2777d..52cdd1e825 100644
--- a/packages/cli/templates/default-app/tsconfig.json
+++ b/packages/cli/templates/default-app/tsconfig.json
@@ -1,3 +1,8 @@
{
- "extends": "@backstage/cli/config/tsconfig.json"
+ "extends": "@backstage/cli/config/tsconfig.json",
+ "include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"],
+ "exclude": ["**/node_modules"],
+ "compilerOptions": {
+ "outDir": "dist"
+ }
}
diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs
index 0a0ecc7e76..000808dd2b 100644
--- a/packages/cli/templates/default-plugin/package.json.hbs
+++ b/packages/cli/templates/default-plugin/package.json.hbs
@@ -2,6 +2,7 @@
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
@@ -31,9 +32,9 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"@backstage/dev-utils": "^{{version}}",
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
diff --git a/packages/cli/templates/default-plugin/src/setupTests.ts b/packages/cli/templates/default-plugin/src/setupTests.ts
index 1a907ab8e6..e34bc46f4b 100644
--- a/packages/cli/templates/default-plugin/src/setupTests.ts
+++ b/packages/cli/templates/default-plugin/src/setupTests.ts
@@ -14,5 +14,5 @@
* limitations under the License.
*/
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();
diff --git a/packages/cli/templates/default-plugin/tsconfig.json b/packages/cli/templates/default-plugin/tsconfig.json
deleted file mode 100644
index b663b01fa2..0000000000
--- a/packages/cli/templates/default-plugin/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src", "dev"],
- "compilerOptions": {}
-}
diff --git a/packages/cli/tsconfig.build.json b/packages/cli/tsconfig.build.json
index 1ef5cdace5..ae42e652e6 100644
--- a/packages/cli/tsconfig.build.json
+++ b/packages/cli/tsconfig.build.json
@@ -1,8 +1,11 @@
{
- "extends": "./tsconfig.json",
+ "extends": "./config/tsconfig.json",
+ "include": ["src"],
"exclude": ["**/*.test.*"],
"compilerOptions": {
"outDir": "dist",
+ "emitDeclarationOnly": false,
+ "removeComments": true,
"module": "CommonJS"
}
}
diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/cli/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/packages/core/package.json b/packages/core/package.json
index 295b95050c..e73c7c581f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -17,6 +17,7 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -30,6 +31,13 @@
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
+ "@types/classnames": "^2.2.9",
+ "@types/google-protobuf": "^3.7.2",
+ "@types/jest": "^25.2.1",
+ "@types/node": "^12.0.0",
+ "@types/react-helmet": "^5.0.15",
+ "@types/react-sparklines": "^1.7.0",
+ "@types/zen-observable": "^0.8.0",
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"lodash": "^4.17.15",
@@ -51,16 +59,9 @@
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/test-utils": "^0.1.1-alpha.5",
"@backstage/test-utils-core": "^0.1.1-alpha.5",
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
- "@types/classnames": "^2.2.9",
- "@types/google-protobuf": "^3.7.2",
- "@types/jest": "^25.2.1",
- "@types/node": "^12.0.0",
- "@types/react-helmet": "^5.0.15",
- "@types/react-sparklines": "^1.7.0",
- "@types/zen-observable": "^0.8.0"
+ "@testing-library/user-event": "^10.2.4"
},
"files": [
"dist/**/*.{js,d.ts}"
diff --git a/packages/core/src/api/apis/ApiAggregator.test.ts b/packages/core/src/api/apis/ApiAggregator.test.ts
new file mode 100644
index 0000000000..a230e0cfff
--- /dev/null
+++ b/packages/core/src/api/apis/ApiAggregator.test.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ApiAggregator } from './ApiAggregator';
+import { ApiRef } from './ApiRef';
+import { ApiRegistry } from './ApiRegistry';
+
+describe('ApiAggregator', () => {
+ const apiARef = new ApiRef({ id: 'a', description: '' });
+ const apiBRef = new ApiRef({ id: 'b', description: '' });
+
+ it('should forward implementations', () => {
+ const agg = new ApiAggregator(
+ ApiRegistry.from([
+ [apiARef, 5],
+ [apiBRef, 10],
+ ]),
+ );
+ expect(agg.get(apiARef)).toBe(5);
+ expect(agg.get(apiBRef)).toBe(10);
+ });
+
+ it('should return the first implementation', () => {
+ const agg = new ApiAggregator(
+ ApiRegistry.from([
+ [apiARef, 1],
+ [apiARef, 2],
+ ]),
+ );
+ expect(agg.get(apiARef)).toBe(2);
+ expect(agg.get(apiBRef)).toBe(undefined);
+ });
+});
diff --git a/packages/core/src/api/apis/ApiAggregator.ts b/packages/core/src/api/apis/ApiAggregator.ts
new file mode 100644
index 0000000000..da49ee6488
--- /dev/null
+++ b/packages/core/src/api/apis/ApiAggregator.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ApiRef } from './ApiRef';
+import { ApiHolder } from './types';
+
+/**
+ * An ApiHolder that queries multiple other holders from for
+ * an Api implementation, returning the first one encountered..
+ */
+export class ApiAggregator implements ApiHolder {
+ private readonly holders: ApiHolder[];
+
+ constructor(...holders: ApiHolder[]) {
+ this.holders = holders;
+ }
+
+ get(apiRef: ApiRef): T | undefined {
+ for (const holder of this.holders) {
+ const api = holder.get(apiRef);
+ if (api) {
+ return api;
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/packages/core/src/api/apis/definitions/AppThemeApi.ts b/packages/core/src/api/apis/definitions/AppThemeApi.ts
new file mode 100644
index 0000000000..738bae8a9f
--- /dev/null
+++ b/packages/core/src/api/apis/definitions/AppThemeApi.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ApiRef } from '../ApiRef';
+import { BackstageTheme } from '@backstage/theme';
+import { Observable } from '../../types';
+
+/**
+ * Describes a theme provided by the app.
+ */
+export type AppTheme = {
+ /**
+ * ID used to remember theme selections.
+ */
+ id: string;
+
+ /**
+ * Title of the theme
+ */
+ title: string;
+
+ /**
+ * Theme variant
+ */
+ variant: 'light' | 'dark';
+
+ /**
+ * The specialized MaterialUI theme instance.
+ */
+ theme: BackstageTheme;
+};
+
+/**
+ * The AppThemeApi gives access to the current app theme, and allows switching
+ * to other options that have been registered as a part of the App.
+ */
+export type AppThemeApi = {
+ /**
+ * Get a list of available themes.
+ */
+ getInstalledThemes(): AppTheme[];
+
+ /**
+ * Observe the currently selected theme. A value of undefined means no specific theme has been selected.
+ */
+ activeThemeId$(): Observable;
+
+ /**
+ * Get the current theme ID. Returns undefined if no specific theme is selected.
+ */
+ getActiveThemeId(): string | undefined;
+
+ /**
+ * Set a specific theme to use in the app, overriding the default theme selection.
+ *
+ * Clear the selection by passing in undefined.
+ */
+ setActiveThemeId(themeId?: string): void;
+};
+
+export const appThemeApiRef = new ApiRef({
+ id: 'core.apptheme',
+ description: 'API Used to configure the app theme, and enumerate options',
+});
diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts
index 9e7f7534b9..2bb365b2ab 100644
--- a/packages/core/src/api/apis/definitions/index.ts
+++ b/packages/core/src/api/apis/definitions/index.ts
@@ -21,6 +21,7 @@
// If you think some API definition is missing, please open an Issue or send a PR!
export * from './AlertApi';
+export * from './AppThemeApi';
export * from './ErrorApi';
export * from './FeatureFlagsApi';
export * from './OAuthRequestApi';
diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts
new file mode 100644
index 0000000000..0c23fe5219
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.test.ts
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { AppTheme } from '../../definitions';
+import { AppThemeSelector } from './AppThemeSelector';
+
+describe('AppThemeSelector', () => {
+ it('should should select new themes', async () => {
+ const selector = new AppThemeSelector([]);
+
+ expect(selector.getInstalledThemes()).toEqual([]);
+
+ const subFn = jest.fn();
+ selector.activeThemeId$().subscribe(subFn);
+ expect(selector.getActiveThemeId()).toBe(undefined);
+ await 'wait a tick';
+ expect(subFn).toHaveBeenLastCalledWith(undefined);
+
+ selector.setActiveThemeId('x');
+ expect(subFn).toHaveBeenLastCalledWith('x');
+ expect(selector.getActiveThemeId()).toBe('x');
+
+ selector.setActiveThemeId(undefined);
+ expect(subFn).toHaveBeenLastCalledWith(undefined);
+ expect(selector.getActiveThemeId()).toBe(undefined);
+ });
+
+ it('should return a new array of themes', () => {
+ const themes = new Array();
+ const selector = new AppThemeSelector(themes);
+
+ expect(selector.getInstalledThemes()).toEqual(themes);
+ expect(selector.getInstalledThemes()).not.toBe(themes);
+ });
+
+ it('should store theme in local storage', async () => {
+ expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
+ undefined,
+ );
+ localStorage.setItem('theme', 'x');
+ expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe('x');
+ localStorage.removeItem('theme');
+ expect(AppThemeSelector.createWithStorage([]).getActiveThemeId()).toBe(
+ undefined,
+ );
+
+ const addListenerSpy = jest.spyOn(window, 'addEventListener');
+ const selector = AppThemeSelector.createWithStorage([]);
+
+ expect(addListenerSpy).toHaveBeenCalledTimes(1);
+ expect(addListenerSpy).toHaveBeenCalledWith(
+ 'storage',
+ expect.any(Function),
+ );
+
+ selector.setActiveThemeId('y');
+ await 'wait a tick';
+ expect(localStorage.getItem('theme')).toBe('y');
+
+ selector.setActiveThemeId(undefined);
+ await 'wait a tick';
+ expect(localStorage.getItem('theme')).toBe(null);
+
+ localStorage.setItem('theme', 'z');
+ expect(selector.getActiveThemeId()).toBe(undefined);
+
+ const listener = addListenerSpy.mock.calls[0][1] as EventListener;
+ listener({ key: 'theme' } as StorageEvent);
+
+ expect(selector.getActiveThemeId()).toBe('z');
+ });
+});
diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts
new file mode 100644
index 0000000000..7f6659e641
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/AppThemeSelector/AppThemeSelector.ts
@@ -0,0 +1,75 @@
+/*
+ * 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 { AppThemeApi, AppTheme } from '../../definitions';
+import { BehaviorSubject } from '../lib';
+import { Observable } from '../../../types';
+
+const STORAGE_KEY = 'theme';
+
+export class AppThemeSelector implements AppThemeApi {
+ static createWithStorage(themes: AppTheme[]) {
+ const selector = new AppThemeSelector(themes);
+
+ if (!window.localStorage) {
+ return selector;
+ }
+
+ const initialThemeId =
+ window.localStorage.getItem(STORAGE_KEY) ?? undefined;
+
+ selector.setActiveThemeId(initialThemeId);
+
+ selector.activeThemeId$().subscribe((themeId) => {
+ if (themeId) {
+ window.localStorage.setItem(STORAGE_KEY, themeId);
+ } else {
+ window.localStorage.removeItem(STORAGE_KEY);
+ }
+ });
+
+ window.addEventListener('storage', (event) => {
+ if (event.key === STORAGE_KEY) {
+ const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
+ selector.setActiveThemeId(themeId);
+ }
+ });
+
+ return selector;
+ }
+
+ private activeThemeId: string | undefined;
+ private readonly subject = new BehaviorSubject(undefined);
+
+ constructor(private readonly themes: AppTheme[]) {}
+
+ getInstalledThemes(): AppTheme[] {
+ return this.themes.slice();
+ }
+
+ activeThemeId$(): Observable {
+ return this.subject;
+ }
+
+ getActiveThemeId(): string | undefined {
+ return this.activeThemeId;
+ }
+
+ setActiveThemeId(themeId?: string): void {
+ this.activeThemeId = themeId;
+ this.subject.next(themeId);
+ }
+}
diff --git a/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts b/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts
new file mode 100644
index 0000000000..cb42c0f875
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/AppThemeSelector/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './AppThemeSelector';
diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts
index 6348262022..96aada1864 100644
--- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts
+++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts
@@ -14,8 +14,9 @@
* limitations under the License.
*/
-import Observable from 'zen-observable';
import { OAuthScopes } from '../../definitions';
+import { BehaviorSubject } from '../lib';
+import { Observable } from '../../../types';
type RequestQueueEntry = {
scopes: OAuthScopes;
@@ -44,16 +45,15 @@ export type OAuthPendingRequestsApi = {
export class OAuthPendingRequests
implements OAuthPendingRequestsApi {
private requests: RequestQueueEntry[] = [];
- private listeners: ZenObservable.SubscriptionObserver<
- PendingRequest
- >[] = [];
+ private subject = new BehaviorSubject>(
+ this.getCurrentPending(),
+ );
request(scopes: OAuthScopes): Promise {
return new Promise((resolve, reject) => {
this.requests.push({ scopes, resolve, reject });
- const pending = this.getCurrentPending();
- this.listeners.forEach((listener) => listener.next(pending));
+ this.subject.next(this.getCurrentPending());
});
}
@@ -66,26 +66,18 @@ export class OAuthPendingRequests
return true;
});
- const pending = this.getCurrentPending();
- this.listeners.forEach((listener) => listener.next(pending));
+ this.subject.next(this.getCurrentPending());
}
reject(error: Error) {
this.requests.forEach((request) => request.reject(error));
this.requests = [];
- const pending = this.getCurrentPending();
- this.listeners.forEach((listener) => listener.next(pending));
+ this.subject.next(this.getCurrentPending());
}
pending(): Observable> {
- return new Observable((subscriber) => {
- this.listeners.push(subscriber);
- subscriber.next(this.getCurrentPending());
- return () => {
- this.listeners = this.listeners.filter((l) => l !== subscriber);
- };
- });
+ return this.subject;
}
private getCurrentPending(): PendingRequest {
diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts
index 02d89b9546..632ec059eb 100644
--- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts
+++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts
@@ -21,8 +21,9 @@ import {
AuthRequester,
AuthRequesterOptions,
} from '../../definitions';
-import Observable from 'zen-observable';
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
+import { BehaviorSubject } from '../lib';
+import { Observable } from '../../../types';
/**
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
@@ -32,24 +33,10 @@ import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
* them all together into a single request for each OAuth provider.
*/
export class OAuthRequestManager implements OAuthRequestApi {
- private readonly request$: Observable;
- private readonly observers = new Set<
- ZenObservable.SubscriptionObserver
- >();
+ private readonly subject = new BehaviorSubject([]);
private currentRequests: PendingAuthRequest[] = [];
private handlerCount = 0;
- constructor() {
- this.request$ = new Observable((observer) => {
- observer.next(this.currentRequests);
-
- this.observers.add(observer);
- return () => {
- this.observers.delete(observer);
- };
- }).map((requests) => requests.filter(Boolean)); // Convert from sparse array to array of present items only
- }
-
createAuthRequester(options: AuthRequesterOptions): AuthRequester {
const handler = new OAuthPendingRequests();
@@ -66,7 +53,8 @@ export class OAuthRequestManager implements OAuthRequestApi {
newRequests[index] = request;
}
this.currentRequests = newRequests;
- this.observers.forEach((observer) => observer.next(newRequests));
+ // Convert from sparse array to array of present items only
+ this.subject.next(newRequests.filter(Boolean));
},
});
@@ -100,7 +88,7 @@ export class OAuthRequestManager implements OAuthRequestApi {
}
authRequest$(): Observable {
- return this.request$;
+ return this.subject;
}
async showLoginPopup(options: LoginPopupOptions): Promise {
diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core/src/api/apis/implementations/index.ts
index ebd89b54c6..8334b068fc 100644
--- a/packages/core/src/api/apis/implementations/index.ts
+++ b/packages/core/src/api/apis/implementations/index.ts
@@ -18,6 +18,7 @@
//
// Plugins should rely on these APIs for functionality as much as possible.
+export * from './AppThemeSelector';
export * from './AlertApiForwarder';
export * from './ErrorApiForwarder';
export * from './OAuthRequestManager';
diff --git a/packages/core/src/api/apis/implementations/lib/index.ts b/packages/core/src/api/apis/implementations/lib/index.ts
new file mode 100644
index 0000000000..aa2202858c
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/lib/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './subjects';
diff --git a/packages/core/src/api/apis/implementations/lib/subjects.test.ts b/packages/core/src/api/apis/implementations/lib/subjects.test.ts
new file mode 100644
index 0000000000..31ed26d97d
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/lib/subjects.test.ts
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { PublishSubject, BehaviorSubject } from './subjects';
+
+function observerSpy() {
+ return {
+ next: jest.fn(),
+ error: jest.fn(),
+ complete: jest.fn(),
+ };
+}
+
+describe('PublishSubject', () => {
+ it('should be completed', async () => {
+ const subj = new PublishSubject();
+ subj.complete();
+
+ const spy = observerSpy();
+ subj.subscribe(spy);
+ await 'a tick';
+
+ expect(spy.next).not.toHaveBeenCalled();
+ expect(spy.error).not.toHaveBeenCalled();
+ expect(spy.complete).toHaveBeenCalledTimes(1);
+
+ expect(() => subj.next(1)).toThrow('PublishSubject is closed');
+ expect(() => subj.error(new Error())).toThrow('PublishSubject is closed');
+ expect(() => subj.complete()).toThrow('PublishSubject is closed');
+
+ expect(subj.closed).toBe(true);
+ });
+
+ it('should forward values to all subscribers', async () => {
+ const subj = new PublishSubject();
+
+ const spy1 = observerSpy();
+ const spy2 = observerSpy();
+ subj.subscribe(spy1);
+ const sub = subj.subscribe(spy2);
+
+ subj.next(42);
+
+ expect(spy1.next).toHaveBeenCalledTimes(1);
+ expect(spy1.next).toHaveBeenCalledWith(42);
+ expect(spy2.next).toHaveBeenCalledTimes(1);
+ expect(spy2.next).toHaveBeenCalledWith(42);
+
+ sub.unsubscribe();
+
+ subj.next(1337);
+
+ expect(spy1.next).toHaveBeenCalledTimes(2);
+ expect(spy1.next).toHaveBeenCalledWith(1337);
+ expect(spy2.next).toHaveBeenCalledTimes(1);
+
+ expect(spy1.error).not.toHaveBeenCalled();
+ expect(spy2.error).not.toHaveBeenCalled();
+ expect(spy1.complete).not.toHaveBeenCalled();
+ expect(spy2.complete).not.toHaveBeenCalled();
+
+ expect(subj.closed).toBe(false);
+ });
+
+ it('should forward errors', async () => {
+ const subj = new PublishSubject();
+
+ const spy1 = observerSpy();
+ subj.subscribe(spy1);
+
+ const error = new Error('NOPE');
+ subj.error(error);
+ expect(spy1.error).toHaveBeenCalledWith(error);
+ expect(spy1.next).not.toHaveBeenCalled();
+ expect(spy1.complete).not.toHaveBeenCalled();
+
+ const spy2 = observerSpy();
+ subj.subscribe(spy2);
+ await 'a tick';
+ expect(spy2.error).toHaveBeenCalledWith(error);
+ expect(spy2.next).not.toHaveBeenCalled();
+ expect(spy2.complete).not.toHaveBeenCalled();
+
+ expect(subj.closed).toBe(true);
+ });
+});
+
+describe('BehaviorSubject', () => {
+ it('should be completed', async () => {
+ const subj = new BehaviorSubject(0);
+ subj.complete();
+
+ const next = jest.fn();
+ const complete = jest.fn();
+ subj.subscribe({ next, complete });
+ await 'a tick';
+
+ expect(complete).toHaveBeenCalledTimes(1);
+
+ expect(() => subj.next(1)).toThrow('BehaviorSubject is closed');
+ expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed');
+ expect(() => subj.complete()).toThrow('BehaviorSubject is closed');
+
+ expect(subj.closed).toBe(true);
+ });
+
+ it('should forward values to all subscribers', async () => {
+ const subj = new BehaviorSubject(0);
+
+ const obs1 = jest.fn();
+ const obs2 = jest.fn();
+ subj.subscribe(obs1);
+ const sub = subj.subscribe(obs2);
+ await 'a tick';
+
+ expect(obs1).toHaveBeenCalledTimes(1);
+ expect(obs1).toHaveBeenCalledWith(0);
+ expect(obs2).toHaveBeenCalledTimes(1);
+ expect(obs2).toHaveBeenCalledWith(0);
+
+ subj.next(42);
+
+ expect(obs1).toHaveBeenCalledTimes(2);
+ expect(obs1).toHaveBeenCalledWith(42);
+ expect(obs2).toHaveBeenCalledTimes(2);
+ expect(obs2).toHaveBeenCalledWith(42);
+
+ sub.unsubscribe();
+
+ subj.next(1337);
+
+ expect(obs1).toHaveBeenCalledTimes(3);
+ expect(obs1).toHaveBeenCalledWith(1337);
+ expect(obs2).toHaveBeenCalledTimes(2);
+
+ expect(subj.closed).toBe(false);
+ });
+
+ it('should forward errors', async () => {
+ const subj = new BehaviorSubject(0);
+
+ const spy1 = observerSpy();
+ subj.subscribe(spy1);
+ await 'a tick';
+
+ expect(spy1.error).not.toHaveBeenCalled();
+ expect(spy1.next).toHaveBeenCalledWith(0);
+
+ const error = new Error('NOPE');
+ subj.error(error);
+ expect(spy1.error).toHaveBeenCalledWith(error);
+ expect(spy1.next).toHaveBeenCalledWith(0);
+ expect(spy1.next).toHaveBeenCalledTimes(1);
+ expect(spy1.complete).not.toHaveBeenCalled();
+
+ const spy2 = observerSpy();
+ subj.subscribe(spy2);
+ await 'a tick';
+ expect(spy2.error).toHaveBeenCalledWith(error);
+ expect(spy2.next).not.toHaveBeenCalled();
+ expect(spy2.complete).not.toHaveBeenCalled();
+
+ expect(subj.closed).toBe(true);
+ });
+});
diff --git a/packages/core/src/api/apis/implementations/lib/subjects.ts b/packages/core/src/api/apis/implementations/lib/subjects.ts
new file mode 100644
index 0000000000..b33df70a65
--- /dev/null
+++ b/packages/core/src/api/apis/implementations/lib/subjects.ts
@@ -0,0 +1,202 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Observable } from '../../../types';
+import ObservableImpl from 'zen-observable';
+
+// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects.
+// If we add a more complete Observables library they should be replaced.
+
+/**
+ * A basic implementation of ReactiveX publish subjects.
+ *
+ * A subject is a convenient way to create an observable when you want
+ * to fan out a single value to all subscribers.
+ *
+ * See http://reactivex.io/documentation/subject.html
+ */
+export class PublishSubject
+ implements Observable, ZenObservable.SubscriptionObserver {
+ private isClosed = false;
+ private terminatingError?: Error;
+
+ private readonly observable = new ObservableImpl((subscriber) => {
+ if (this.isClosed) {
+ if (this.terminatingError) {
+ subscriber.error(this.terminatingError);
+ } else {
+ subscriber.complete();
+ }
+ return () => {};
+ }
+
+ this.subscribers.add(subscriber);
+ return () => {
+ this.subscribers.delete(subscriber);
+ };
+ });
+
+ private readonly subscribers = new Set<
+ ZenObservable.SubscriptionObserver
+ >();
+
+ get closed() {
+ return this.isClosed;
+ }
+
+ next(value: T) {
+ if (this.isClosed) {
+ throw new Error('PublishSubject is closed');
+ }
+ this.subscribers.forEach((subscriber) => subscriber.next(value));
+ }
+
+ error(error: Error) {
+ if (this.isClosed) {
+ throw new Error('PublishSubject is closed');
+ }
+ this.isClosed = true;
+ this.terminatingError = error;
+ this.subscribers.forEach((subscriber) => subscriber.error(error));
+ }
+
+ complete() {
+ if (this.isClosed) {
+ throw new Error('PublishSubject is closed');
+ }
+ this.isClosed = true;
+ this.subscribers.forEach((subscriber) => subscriber.complete());
+ }
+
+ subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription;
+ subscribe(
+ onNext: ZenObservable.Observer | ((value: T) => void),
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription {
+ const observer =
+ typeof onNext === 'function'
+ ? {
+ next: onNext,
+ error: onError,
+ complete: onComplete,
+ }
+ : onNext;
+
+ return this.observable.subscribe(observer);
+ }
+}
+
+/**
+ * A basic implementation of ReactiveX behavior subjects.
+ *
+ * A subject is a convenient way to create an observable when you want
+ * to fan out a single value to all subscribers.
+ *
+ * The BehaviorSubject will emit the most recently emitted value or error
+ * whenever a new observer subscribes to the subject.
+ *
+ * See http://reactivex.io/documentation/subject.html
+ */
+export class BehaviorSubject
+ implements Observable, ZenObservable.SubscriptionObserver {
+ private isClosed = false;
+ private currentValue: T;
+ private terminatingError?: Error;
+
+ constructor(value: T) {
+ this.currentValue = value;
+ }
+
+ private readonly observable = new ObservableImpl((subscriber) => {
+ if (this.isClosed) {
+ if (this.terminatingError) {
+ subscriber.error(this.terminatingError);
+ } else {
+ subscriber.complete();
+ }
+ return () => {};
+ }
+
+ subscriber.next(this.currentValue);
+
+ this.subscribers.add(subscriber);
+ return () => {
+ this.subscribers.delete(subscriber);
+ };
+ });
+
+ private readonly subscribers = new Set<
+ ZenObservable.SubscriptionObserver
+ >();
+
+ get closed() {
+ return this.isClosed;
+ }
+
+ next(value: T) {
+ if (this.isClosed) {
+ throw new Error('BehaviorSubject is closed');
+ }
+ this.currentValue = value;
+ this.subscribers.forEach((subscriber) => subscriber.next(value));
+ }
+
+ error(error: Error) {
+ if (this.isClosed) {
+ throw new Error('BehaviorSubject is closed');
+ }
+ this.isClosed = true;
+ this.terminatingError = error;
+ this.subscribers.forEach((subscriber) => subscriber.error(error));
+ }
+
+ complete() {
+ if (this.isClosed) {
+ throw new Error('BehaviorSubject is closed');
+ }
+ this.isClosed = true;
+ this.subscribers.forEach((subscriber) => subscriber.complete());
+ }
+
+ subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription;
+ subscribe(
+ onNext: (value: T) => void,
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription;
+ subscribe(
+ onNext: ZenObservable.Observer | ((value: T) => void),
+ onError?: (error: any) => void,
+ onComplete?: () => void,
+ ): ZenObservable.Subscription {
+ const observer =
+ typeof onNext === 'function'
+ ? {
+ next: onNext,
+ error: onError,
+ complete: onComplete,
+ }
+ : onNext;
+
+ return this.observable.subscribe(observer);
+ }
+}
diff --git a/packages/core/src/api/app/App.tsx b/packages/core/src/api/app/App.tsx
index c44454b10e..128f978139 100644
--- a/packages/core/src/api/app/App.tsx
+++ b/packages/core/src/api/app/App.tsx
@@ -22,6 +22,7 @@ import { BackstagePlugin } from '../plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from '../apis/definitions';
import ErrorPage from '../../layout/ErrorPage';
+import { AppThemeProvider } from './AppThemeProvider';
import {
IconComponent,
@@ -29,14 +30,24 @@ import {
SystemIconKey,
defaultSystemIcons,
} from '../../icons';
-import { ApiHolder, ApiProvider, ApiRegistry } from '../apis';
+import {
+ ApiHolder,
+ ApiProvider,
+ ApiRegistry,
+ AppTheme,
+ AppThemeSelector,
+ appThemeApiRef,
+} from '../apis';
import LoginPage from './LoginPage';
+import { lightTheme, darkTheme } from '@backstage/theme';
+import { ApiAggregator } from '../apis/ApiAggregator';
type FullAppOptions = {
apis: ApiHolder;
icons: SystemIcons;
plugins: BackstagePlugin[];
components: AppComponents;
+ themes: AppTheme[];
};
class AppImpl implements BackstageApp {
@@ -44,12 +55,14 @@ class AppImpl implements BackstageApp {
private readonly icons: SystemIcons;
private readonly plugins: BackstagePlugin[];
private readonly components: AppComponents;
+ private readonly themes: AppTheme[];
constructor(options: FullAppOptions) {
this.apis = options.apis;
this.icons = options.icons;
this.plugins = options.plugins;
this.components = options.components;
+ this.themes = options.themes;
}
getApis(): ApiHolder {
@@ -129,23 +142,28 @@ class AppImpl implements BackstageApp {
,
);
- let rendered = (
+ const rendered = (
{routes}
);
- if (this.apis) {
- rendered = ;
- }
-
return () => rendered;
}
getProvider(): ComponentType<{}> {
+ const appApis = ApiRegistry.from([
+ [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
+ ]);
+ const apis = new ApiAggregator(this.apis, appApis);
+
const Provider: FC<{}> = ({ children }) => (
- {children}
+
+
+ {children}
+
+
);
return Provider;
}
@@ -178,8 +196,22 @@ export function createApp(options?: AppOptions) {
NotFoundErrorPage: DefaultNotFoundPage,
...options?.components,
};
+ const themes = options?.themes ?? [
+ {
+ id: 'light',
+ title: 'Light Theme',
+ variant: 'light',
+ theme: lightTheme,
+ },
+ {
+ id: 'dark',
+ title: 'Dark Theme',
+ variant: 'dark',
+ theme: darkTheme,
+ },
+ ];
- const app = new AppImpl({ apis, icons, plugins, components });
+ const app = new AppImpl({ apis, icons, plugins, components, themes });
app.verify();
diff --git a/packages/core/src/api/app/AppThemeProvider.tsx b/packages/core/src/api/app/AppThemeProvider.tsx
new file mode 100644
index 0000000000..6e68fe9ba0
--- /dev/null
+++ b/packages/core/src/api/app/AppThemeProvider.tsx
@@ -0,0 +1,97 @@
+/*
+ * 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, useMemo, useEffect, useState } from 'react';
+import { ThemeProvider, CssBaseline } from '@material-ui/core';
+import { useApi, appThemeApiRef, AppTheme } from '../apis';
+import { useObservable } from 'react-use';
+
+// This tries to find the most accurate match, but also falls back to less
+// accurate results in order to avoid errors.
+function resolveTheme(
+ themeId: string | undefined,
+ shouldPreferDark: boolean,
+ themes: AppTheme[],
+) {
+ if (themeId !== undefined) {
+ const selectedTheme = themes.find((theme) => theme.id === themeId);
+ if (selectedTheme) {
+ return selectedTheme;
+ }
+ }
+
+ if (shouldPreferDark) {
+ const darkTheme = themes.find((theme) => theme.variant === 'dark');
+ if (darkTheme) {
+ return darkTheme;
+ }
+ }
+
+ const lightTheme = themes.find((theme) => theme.variant === 'light');
+ if (lightTheme) {
+ return lightTheme;
+ }
+
+ return themes[0];
+}
+
+const useShouldPreferDarkTheme = () => {
+ if (!window.matchMedia) {
+ return false;
+ }
+
+ const mediaQuery = useMemo(
+ () => window.matchMedia('(prefers-color-scheme: dark)'),
+ [],
+ );
+ const [shouldPreferDark, setPrefersDark] = useState(mediaQuery.matches);
+
+ useEffect(() => {
+ const listener = (event: MediaQueryListEvent) => {
+ setPrefersDark(event.matches);
+ };
+ mediaQuery.addListener(listener);
+ return () => {
+ mediaQuery.removeListener(listener);
+ };
+ }, [mediaQuery]);
+
+ return shouldPreferDark;
+};
+
+export const AppThemeProvider: FC<{}> = ({ children }) => {
+ const appThemeApi = useApi(appThemeApiRef);
+ const shouldPreferDark = useShouldPreferDarkTheme();
+ const themeId = useObservable(
+ appThemeApi.activeThemeId$(),
+ appThemeApi.getActiveThemeId(),
+ );
+
+ const appTheme = resolveTheme(
+ themeId,
+ shouldPreferDark,
+ appThemeApi.getInstalledThemes(),
+ );
+ if (!appTheme) {
+ throw new Error('App has no themes');
+ }
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/packages/core/src/api/app/LoginPage/LoginPage.tsx b/packages/core/src/api/app/LoginPage/LoginPage.tsx
index 07160e750c..cdf0b55fd4 100644
--- a/packages/core/src/api/app/LoginPage/LoginPage.tsx
+++ b/packages/core/src/api/app/LoginPage/LoginPage.tsx
@@ -15,7 +15,7 @@
*/
import React, { FC, useState } from 'react';
-import { GitHub as GitHubIcon } from '@material-ui/icons';
+import GitHubIcon from '@material-ui/icons/GitHub';
import Page from '../../../layout/Page';
import Header from '../../../layout/Header';
import Content from '../../../layout/Content/Content';
diff --git a/packages/core/src/api/app/types.ts b/packages/core/src/api/app/types.ts
index e8d597e120..75a750ce6e 100644
--- a/packages/core/src/api/app/types.ts
+++ b/packages/core/src/api/app/types.ts
@@ -18,6 +18,7 @@ import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
+import { AppTheme } from '../apis/definitions';
export type AppComponents = {
NotFoundErrorPage: ComponentType<{}>;
@@ -45,6 +46,28 @@ export type AppOptions = {
* Supply components to the app to override the default ones.
*/
components?: Partial;
+
+ /**
+ * Themes provided as a part of the app. By default two themes are included, one
+ * light variant of the default backstage theme, and one dark.
+ *
+ * This is the default config:
+ *
+ * ```
+ * [{
+ * id: 'light',
+ * title: 'Light Theme',
+ * variant: 'light',
+ * theme: lightTheme,
+ * }, {
+ * id: 'dark',
+ * title: 'Dark Theme',
+ * variant: 'dark',
+ * theme: darkTheme,
+ * }]
+ * ```
+ */
+ themes?: AppTheme[];
};
export type BackstageApp = {
diff --git a/packages/core/src/api/navTargets/index.ts b/packages/core/src/api/navTargets/index.ts
index 6c2c899f89..c5d0cc4289 100644
--- a/packages/core/src/api/navTargets/index.ts
+++ b/packages/core/src/api/navTargets/index.ts
@@ -14,5 +14,5 @@
* limitations under the License.
*/
-export type { NavTarget, NavTargetConfig, NavTargetOverrideConfig } from './types';
+export * from './types';
export { createNavTarget } from './NavTarget';
diff --git a/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx b/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx
index 52db02177e..1695b5d8c3 100644
--- a/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx
+++ b/packages/core/src/components/CodeSnippet/CodeSnippet.stories.tsx
@@ -72,7 +72,7 @@ export const Overflow = () => (
>
);
-export const Laguages = () => (
+export const Languages = () => (
<>
diff --git a/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.js b/packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
similarity index 100%
rename from packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.js
rename to packages/core/src/components/HorizontalScrollGrid/HorizontalScrollGrid.test.jsx
diff --git a/packages/core/src/components/ProgressBars/CircleProgress.test.js b/packages/core/src/components/ProgressBars/CircleProgress.test.jsx
similarity index 100%
rename from packages/core/src/components/ProgressBars/CircleProgress.test.js
rename to packages/core/src/components/ProgressBars/CircleProgress.test.jsx
diff --git a/packages/core/src/components/ProgressBars/ProgressCard.test.js b/packages/core/src/components/ProgressBars/ProgressCard.test.jsx
similarity index 100%
rename from packages/core/src/components/ProgressBars/ProgressCard.test.js
rename to packages/core/src/components/ProgressBars/ProgressCard.test.jsx
diff --git a/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.js b/packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.jsx
similarity index 100%
rename from packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.js
rename to packages/core/src/components/StructuredMetadataTable/StructuredMetadataTable.test.jsx
diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx
index 3f3f879657..550353920c 100644
--- a/packages/core/src/components/Table/Table.tsx
+++ b/packages/core/src/components/Table/Table.tsx
@@ -27,23 +27,21 @@ import { BackstageTheme } from '@backstage/theme';
import { makeStyles, useTheme } from '@material-ui/core';
// Material-table is not using the standard icons available in in material-ui. https://github.com/mbrn/material-table/issues/51
-import {
- AddBox,
- ArrowUpward,
- Check,
- ChevronLeft,
- ChevronRight,
- Clear,
- DeleteOutline,
- Edit,
- FilterList,
- FirstPage,
- LastPage,
- Remove,
- SaveAlt,
- Search,
- ViewColumn,
-} from '@material-ui/icons';
+import AddBox from '@material-ui/icons/AddBox';
+import ArrowUpward from '@material-ui/icons/ArrowUpward';
+import Check from '@material-ui/icons/Check';
+import ChevronLeft from '@material-ui/icons/ChevronLeft';
+import ChevronRight from '@material-ui/icons/ChevronRight';
+import Clear from '@material-ui/icons/Clear';
+import DeleteOutline from '@material-ui/icons/DeleteOutline';
+import Edit from '@material-ui/icons/Edit';
+import FilterList from '@material-ui/icons/FilterList';
+import FirstPage from '@material-ui/icons/FirstPage';
+import LastPage from '@material-ui/icons/LastPage';
+import Remove from '@material-ui/icons/Remove';
+import SaveAlt from '@material-ui/icons/SaveAlt';
+import Search from '@material-ui/icons/Search';
+import ViewColumn from '@material-ui/icons/ViewColumn';
const tableIcons = {
Add: forwardRef((props, ref: React.Ref) => (
diff --git a/packages/core/src/layout/ErrorPage/MicDrop.js b/packages/core/src/layout/ErrorPage/MicDrop.jsx
similarity index 100%
rename from packages/core/src/layout/ErrorPage/MicDrop.js
rename to packages/core/src/layout/ErrorPage/MicDrop.jsx
diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx
index b32101a270..a03a33ce09 100644
--- a/packages/core/src/layout/Sidebar/Bar.tsx
+++ b/packages/core/src/layout/Sidebar/Bar.tsx
@@ -20,7 +20,7 @@ import React, { FC, useRef, useState } from 'react';
import { sidebarConfig, SidebarContext } from './config';
import { BackstageTheme } from '@backstage/theme';
-const useStyles = makeStyles(theme => ({
+const useStyles = makeStyles((theme) => ({
root: {
zIndex: 1000,
position: 'relative',
@@ -115,7 +115,7 @@ export const Sidebar: FC = ({
onBlur={handleClose}
data-testid="sidebar-root"
>
-
+
);
};
@@ -111,4 +220,5 @@ export const SidebarDivider = styled('hr')({
width: '100%',
background: '#383838',
border: 'none',
+ margin: 0,
});
diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
new file mode 100644
index 0000000000..1b9ba5bbab
--- /dev/null
+++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx
@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import {
+ Sidebar,
+ SidebarIntro,
+ SidebarItem,
+ SidebarDivider,
+ SidebarSearchField,
+ SidebarSpace,
+ SidebarUserBadge,
+} from '.';
+import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
+import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
+import { MemoryRouter } from 'react-router-dom';
+
+export default {
+ title: 'Sidebar',
+ component: Sidebar,
+ decorators: [
+ (storyFn: () => JSX.Element) => (
+ {storyFn()}
+ ),
+ ],
+};
+
+const handleSearch = (input: string) => {
+ // eslint-disable-next-line no-console
+ console.log(input);
+};
+
+export const SampleSidebar = () => (
+
+ {/* */}
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx b/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx
new file mode 100644
index 0000000000..7f4c8890f9
--- /dev/null
+++ b/packages/core/src/layout/Sidebar/SidebarThemeToggle.tsx
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { FC } from 'react';
+import { useObservable } from 'react-use';
+import LightIcon from '@material-ui/icons/WbSunny';
+import DarkIcon from '@material-ui/icons/Brightness2';
+import AutoIcon from '@material-ui/icons/BrightnessAuto';
+import { appThemeApiRef, useApi } from '../../api';
+import { SidebarItem } from './Items';
+
+export const SidebarThemeToggle: FC<{}> = () => {
+ const appThemeApi = useApi(appThemeApiRef);
+ const themeId = useObservable(
+ appThemeApi.activeThemeId$(),
+ appThemeApi.getActiveThemeId(),
+ );
+
+ let text = 'Auto';
+ let icon = AutoIcon;
+ switch (themeId) {
+ case 'dark':
+ text = 'Dark mode';
+ icon = DarkIcon;
+ break;
+ case 'light':
+ text = 'Light mode';
+ icon = LightIcon;
+ break;
+ default:
+ break;
+ }
+
+ const handleToggle = () => {
+ if (!themeId) {
+ appThemeApi.setActiveThemeId('light');
+ } else if (themeId === 'light') {
+ appThemeApi.setActiveThemeId('dark');
+ } else {
+ appThemeApi.setActiveThemeId(undefined);
+ }
+ };
+
+ return ;
+};
diff --git a/packages/core/src/layout/Sidebar/UserBadge.tsx b/packages/core/src/layout/Sidebar/UserBadge.tsx
new file mode 100644
index 0000000000..1614558ab9
--- /dev/null
+++ b/packages/core/src/layout/Sidebar/UserBadge.tsx
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React, { FC, useContext } from 'react';
+import { Avatar, makeStyles, Theme, Typography } from '@material-ui/core';
+import People from '@material-ui/icons/People';
+import { sidebarConfig, SidebarContext } from './config';
+import { SidebarItem } from './Items';
+
+const useStyles = makeStyles(() => {
+ const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
+ return {
+ root: {
+ width: drawerWidthOpen,
+ display: 'flex',
+ alignItems: 'center',
+ color: '#b5b5b5',
+ paddingLeft: 18,
+ paddingTop: 14,
+ paddingBottom: 14,
+ },
+ avatar: {
+ width: userBadgeDiameter,
+ height: userBadgeDiameter,
+ marginRight: 8,
+ },
+ };
+});
+
+type Props = {
+ imageUrl: string;
+ name: string;
+ hideName?: boolean;
+};
+
+export const UserBadge: FC = ({ imageUrl, name, hideName = false }) => {
+ const classes = useStyles();
+
+ return (
+
+
+ {!hideName && {name}}
+
+ );
+};
+
+export const SidebarUserBadge: FC = () => {
+ const { isOpen } = useContext(SidebarContext);
+ const isUserLoggedIn = false;
+ return isUserLoggedIn ? (
+
+ ) : (
+
+ );
+};
diff --git a/packages/core/src/layout/Sidebar/config.ts b/packages/core/src/layout/Sidebar/config.ts
index 7695e695d7..8ea6cc94f9 100644
--- a/packages/core/src/layout/Sidebar/config.ts
+++ b/packages/core/src/layout/Sidebar/config.ts
@@ -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(false);
+export const SIDEBAR_INTRO_LOCAL_STORAGE =
+ '@backstage/core/sidebar-intro-dismissed';
+
+export type SidebarContextType = {
+ isOpen: boolean;
+};
+
+export const SidebarContext = createContext({
+ isOpen: false,
+});
diff --git a/packages/core/src/layout/Sidebar/index.ts b/packages/core/src/layout/Sidebar/index.ts
index e4e0a2d991..731d117ce3 100644
--- a/packages/core/src/layout/Sidebar/index.ts
+++ b/packages/core/src/layout/Sidebar/index.ts
@@ -17,4 +17,7 @@
export * from './Bar';
export * from './Page';
export * from './Items';
+export * from './Intro';
+export * from './UserBadge';
export * from './config';
+export { SidebarThemeToggle } from './SidebarThemeToggle';
diff --git a/packages/core/src/setupTests.ts b/packages/core/src/setupTests.ts
index 8925258421..825bcd4115 100644
--- a/packages/core/src/setupTests.ts
+++ b/packages/core/src/setupTests.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/core/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json
index aea4f9b6ae..f5eb8e9708 100644
--- a/packages/dev-utils/package.json
+++ b/packages/dev-utils/package.json
@@ -17,6 +17,7 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -33,13 +34,14 @@
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@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"
},
diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx
index 3a69faa18f..b251ebfb6d 100644
--- a/packages/dev-utils/src/devApp/render.tsx
+++ b/packages/dev-utils/src/devApp/render.tsx
@@ -14,11 +14,11 @@
* 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';
import BookmarkIcon from '@material-ui/icons/Bookmark';
-import { ThemeProvider, CssBaseline } from '@material-ui/core';
import {
createApp,
SidebarPage,
@@ -30,7 +30,6 @@ import {
ApiTestRegistry,
ApiHolder,
} from '@backstage/core';
-import { lightTheme } from '@backstage/theme';
import * as defaultApiFactories from './apiFactories';
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
@@ -78,16 +77,12 @@ class DevAppBuilder {
const DevApp: FC<{}> = () => {
return (
-
-
-
-
- {sidebar}
-
-
-
-
-
+
+
+ {sidebar}
+
+
+
);
};
@@ -96,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);
diff --git a/packages/dev-utils/src/setupTests.ts b/packages/dev-utils/src/setupTests.ts
index 8925258421..825bcd4115 100644
--- a/packages/dev-utils/src/setupTests.ts
+++ b/packages/dev-utils/src/setupTests.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/dev-utils/tsconfig.json b/packages/dev-utils/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/dev-utils/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js
index 7243452357..5f9bde16c3 100644
--- a/packages/storybook/.storybook/main.js
+++ b/packages/storybook/.storybook/main.js
@@ -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(
diff --git a/packages/storybook/package.json b/packages/storybook/package.json
index 64be72bdcf..be449f91ed 100644
--- a/packages/storybook/package.json
+++ b/packages/storybook/package.json
@@ -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": [
diff --git a/packages/storybook/tsconfig.json b/packages/storybook/tsconfig.json
deleted file mode 100644
index 87132f9b4f..0000000000
--- a/packages/storybook/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": [".storybook/**/*"],
- "compilerOptions": {}
-}
diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json
index 8509763333..6efe6f9400 100644
--- a/packages/test-utils-core/package.json
+++ b/packages/test-utils-core/package.json
@@ -17,6 +17,7 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -27,7 +28,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
diff --git a/packages/test-utils-core/src/setupTests.ts b/packages/test-utils-core/src/setupTests.ts
index 8925258421..825bcd4115 100644
--- a/packages/test-utils-core/src/setupTests.ts
+++ b/packages/test-utils-core/src/setupTests.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/test-utils-core/tsconfig.json b/packages/test-utils-core/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/test-utils-core/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index 0367036610..06bf415bd6 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -17,6 +17,7 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -31,9 +32,9 @@
"@backstage/test-utils-core": "^0.1.1-alpha.5",
"@backstage/theme": "^0.1.1-alpha.5",
"@material-ui/core": "^4.9.1",
- "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/jest-dom": "^5.7.0",
"@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",
diff --git a/packages/test-utils/src/setupTests.ts b/packages/test-utils/src/setupTests.ts
index 8925258421..825bcd4115 100644
--- a/packages/test-utils/src/setupTests.ts
+++ b/packages/test-utils/src/setupTests.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-import '@testing-library/jest-dom/extend-expect';
+import '@testing-library/jest-dom';
diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/test-utils/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/packages/theme/package.json b/packages/theme/package.json
index be43eb616a..7a65ab9a4c 100644
--- a/packages/theme/package.json
+++ b/packages/theme/package.json
@@ -17,6 +17,7 @@
],
"license": "Apache-2.0",
"main": "dist/index.esm.js",
+ "main:src": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "backstage-cli plugin:build",
diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts
index 4a2216546f..bac67f4e47 100644
--- a/packages/theme/src/baseTheme.ts
+++ b/packages/theme/src/baseTheme.ts
@@ -21,12 +21,17 @@ import { Overrides } from '@material-ui/core/styles/overrides';
import {
BackstageTheme,
BackstageThemeOptions,
- BackstagePaletteOptions,
+ SimpleThemeOptions,
} from './types';
+const DEFAULT_FONT_FAMILY =
+ '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif';
+
export function createThemeOptions(
- palette: BackstagePaletteOptions,
+ options: SimpleThemeOptions,
): BackstageThemeOptions {
+ const { palette, fontFamily = DEFAULT_FONT_FAMILY } = options;
+
return {
palette,
props: {
@@ -38,7 +43,7 @@ export function createThemeOptions(
},
},
typography: {
- fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif',
+ fontFamily,
h5: {
fontWeight: 700,
},
@@ -205,8 +210,8 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides {
// Creates a Backstage MUI theme using a palette.
// The theme is created with the common Backstage options and component styles.
-export function createTheme(palette: BackstagePaletteOptions): BackstageTheme {
- const themeOptions = createThemeOptions(palette);
+export function createTheme(options: SimpleThemeOptions): BackstageTheme {
+ const themeOptions = createThemeOptions(options);
const baseTheme = createMuiTheme(themeOptions) as BackstageTheme;
const overrides = createThemeOverrides(baseTheme);
const theme = { ...baseTheme, overrides };
diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts
index 33cefe3283..ff86cca330 100644
--- a/packages/theme/src/themes.ts
+++ b/packages/theme/src/themes.ts
@@ -18,81 +18,85 @@ import { createTheme } from './baseTheme';
import { blue, yellow } from '@material-ui/core/colors';
export const lightTheme = createTheme({
- type: 'light',
- background: {
- default: '#F8F8F8',
- },
- status: {
- ok: '#1DB954',
- warning: '#FF9800',
- error: '#E22134',
- running: '#2E77D0',
- pending: '#FFED51',
- aborted: '#757575',
- },
- bursts: {
- fontColor: '#FEFEFE',
- slackChannelText: '#ddd',
- backgroundColor: {
- default: '#7C3699',
+ palette: {
+ type: 'light',
+ background: {
+ default: '#F8F8F8',
},
+ status: {
+ ok: '#1DB954',
+ warning: '#FF9800',
+ error: '#E22134',
+ running: '#2E77D0',
+ pending: '#FFED51',
+ aborted: '#757575',
+ },
+ bursts: {
+ fontColor: '#FEFEFE',
+ slackChannelText: '#ddd',
+ backgroundColor: {
+ default: '#7C3699',
+ },
+ },
+ primary: {
+ main: blue[500],
+ },
+ border: '#E6E6E6',
+ textContrast: '#000000',
+ textVerySubtle: '#DDD',
+ textSubtle: '#6E6E6E',
+ highlight: '#FFFBCC',
+ errorBackground: '#FFEBEE',
+ warningBackground: '#F59B23',
+ infoBackground: '#ebf5ff',
+ errorText: '#CA001B',
+ infoText: '#004e8a',
+ warningText: '#FEFEFE',
+ linkHover: '#2196F3',
+ link: '#0A6EBE',
+ gold: yellow.A700,
+ sidebar: '#171717',
},
- primary: {
- main: blue[500],
- },
- border: '#E6E6E6',
- textContrast: '#000000',
- textVerySubtle: '#DDD',
- textSubtle: '#6E6E6E',
- highlight: '#FFFBCC',
- errorBackground: '#FFEBEE',
- warningBackground: '#F59B23',
- infoBackground: '#ebf5ff',
- errorText: '#CA001B',
- infoText: '#004e8a',
- warningText: '#FEFEFE',
- linkHover: '#2196F3',
- link: '#0A6EBE',
- gold: yellow.A700,
- sidebar: '#171717',
});
export const darkTheme = createTheme({
- type: 'dark',
- background: {
- default: '#333333',
- },
- status: {
- ok: '#1DB954',
- warning: '#FF9800',
- error: '#E22134',
- running: '#2E77D0',
- pending: '#FFED51',
- aborted: '#757575',
- },
- bursts: {
- fontColor: '#FEFEFE',
- slackChannelText: '#ddd',
- backgroundColor: {
- default: '#7C3699',
+ palette: {
+ type: 'dark',
+ background: {
+ default: '#333333',
},
+ status: {
+ ok: '#1DB954',
+ warning: '#FF9800',
+ error: '#E22134',
+ running: '#2E77D0',
+ pending: '#FFED51',
+ aborted: '#757575',
+ },
+ bursts: {
+ fontColor: '#FEFEFE',
+ slackChannelText: '#ddd',
+ backgroundColor: {
+ default: '#7C3699',
+ },
+ },
+ primary: {
+ main: blue[500],
+ },
+ border: '#E6E6E6',
+ textContrast: '#FFFFFF',
+ textVerySubtle: '#DDD',
+ textSubtle: '#EEEEEE',
+ highlight: '#FFFBCC',
+ errorBackground: '#FFEBEE',
+ warningBackground: '#F59B23',
+ infoBackground: '#ebf5ff',
+ errorText: '#CA001B',
+ infoText: '#004e8a',
+ warningText: '#FEFEFE',
+ linkHover: '#2196F3',
+ link: '#0A6EBE',
+ gold: yellow.A700,
+ sidebar: '#424242',
},
- primary: {
- main: blue[500],
- },
- border: '#E6E6E6',
- textContrast: '#FFFFFF',
- textVerySubtle: '#DDD',
- textSubtle: '#EEEEEE',
- highlight: '#FFFBCC',
- errorBackground: '#FFEBEE',
- warningBackground: '#F59B23',
- infoBackground: '#ebf5ff',
- errorText: '#CA001B',
- infoText: '#004e8a',
- warningText: '#FEFEFE',
- linkHover: '#2196F3',
- link: '#0A6EBE',
- gold: yellow.A700,
- sidebar: '#424242',
});
diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts
index 82c717e433..d11b07d3fe 100644
--- a/packages/theme/src/types.ts
+++ b/packages/theme/src/types.ts
@@ -63,3 +63,11 @@ export interface BackstageTheme extends Theme {
export interface BackstageThemeOptions extends ThemeOptions {
palette: BackstagePaletteOptions;
}
+
+/**
+ * A simpler configuration for creating a new theme that just tweaks some parts of the backstage one.
+ */
+export type SimpleThemeOptions = {
+ palette: BackstagePaletteOptions;
+ fontFamily?: string;
+};
diff --git a/packages/theme/tsconfig.json b/packages/theme/tsconfig.json
deleted file mode 100644
index 5a3931ffce..0000000000
--- a/packages/theme/tsconfig.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "extends": "../../tsconfig.json",
- "include": ["src"],
- "compilerOptions": {}
-}
diff --git a/patches/README.md b/patches/README.md
new file mode 100644
index 0000000000..5cebd1f222
--- /dev/null
+++ b/patches/README.md
@@ -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
diff --git a/patches/graphiql+1.0.0-alpha.8.patch b/patches/graphiql+1.0.0-alpha.8.patch
new file mode 100644
index 0000000000..4634aab64e
--- /dev/null
+++ b/patches/graphiql+1.0.0-alpha.8.patch
@@ -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;
diff --git a/patches/material-table+1.57.2.patch b/patches/material-table+1.57.2.patch
new file mode 100644
index 0000000000..5751243775
--- /dev/null
+++ b/patches/material-table+1.57.2.patch
@@ -0,0 +1,12 @@
+diff --git a/node_modules/material-table/types/index.d.ts b/node_modules/material-table/types/index.d.ts
+index 06b700b..5b3c765 100644
+--- a/node_modules/material-table/types/index.d.ts
++++ b/node_modules/material-table/types/index.d.ts
+@@ -228,7 +228,6 @@ export interface Options {
+ showTitle?: boolean;
+ showTextRowsSelected?: boolean;
+ search?: boolean;
+- searchText?: string;
+ searchFieldAlignment?: 'left' | 'right';
+ searchFieldStyle?: React.CSSProperties;
+ searchText?: string;
diff --git a/plugins/auth-backend/.eslintrc.js b/plugins/auth-backend/.eslintrc.js
new file mode 100644
index 0000000000..16a033dbc6
--- /dev/null
+++ b/plugins/auth-backend/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint.backend')],
+};
diff --git a/plugins/auth-backend/README.md b/plugins/auth-backend/README.md
new file mode 100644
index 0000000000..f450332e8d
--- /dev/null
+++ b/plugins/auth-backend/README.md
@@ -0,0 +1,12 @@
+# Auth Backend
+
+WORK IN PROGRESS
+
+This is the backend part of the auth plugin.
+
+It responds to auth requests from the frontend, and fulfills them by delegating
+to the appropriate provider in the backend.
+
+## Links
+
+- (The Backstage homepage)[https://backstage.io]
diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json
new file mode 100644
index 0000000000..c043bb990c
--- /dev/null
+++ b/plugins/auth-backend/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "@backstage/plugin-auth-backend",
+ "version": "0.1.1-alpha.5",
+ "main": "dist",
+ "types": "src/index.ts",
+ "license": "Apache-2.0",
+ "private": true,
+ "scripts": {
+ "start": "tsc-watch --onFirstSuccess \"cross-env NODE_ENV=development nodemon dist/run.js\"",
+ "build": "tsc",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "prepack": "backstage-cli prepack",
+ "postpack": "backstage-cli postpack",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "^0.1.1-alpha.5",
+ "compression": "^1.7.4",
+ "cors": "^2.8.5",
+ "express": "^4.17.1",
+ "express-promise-router": "^3.0.3",
+ "fs-extra": "^9.0.0",
+ "helmet": "^3.22.0",
+ "morgan": "^1.10.0",
+ "winston": "^3.2.1",
+ "yn": "^4.0.0"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.5",
+ "jest-fetch-mock": "^3.0.3",
+ "tsc-watch": "^4.2.3"
+ },
+ "files": [
+ "dist"
+ ],
+ "nodemonConfig": {
+ "watch": "./dist"
+ }
+}
diff --git a/plugins/auth-backend/src/index.test.ts b/plugins/auth-backend/src/index.test.ts
new file mode 100644
index 0000000000..b3e2f19771
--- /dev/null
+++ b/plugins/auth-backend/src/index.test.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+describe('test', () => {
+ it('unbreaks the test runner', () => {
+ expect(true).toBeTruthy();
+ });
+});
diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts
new file mode 100644
index 0000000000..7612c392a2
--- /dev/null
+++ b/plugins/auth-backend/src/index.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './service/router';
diff --git a/plugins/auth-backend/src/run.ts b/plugins/auth-backend/src/run.ts
new file mode 100644
index 0000000000..0a684c379e
--- /dev/null
+++ b/plugins/auth-backend/src/run.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import yn from 'yn';
+import { getRootLogger } from '@backstage/backend-common';
+import { startStandaloneServer } from './service/standaloneServer';
+
+const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
+const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
+const logger = getRootLogger();
+
+startStandaloneServer({ port, enableCors, logger }).catch((err) => {
+ logger.error(err);
+ process.exit(1);
+});
+
+process.on('SIGINT', () => {
+ logger.info('CTRL+C pressed; exiting.');
+ process.exit(0);
+});
diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts
new file mode 100644
index 0000000000..f238d973aa
--- /dev/null
+++ b/plugins/auth-backend/src/service/router.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 express from 'express';
+import Router from 'express-promise-router';
+import { Logger } from 'winston';
+
+export interface RouterOptions {
+ logger: Logger;
+}
+
+export async function createRouter(
+ options: RouterOptions,
+): Promise {
+ const logger = options.logger.child({ plugin: 'auth' });
+ const router = Router();
+
+ router.get('/ping', async (_req, res) => {
+ res.status(200).send('pong');
+ });
+
+ const app = express();
+ app.set('logger', logger);
+ app.use(router);
+
+ return app;
+}
diff --git a/plugins/auth-backend/src/service/standaloneApplication.ts b/plugins/auth-backend/src/service/standaloneApplication.ts
new file mode 100644
index 0000000000..5eea4fb8f5
--- /dev/null
+++ b/plugins/auth-backend/src/service/standaloneApplication.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 {
+ errorHandler,
+ notFoundHandler,
+ requestLoggingHandler,
+} from '@backstage/backend-common';
+import compression from 'compression';
+import cors from 'cors';
+import express from 'express';
+import helmet from 'helmet';
+import { Logger } from 'winston';
+import { createRouter } from './router';
+
+export interface ApplicationOptions {
+ enableCors: boolean;
+ logger: Logger;
+}
+
+export async function createStandaloneApplication(
+ options: ApplicationOptions,
+): Promise {
+ const { enableCors, logger } = options;
+ const app = express();
+
+ app.use(helmet());
+ if (enableCors) {
+ app.use(cors());
+ }
+ app.use(compression());
+ app.use(express.json());
+ app.use(requestLoggingHandler());
+ app.use('/', await createRouter({ logger }));
+ app.use(notFoundHandler());
+ app.use(errorHandler());
+
+ return app;
+}
diff --git a/plugins/auth-backend/src/service/standaloneServer.ts b/plugins/auth-backend/src/service/standaloneServer.ts
new file mode 100644
index 0000000000..be1021d604
--- /dev/null
+++ b/plugins/auth-backend/src/service/standaloneServer.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { Server } from 'http';
+import { Logger } from 'winston';
+import { createStandaloneApplication } from './standaloneApplication';
+
+export interface ServerOptions {
+ port: number;
+ enableCors: boolean;
+ logger: Logger;
+}
+
+export async function startStandaloneServer(
+ options: ServerOptions,
+): Promise {
+ const logger = options.logger.child({ service: 'auth-backend' });
+
+ logger.debug('Creating application...');
+ const app = await createStandaloneApplication({
+ enableCors: options.enableCors,
+ logger,
+ });
+
+ logger.debug('Starting application server...');
+ return await new Promise((resolve, reject) => {
+ const server = app.listen(options.port, (err?: Error) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ logger.info(`Listening on port ${options.port}`);
+ resolve(server);
+ });
+ });
+}
diff --git a/plugins/auth-backend/src/setupTests.ts b/plugins/auth-backend/src/setupTests.ts
new file mode 100644
index 0000000000..3fa7cb04b4
--- /dev/null
+++ b/plugins/auth-backend/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+require('jest-fetch-mock').enableMocks();
diff --git a/plugins/auth-backend/tsconfig.json b/plugins/auth-backend/tsconfig.json
new file mode 100644
index 0000000000..015a967f76
--- /dev/null
+++ b/plugins/auth-backend/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "outDir": "dist",
+ "incremental": true,
+ "sourceMap": true,
+ "declaration": true,
+ "strict": true,
+ "target": "es2019",
+ "module": "commonjs",
+ "esModuleInterop": true,
+ "lib": ["es2019"],
+ "types": ["node", "jest"]
+ }
+}
diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json
index 477cf5e54f..b4aa397883 100644
--- a/plugins/catalog-backend/package.json
+++ b/plugins/catalog-backend/package.json
@@ -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": {
diff --git a/plugins/catalog-backend/src/service/router.ts b/plugins/catalog-backend/src/service/router.ts
index 479c4c2c1f..d2fda8a587 100644
--- a/plugins/catalog-backend/src/service/router.ts
+++ b/plugins/catalog-backend/src/service/router.ts
@@ -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);
})
diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json
index 1987dea1f5..b55cf15fc6 100644
--- a/plugins/catalog/package.json
+++ b/plugins/catalog/package.json
@@ -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,
@@ -28,9 +29,10 @@
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.5",
"@backstage/dev-utils": "^0.1.1-alpha.5",
- "@testing-library/jest-dom": "^4.2.4",
+ "@backstage/test-utils": "^0.1.1-alpha.5",
+ "@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
- "@testing-library/user-event": "^7.1.2",
+ "@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.1",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
index 01d821e175..d018d59837 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx
@@ -19,12 +19,19 @@ import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
+import { ComponentFactory } from '../../data/component';
+
+const testComponentFactory: ComponentFactory = {
+ getAllComponents: jest.fn(() => Promise.resolve([{ name: 'test' }])),
+ getComponentByName: jest.fn(() => Promise.resolve({ name: 'test' })),
+ removeComponentByName: jest.fn(() => Promise.resolve(true)),
+};
describe('CatalogPage', () => {
it('should render', async () => {
const rendered = render(
-
+ ,
);
expect(await rendered.findByText('Your components')).toBeInTheDocument();
diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
index 9972b9d71f..7889c5e0c6 100644
--- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
+++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx
@@ -18,12 +18,12 @@ import React, { FC } from 'react';
import { Content, Header, Page, pageTheme } from '@backstage/core';
import { useAsync } from 'react-use';
import { ComponentFactory } from '../../data/component';
-import { MockComponentFactory } from '../../data/mock-factory';
import CatalogTable from '../CatalogTable/CatalogTable';
-const componentFactory: ComponentFactory = MockComponentFactory;
-
-const CatalogPage: FC<{}> = () => {
+type CatalogPageProps = {
+ componentFactory: ComponentFactory;
+};
+const CatalogPage: FC = ({ componentFactory }) => {
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
return (
diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx
index 31f15fe5ca..ea23281262 100644
--- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx
+++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx
@@ -16,13 +16,16 @@
import React, { FC } from 'react';
import { Component } from '../../data/component';
import { InfoCard, Progress, Table, TableColumn } from '@backstage/core';
-import { Typography } from '@material-ui/core';
+import { Typography, Link } from '@material-ui/core';
const columns: TableColumn[] = [
{
title: 'Name',
field: 'name',
highlight: true,
+ render: (componentData: any) => (
+ {componentData.name}
+ ),
},
];
diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx
new file mode 100644
index 0000000000..cd9bb95b23
--- /dev/null
+++ b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import ComponentContextMenu from './ComponentContextMenu';
+import { render } from '@testing-library/react';
+import * as React from 'react';
+import { act } from 'react-dom/test-utils';
+
+describe('ComponentContextMenu', () => {
+ it('should call onUnregisterComponent on button click', async () => {
+ await act(async () => {
+ const mockCallback = jest.fn();
+ const menu = await render(
+ ,
+ );
+ const button = await menu.findByTestId('menu-button');
+ button.click();
+ const unregister = await menu.findByText('Unregister component');
+ expect(unregister).toBeInTheDOM();
+ });
+ });
+});
diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx
new file mode 100644
index 0000000000..674d979f83
--- /dev/null
+++ b/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React, { FC, useEffect, useRef, useState } from 'react';
+import {
+ IconButton,
+ ListItemIcon,
+ Menu,
+ MenuItem,
+ Typography,
+} from '@material-ui/core';
+import Cancel from '@material-ui/icons/Cancel';
+import MoreVert from '@material-ui/icons/MoreVert';
+import SwapHoriz from '@material-ui/icons/SwapHoriz';
+import { makeStyles } from '@material-ui/core/styles';
+
+const useStyles = makeStyles({
+ menu: {
+ marginTop: 52,
+ },
+});
+
+type ComponentContextMenuProps = {
+ onUnregisterComponent: () => void;
+};
+
+const ComponentContextMenu: FC = ({
+ onUnregisterComponent,
+}) => {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuAnchor = useRef(null);
+ const classes = useStyles();
+
+ useEffect(() => {
+ const globalCloseHandler = (event: any) => {
+ const menu = menuAnchor.current;
+ if (menu !== null && !menu.contains(event.target)) {
+ setMenuOpen(false);
+ }
+ };
+
+ window.addEventListener('click', globalCloseHandler);
+ return () => window.removeEventListener('click', globalCloseHandler);
+ }, [menuOpen]);
+
+ return (
+