Merge branch 'master' of github.com:spotify/backstage into catalog/deduplicate-locations
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
@@ -24,6 +24,15 @@ plugin directly by navigating to `http://localhost:3000/my-plugin`._
|
||||
<img src='https://github.com/spotify/backstage/raw/master/docs/getting-started/my-plugin_screenshot.png' width='600' alt='my plugin'>
|
||||
</p>
|
||||
|
||||
You can also serve the plugin in isolation by running `yarn start` in the plugin directory. Or by using the yarn workspace command, for example:
|
||||
|
||||
```bash
|
||||
yarn workspace @backstage/plugin-welcome start # Also supports --check
|
||||
```
|
||||
|
||||
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
|
||||
It is only meant for local development, and the setup for it can be found inside the plugin's `dev/` directory.
|
||||
|
||||
[Next Step - Structure of a plugin](structure-of-a-plugin.md)
|
||||
|
||||
[Back to Getting Started](README.md)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Development Environment
|
||||
|
||||
## Serving the Example App
|
||||
|
||||
Open a terminal window and start the web app using the following commands from the project root:
|
||||
|
||||
```bash
|
||||
@@ -21,6 +23,40 @@ You can now view example-app in the browser.
|
||||
On Your Network: http://192.168.1.224:8080
|
||||
```
|
||||
|
||||
## Editor
|
||||
|
||||
The Backstage development environment does not require any specific editor, but it is intended to be used with one that has built-in linting and type-checking. The development server does not include any checks by default, but they can be enabled using the `--check` flag. Note that using the flag may consume more system resources and slow things down.
|
||||
|
||||
## Package Scripts
|
||||
|
||||
There are many commands to be found in the root [package.json](package.json), here are some useful ones:
|
||||
|
||||
```python
|
||||
yarn start # Start serving the example app, use --check to include type checks and linting
|
||||
|
||||
yarn storybook # Start local storybook, useful for working on components in @backstage/core
|
||||
|
||||
yarn workspace @backstage/plugin-welcome start # Serve welcome plugin only, also supports --check
|
||||
|
||||
yarn tsc # Run typecheck, use --watch for watch mode
|
||||
|
||||
yarn build # Build published versions of packages, depends on tsc
|
||||
|
||||
yarn lint # lint packages that have changed since later commit on origin/master
|
||||
yarn lint:all # lint all packages
|
||||
|
||||
yarn test # test packages that have changed since later commit on origin/master
|
||||
yarn test:all # test all packages
|
||||
|
||||
yarn clean # Remove all output folders and @backstage/cli cache
|
||||
|
||||
yarn bundle # Build a production bundle of the example app
|
||||
|
||||
yarn diff # Make sure all plugins are up to date with the latest plugin template
|
||||
|
||||
yarn create-plugin # Create a new plugin
|
||||
```
|
||||
|
||||
### (Optional)Try on Docker
|
||||
|
||||
Run the following commands if you have Docker environment
|
||||
|
||||
+7
-4
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+10
-48
@@ -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 (
|
||||
<AppProvider>
|
||||
<ThemeContext.Provider value={themeContext}>
|
||||
<ThemeProvider theme={backstageTheme}>
|
||||
<CssBaseline>
|
||||
<AlertDisplay forwarder={alertApiForwarder} />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
</ThemeContext.Provider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
const App: FC<{}> = () => (
|
||||
<AppProvider>
|
||||
<AlertDisplay forwarder={alertApiForwarder} />
|
||||
<Router>
|
||||
<Root>
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -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<ThemeContextType>({
|
||||
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];
|
||||
}
|
||||
@@ -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 (
|
||||
<div className={classes.root}>
|
||||
@@ -75,21 +78,30 @@ const SidebarLogo: FC<{}> = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSearch = (query: string): void => {
|
||||
// XXX (@koroeskohr): for testing purposes
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(query);
|
||||
};
|
||||
|
||||
const Root: FC<{}> = ({ children }) => (
|
||||
<SidebarPage>
|
||||
<Sidebar>
|
||||
<SidebarLogo />
|
||||
<SidebarSpacer />
|
||||
<SidebarSearchField onSearch={handleSearch} />
|
||||
<SidebarDivider />
|
||||
{/* Global nav, not org-specific */}
|
||||
<SidebarItem icon={HomeIcon} to="/" text="Home" />
|
||||
<SidebarItem icon={ExploreIcon} to="/explore" text="Explore" />
|
||||
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={AccountTreeIcon} to="/catalog" text="Catalog" />
|
||||
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
|
||||
<SidebarDivider />
|
||||
<SidebarSpace />
|
||||
<ToggleThemeSidebarItem />
|
||||
<SidebarDivider />
|
||||
<SidebarThemeToggle />
|
||||
<SidebarUserBadge />
|
||||
</Sidebar>
|
||||
{children}
|
||||
</SidebarPage>
|
||||
|
||||
@@ -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.Consumer>
|
||||
{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 (
|
||||
<SidebarItem
|
||||
text={text}
|
||||
onClick={themeContext.toggleTheme}
|
||||
icon={icon}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</ThemeContext.Consumer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToggleThemeSidebarItem;
|
||||
@@ -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';
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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/<Icon>' instead.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
+45
-21
@@ -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: ['<rootDir>'],
|
||||
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 = <repo-root>/packages/core/src/index.ts is added to moduleNameMapper
|
||||
for (const pkg of packages) {
|
||||
const mainSrc = pkg.get('main:src');
|
||||
if (mainSrc) {
|
||||
moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
rootDir: path.resolve('src'),
|
||||
coverageDirectory: path.resolve('coverage'),
|
||||
collectCoverageFrom: ['**/*.{js,jsx,ts,tsx}', '!**/*.d.ts'],
|
||||
moduleNameMapper,
|
||||
|
||||
// We build .esm.js files with plugin:build, so to be able to load these in tests they need to be transformed
|
||||
// TODO: jest is working on module support, it's possible that we can remove this in the future
|
||||
transform: {
|
||||
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
|
||||
'\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'),
|
||||
},
|
||||
// Default behaviour is to not apply transforms for node_modules, but we still want to tranform .esm.js files
|
||||
|
||||
// Default behaviour is to not apply transforms for node_modules, but we still want
|
||||
// to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
|
||||
transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'],
|
||||
};
|
||||
|
||||
// Use src/setupTests.ts as the default location for configuring test env
|
||||
if (fs.existsSync('src/setupTests.ts')) {
|
||||
extraOptions.setupFilesAfterEnv = ['<rootDir>/setupTests.ts'];
|
||||
options.setupFilesAfterEnv = ['<rootDir>/setupTests.ts'];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
// We base the jest config on web-scripts, it's pretty flat so we skip any complex merging of config objects
|
||||
// Config can be found here: https://github.com/spotify/web-scripts/blob/master/packages/web-scripts/config/jest.config.js
|
||||
...require('@spotify/web-scripts/config/jest.config.js'),
|
||||
|
||||
...extraOptions,
|
||||
return {
|
||||
...options,
|
||||
|
||||
// If the package has a jest object in package.json we merge that config in. This is the recommended
|
||||
// location for configuring tests.
|
||||
...require(path.resolve('package.json')).jest,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = getConfig();
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -14,20 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { run } from '../../lib/run';
|
||||
import { createLogFunc } from '../../lib/logging';
|
||||
import { watchDeps } from '../../lib/watchDeps';
|
||||
import { serveBundle } from '../../lib/bundler';
|
||||
import { Command } from 'commander';
|
||||
|
||||
export default async () => {
|
||||
// Start dynamic watch and build of dependencies, then serve the app
|
||||
await watchDeps({ build: true });
|
||||
|
||||
await run('react-scripts', ['start'], {
|
||||
env: {
|
||||
EXTEND_ESLINT: 'true',
|
||||
SKIP_PREFLIGHT_CHECK: 'true',
|
||||
},
|
||||
// We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
|
||||
stdoutLogFunc: createLogFunc(process.stdout),
|
||||
export default async (cmd: Command) => {
|
||||
const waitForExit = await serveBundle({
|
||||
entry: 'src/index',
|
||||
checksEnabled: cmd.check,
|
||||
});
|
||||
|
||||
await waitForExit();
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import os from 'os';
|
||||
import { Task, templatingTask } from '../../lib/tasks';
|
||||
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { version } from '../../lib/version';
|
||||
const exec = promisify(execCb);
|
||||
@@ -57,19 +57,22 @@ async function cleanUp(tempDir: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function buildApp(appFolder: string) {
|
||||
const commands = ['yarn install', 'yarn build'];
|
||||
for (const command of commands) {
|
||||
await Task.forItem('executing', command, async () => {
|
||||
process.chdir(appFolder);
|
||||
async function buildApp(appDir: string) {
|
||||
const runCmd = async (cmd: string) => {
|
||||
await Task.forItem('executing', cmd, async () => {
|
||||
process.chdir(appDir);
|
||||
|
||||
await exec(command).catch((error) => {
|
||||
await exec(cmd).catch((error) => {
|
||||
process.stdout.write(error.stderr);
|
||||
process.stdout.write(error.stdout);
|
||||
throw new Error(`Could not execute command ${chalk.cyan(command)}`);
|
||||
throw new Error(`Could not execute command ${chalk.cyan(cmd)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await installWithLocalDeps(appDir);
|
||||
await runCmd('yarn tsc');
|
||||
await runCmd('yarn build');
|
||||
}
|
||||
|
||||
export async function moveApp(
|
||||
@@ -86,44 +89,6 @@ export async function moveApp(
|
||||
});
|
||||
}
|
||||
|
||||
async function addPackageResolutions(appDir: string) {
|
||||
const pkgJsonPath = resolvePath(appDir, 'package.json');
|
||||
const pkgJson = await fs.readJson(pkgJsonPath);
|
||||
|
||||
pkgJson.resolutions = pkgJson.resolutions || {};
|
||||
pkgJson.dependencies = pkgJson.dependencies || {};
|
||||
|
||||
const depNames = [
|
||||
'cli',
|
||||
'core',
|
||||
'dev-utils',
|
||||
'test-utils',
|
||||
'test-utils-core',
|
||||
'theme',
|
||||
];
|
||||
|
||||
for (const name of depNames) {
|
||||
await Task.forItem(
|
||||
'adding',
|
||||
`@backstage/${name} link to package.json`,
|
||||
async () => {
|
||||
const pkgPath = paths.resolveOwnRoot('packages', name);
|
||||
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
|
||||
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
|
||||
await fs
|
||||
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
|
||||
.catch((error) => {
|
||||
throw new Error(
|
||||
`Failed to add resolutions to package.json: ${error.message}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
@@ -164,12 +129,6 @@ export default async () => {
|
||||
Task.section('Moving to final location');
|
||||
await moveApp(tempDir, appDir, answers.name);
|
||||
|
||||
// e2e testing needs special treatment
|
||||
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
|
||||
Task.section('Linking packages locally for e2e tests');
|
||||
await addPackageResolutions(appDir);
|
||||
}
|
||||
|
||||
Task.section('Building the app');
|
||||
await buildApp(appDir);
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from '../../lib/codeowners';
|
||||
import { paths } from '../../lib/paths';
|
||||
import { version } from '../../lib/version';
|
||||
import { Task, templatingTask } from '../../lib/tasks';
|
||||
import { Task, templatingTask, installWithLocalDeps } from '../../lib/tasks';
|
||||
const exec = promisify(execCb);
|
||||
|
||||
async function checkExists(rootDir: string, id: string) {
|
||||
@@ -141,7 +141,9 @@ async function cleanUp(tempDir: string) {
|
||||
}
|
||||
|
||||
async function buildPlugin(pluginFolder: string) {
|
||||
const commands = ['yarn install', 'yarn build'];
|
||||
await installWithLocalDeps(paths.targetRoot);
|
||||
|
||||
const commands = ['yarn tsc', 'yarn build'];
|
||||
for (const command of commands) {
|
||||
await Task.forItem('executing', command, async () => {
|
||||
process.chdir(pluginFolder);
|
||||
|
||||
@@ -14,85 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { rollup, watch, OutputOptions } from 'rollup';
|
||||
import { Command } from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import { withCache, getDefaultCacheOptions } from '../../lib/buildCache';
|
||||
import { paths } from '../../lib/paths';
|
||||
import conf from './rollup.config';
|
||||
import { buildPackage } from '../../lib/packager';
|
||||
|
||||
function logError(error: any) {
|
||||
console.log('');
|
||||
|
||||
if (error.code === 'PLUGIN_ERROR') {
|
||||
// typescript2 plugin has a complete message with all codeframes
|
||||
if (error.plugin === 'rpt2') {
|
||||
console.log(error.message);
|
||||
} else {
|
||||
// Log which plugin is causing errors to make it easier to identity.
|
||||
// If we see these in logs we likely want to provide some custom error
|
||||
// output for those plugins too.
|
||||
console.log(`(plugin ${error.plugin}) ${error}`);
|
||||
}
|
||||
} else {
|
||||
// Generic rollup errors, log what's available
|
||||
if (error.loc) {
|
||||
const file = `${paths.resolveTarget((error.loc.file || error.id)!)}`;
|
||||
const pos = `${error.loc.line}:${error.loc.column}`;
|
||||
console.log(`${file} [${pos}]`);
|
||||
} else if (error.id) {
|
||||
console.log(paths.resolveTarget(error.id));
|
||||
}
|
||||
|
||||
console.log(String(error));
|
||||
|
||||
if (error.url) {
|
||||
console.log(chalk.cyan(error.url));
|
||||
}
|
||||
|
||||
if (error.frame) {
|
||||
console.log(chalk.dim(error.frame));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default async (cmd: Command) => {
|
||||
if (cmd.watch) {
|
||||
// We're not resolving this promise because watch() doesn't have any exit event.
|
||||
// Instead we just wait until the user sends an interrupt signal.
|
||||
await new Promise(() => {
|
||||
const watcher = watch(conf);
|
||||
watcher.on('event', (event) => {
|
||||
// START — the watcher is (re)starting
|
||||
// BUNDLE_START — building an individual bundle
|
||||
// BUNDLE_END — finished building a bundle
|
||||
// END — finished building all bundles
|
||||
// ERROR — encountered an error while bundling
|
||||
|
||||
if (event.code === 'ERROR') {
|
||||
logError(event.error);
|
||||
} else if (event.code === 'BUNDLE_START') {
|
||||
console.log(chalk.dim(`building ${event.input}`));
|
||||
} else if (event.code === 'BUNDLE_END') {
|
||||
const { input, duration } = event;
|
||||
const s = (duration / 1000).toFixed(1);
|
||||
console.log(chalk.green(`built ${input} in ${s}s`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await withCache(getDefaultCacheOptions(), async () => {
|
||||
try {
|
||||
const bundle = await rollup({
|
||||
input: conf.input,
|
||||
plugins: conf.plugins,
|
||||
});
|
||||
await bundle.generate(conf.output as OutputOptions);
|
||||
await bundle.write(conf.output as OutputOptions);
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
export default async () => {
|
||||
await buildPackage();
|
||||
};
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import typescript from 'rollup-plugin-typescript2';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import imageFiles from 'rollup-plugin-image-files';
|
||||
import json from '@rollup/plugin-json';
|
||||
import { RollupWatchOptions } from 'rollup';
|
||||
import { paths } from '../../lib/paths';
|
||||
|
||||
export default {
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/index.esm.js',
|
||||
format: 'module',
|
||||
},
|
||||
plugins: [
|
||||
peerDepsExternal({
|
||||
includeDependencies: true,
|
||||
}),
|
||||
resolve({
|
||||
mainFields: ['browser', 'module', 'main'],
|
||||
}),
|
||||
commonjs({
|
||||
include: ['node_modules/**', '../../node_modules/**'],
|
||||
exclude: ['**/*.stories.*', '**/*.test.*'],
|
||||
}),
|
||||
postcss(),
|
||||
imageFiles(),
|
||||
json(),
|
||||
typescript({
|
||||
include: `${paths.resolveTarget('src')}/**/*.{js,jsx,ts,tsx}`,
|
||||
tsconfigOverride: {
|
||||
// The dev folder is for the local plugin serve, ignore it in the build
|
||||
// If we don't do this we get a folder structure similar to dist/{src,dev}/...
|
||||
exclude: ['dev'],
|
||||
compilerOptions: {
|
||||
// Use absolute path to src dir as root for declarations, relying on the default
|
||||
// seems to produce declaration maps that are relative to dist/ instead of src/
|
||||
// Using a relative path like ../src doesn't work either becaus it will be used as is in subdirs.
|
||||
sourceRoot: paths.resolveTarget('src'),
|
||||
},
|
||||
},
|
||||
clean: true,
|
||||
}),
|
||||
],
|
||||
} as RollupWatchOptions;
|
||||
+8
-8
@@ -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();
|
||||
};
|
||||
@@ -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<T extends readonly any[]>(
|
||||
};
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (rejection) => {
|
||||
process.on('unhandledRejection', rejection => {
|
||||
if (rejection instanceof Error) {
|
||||
exitWithError(rejection);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import yn from 'yn';
|
||||
import fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import webpack from 'webpack';
|
||||
import {
|
||||
measureFileSizesBeforeBuild,
|
||||
printFileSizesAfterBuild,
|
||||
} from 'react-dev-utils/FileSizeReporter';
|
||||
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
|
||||
import { createConfig } from './config';
|
||||
import { BuildOptions } from './types';
|
||||
import { resolveBundlingPaths } from './paths';
|
||||
import chalk from 'chalk';
|
||||
|
||||
// TODO(Rugvip): Limits from CRA, we might want to tweak these though.
|
||||
const WARN_AFTER_BUNDLE_GZIP_SIZE = 512 * 1024;
|
||||
const WARN_AFTER_CHUNK_GZIP_SIZE = 1024 * 1024;
|
||||
|
||||
export async function buildBundle(options: BuildOptions) {
|
||||
const { statsJsonEnabled } = options;
|
||||
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const config = createConfig(paths, {
|
||||
...options,
|
||||
checksEnabled: false,
|
||||
isDev: false,
|
||||
});
|
||||
const compiler = webpack(config);
|
||||
|
||||
const isCi = yn(process.env.CI, { default: false });
|
||||
|
||||
const previousFileSizes = await measureFileSizesBeforeBuild(paths.targetDist);
|
||||
await fs.emptyDir(paths.targetDist);
|
||||
|
||||
const { stats } = await build(compiler, isCi).catch(error => {
|
||||
console.log(chalk.red('Failed to compile.\n'));
|
||||
throw new Error(`Failed to compile.\n${error.message || error}`);
|
||||
});
|
||||
|
||||
if (statsJsonEnabled) {
|
||||
// No @types/bfj
|
||||
await require('bfj').write(
|
||||
resolvePath(paths.targetDist, 'bundle-stats.json'),
|
||||
stats.toJson(),
|
||||
);
|
||||
}
|
||||
|
||||
printFileSizesAfterBuild(
|
||||
stats,
|
||||
previousFileSizes,
|
||||
paths.targetDist,
|
||||
WARN_AFTER_BUNDLE_GZIP_SIZE,
|
||||
WARN_AFTER_CHUNK_GZIP_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
async function build(compiler: webpack.Compiler, isCi: boolean) {
|
||||
const stats = await new Promise<webpack.Stats>((resolve, reject) => {
|
||||
compiler.run((err, buildStats) => {
|
||||
if (err) {
|
||||
if (err.message) {
|
||||
const { errors } = formatWebpackMessages({
|
||||
errors: [err.message],
|
||||
warnings: new Array<string>(),
|
||||
} as webpack.Stats.ToJsonOutput);
|
||||
|
||||
throw new Error(errors[0]);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
resolve(buildStats);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const { errors, warnings } = formatWebpackMessages(
|
||||
stats.toJson({ all: false, warnings: true, errors: true }),
|
||||
);
|
||||
|
||||
if (errors.length) {
|
||||
// Only keep the first error. Others are often indicative
|
||||
// of the same problem, but confuse the reader with noise.
|
||||
throw new Error(errors[0]);
|
||||
}
|
||||
if (isCi && warnings.length) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\nTreating warnings as errors because process.env.CI = true.\n',
|
||||
),
|
||||
);
|
||||
throw new Error(warnings.join('\n\n'));
|
||||
}
|
||||
|
||||
return { stats };
|
||||
}
|
||||
+53
-79
@@ -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',
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { buildBundle } from './bundle';
|
||||
export { serveBundle } from './server';
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Options } from 'webpack';
|
||||
import { BundlingOptions } from './types';
|
||||
|
||||
export const optimization = (
|
||||
options: BundlingOptions,
|
||||
): Options.Optimization => {
|
||||
const { isDev } = options;
|
||||
|
||||
return {
|
||||
minimize: !isDev,
|
||||
runtimeChunk: 'single',
|
||||
splitChunks: {
|
||||
automaticNameDelimiter: '-',
|
||||
cacheGroups: {
|
||||
default: false,
|
||||
// Put all vendor code needed for initial page load in individual files if they're big
|
||||
// enough, if they're smaller they end up in the main
|
||||
packages: {
|
||||
chunks: 'initial',
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name(module: any) {
|
||||
// get the name. E.g. node_modules/packageName/not/this/part.js
|
||||
// or node_modules/packageName
|
||||
const packageName = module.context.match(
|
||||
/[\\/]node_modules[\\/](.*?)([\\/]|$)/,
|
||||
)[1];
|
||||
|
||||
// npm package names are URL-safe, but some servers don't like @ symbols
|
||||
return packageName.replace('@', '');
|
||||
},
|
||||
filename: isDev
|
||||
? 'module-[name].js'
|
||||
: 'module-[name].[chunkhash:8].js',
|
||||
priority: 10,
|
||||
minSize: 100000,
|
||||
minChunks: 1,
|
||||
maxAsyncRequests: Infinity,
|
||||
maxInitialRequests: Infinity,
|
||||
} as any, // filename is not included in type, but we need it
|
||||
// Group together the smallest modules
|
||||
vendor: {
|
||||
chunks: 'initial',
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
name: 'vendor',
|
||||
priority: 5,
|
||||
enforce: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+14
-6
@@ -15,9 +15,16 @@
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { paths } from '../../../lib/paths';
|
||||
import { paths } from '../paths';
|
||||
|
||||
export type BundlingPathsOptions = {
|
||||
// bundle entrypoint, e.g. 'src/index'
|
||||
entry: string;
|
||||
};
|
||||
|
||||
export function resolveBundlingPaths(options: BundlingPathsOptions) {
|
||||
const { entry } = options;
|
||||
|
||||
export function getPaths() {
|
||||
const resolveTargetModule = (path: string) => {
|
||||
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
|
||||
const filePath = paths.resolveTarget(`${path}.${ext}`);
|
||||
@@ -28,7 +35,7 @@ export function getPaths() {
|
||||
return paths.resolveTarget(`${path}.js`);
|
||||
};
|
||||
|
||||
let targetHtml = paths.resolveTarget('dev/index.html');
|
||||
let targetHtml = paths.resolveTarget(`${entry}.html`);
|
||||
if (!existsSync(targetHtml)) {
|
||||
targetHtml = paths.resolveOwn('templates/serve_index.html');
|
||||
}
|
||||
@@ -36,14 +43,15 @@ export function getPaths() {
|
||||
return {
|
||||
targetHtml,
|
||||
targetPath: paths.resolveTarget('.'),
|
||||
targetDist: paths.resolveTarget('dist'),
|
||||
targetAssets: paths.resolveTarget('assets'),
|
||||
targetSrc: paths.resolveTarget('src'),
|
||||
targetDev: paths.resolveTarget('dev'),
|
||||
targetDevEntry: resolveTargetModule('dev/index'),
|
||||
targetTsConfig: paths.resolveTarget('tsconfig.json'),
|
||||
targetEntry: resolveTargetModule(entry),
|
||||
targetTsConfig: paths.resolveTargetRoot('tsconfig.json'),
|
||||
targetNodeModules: paths.resolveTarget('node_modules'),
|
||||
targetPackageJson: paths.resolveTarget('package.json'),
|
||||
};
|
||||
}
|
||||
|
||||
export type Paths = ReturnType<typeof getPaths>;
|
||||
export type BundlingPaths = ReturnType<typeof resolveBundlingPaths>;
|
||||
+26
-7
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import webpack, { Module, Plugin } from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
|
||||
import { BundlingOptions } from './types';
|
||||
import { BundlingPaths } from './paths';
|
||||
|
||||
type Transforms = {
|
||||
loaders: Module['rules'];
|
||||
plugins: Plugin[];
|
||||
};
|
||||
|
||||
export const transforms = (
|
||||
paths: BundlingPaths,
|
||||
options: BundlingOptions,
|
||||
): Transforms => {
|
||||
const { isDev } = options;
|
||||
|
||||
const loaders = [
|
||||
{
|
||||
test: /\.(tsx?)$/,
|
||||
exclude: /node_modules/,
|
||||
loader: require.resolve('@sucrase/webpack-loader'),
|
||||
options: {
|
||||
transforms: ['typescript', 'jsx', 'react-hot-loader'],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(jsx?|mjs)$/,
|
||||
exclude: /node_modules/,
|
||||
loader: require.resolve('@sucrase/webpack-loader'),
|
||||
options: {
|
||||
transforms: ['jsx', 'react-hot-loader'],
|
||||
},
|
||||
},
|
||||
{
|
||||
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
|
||||
loader: require.resolve('url-loader'),
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: 'static/media/[name].[hash:8].[ext]',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.ya?ml$/,
|
||||
use: require.resolve('yml-loader'),
|
||||
},
|
||||
{
|
||||
include: /\.(md)$/,
|
||||
use: require.resolve('raw-loader'),
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: [
|
||||
isDev ? require.resolve('style-loader') : MiniCssExtractPlugin.loader,
|
||||
{
|
||||
loader: require.resolve('css-loader'),
|
||||
options: {
|
||||
sourceMap: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const plugins = new Array<Plugin>();
|
||||
|
||||
plugins.push(
|
||||
new HtmlWebpackPlugin({
|
||||
template: paths.targetHtml,
|
||||
}),
|
||||
);
|
||||
|
||||
if (isDev) {
|
||||
plugins.push(new webpack.HotModuleReplacementPlugin());
|
||||
} else {
|
||||
plugins.push(
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].[contenthash:8].css',
|
||||
chunkFilename: '[name].[id].[contenthash:8].css',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { loaders, plugins };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { relative as relativePath } from 'path';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import esbuild from 'rollup-plugin-esbuild';
|
||||
import imageFiles from 'rollup-plugin-image-files';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
import json from '@rollup/plugin-json';
|
||||
import { RollupOptions } from 'rollup';
|
||||
|
||||
import { paths } from '../paths';
|
||||
|
||||
export const makeConfigs = async (): Promise<RollupOptions[]> => {
|
||||
const typesInput = paths.resolveTargetRoot(
|
||||
'dist',
|
||||
relativePath(paths.targetRoot, paths.targetDir),
|
||||
'src/index.d.ts',
|
||||
);
|
||||
|
||||
const declarationsExist = await fs.pathExists(typesInput);
|
||||
if (!declarationsExist) {
|
||||
const path = relativePath(paths.targetDir, typesInput);
|
||||
throw new Error(
|
||||
`No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`,
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
input: 'src/index.ts',
|
||||
output: {
|
||||
file: 'dist/index.esm.js',
|
||||
format: 'module',
|
||||
},
|
||||
plugins: [
|
||||
peerDepsExternal({
|
||||
includeDependencies: true,
|
||||
}),
|
||||
resolve({
|
||||
mainFields: ['browser', 'module', 'main'],
|
||||
}),
|
||||
commonjs({
|
||||
include: ['node_modules/**', '../../node_modules/**'],
|
||||
exclude: ['**/*.stories.*', '**/*.test.*'],
|
||||
}),
|
||||
postcss(),
|
||||
imageFiles(),
|
||||
json(),
|
||||
esbuild({
|
||||
target: 'es2019',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
input: typesInput,
|
||||
output: {
|
||||
file: 'dist/index.d.ts',
|
||||
format: 'es',
|
||||
},
|
||||
plugins: [dts()],
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
};
|
||||
@@ -18,8 +18,12 @@ import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import handlebars from 'handlebars';
|
||||
import ora from 'ora';
|
||||
import { basename, dirname } from 'path';
|
||||
import { resolve as resolvePath, basename, dirname } from 'path';
|
||||
import recursive from 'recursive-readdir';
|
||||
import { promisify } from 'util';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { paths } from './paths';
|
||||
const exec = promisify(execCb);
|
||||
|
||||
const TASK_NAME_MAX_LENGTH = 14;
|
||||
|
||||
@@ -69,7 +73,7 @@ export async function templatingTask(
|
||||
destinationDir: string,
|
||||
context: any,
|
||||
) {
|
||||
const files = await recursive(templateDir).catch(error => {
|
||||
const files = await recursive(templateDir).catch((error) => {
|
||||
throw new Error(`Failed to read template directory: ${error.message}`);
|
||||
});
|
||||
|
||||
@@ -85,7 +89,7 @@ export async function templatingTask(
|
||||
const compiled = handlebars.compile(template.toString());
|
||||
const contents = compiled({ name: basename(destination), ...context });
|
||||
|
||||
await fs.writeFile(destination, contents).catch(error => {
|
||||
await fs.writeFile(destination, contents).catch((error) => {
|
||||
throw new Error(
|
||||
`Failed to create file: ${destination}: ${error.message}`,
|
||||
);
|
||||
@@ -93,7 +97,7 @@ export async function templatingTask(
|
||||
});
|
||||
} else {
|
||||
await Task.forItem('copying', basename(file), async () => {
|
||||
await fs.copyFile(file, destinationFile).catch(error => {
|
||||
await fs.copyFile(file, destinationFile).catch((error) => {
|
||||
const destination = destinationFile;
|
||||
throw new Error(
|
||||
`Failed to copy file to ${destination} : ${error.message}`,
|
||||
@@ -103,3 +107,99 @@ export async function templatingTask(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// List of local packages that we need to modify as a part of an E2E test
|
||||
const PATCH_PACKAGES = [
|
||||
'cli',
|
||||
'core',
|
||||
'dev-utils',
|
||||
'test-utils',
|
||||
'test-utils-core',
|
||||
'theme',
|
||||
];
|
||||
|
||||
// This runs a `yarn install` task, but with special treatment for e2e tests
|
||||
export async function installWithLocalDeps(dir: string) {
|
||||
// This makes us install any package inside this repo as a local file dependency.
|
||||
// For example, instead of trying to fetch @backstage/core from npm, we point it
|
||||
// to <repo-root>/packages/core. This makes yarn use a simple file copy to install it instead.
|
||||
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
|
||||
Task.section('Linking packages locally for e2e tests');
|
||||
|
||||
const pkgJsonPath = resolvePath(dir, 'package.json');
|
||||
const pkgJson = await fs.readJson(pkgJsonPath);
|
||||
|
||||
pkgJson.resolutions = pkgJson.resolutions || {};
|
||||
pkgJson.dependencies = pkgJson.dependencies || {};
|
||||
|
||||
if (!pkgJson.resolutions[`@backstage/${PATCH_PACKAGES[0]}`]) {
|
||||
for (const name of PATCH_PACKAGES) {
|
||||
await Task.forItem(
|
||||
'adding',
|
||||
`@backstage/${name} link to package.json`,
|
||||
async () => {
|
||||
const pkgPath = paths.resolveOwnRoot('packages', name);
|
||||
// Add to both resolutions and dependencies, or transitive dependencies will still be fetched from the registry.
|
||||
pkgJson.dependencies[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
pkgJson.resolutions[`@backstage/${name}`] = `file:${pkgPath}`;
|
||||
|
||||
await fs
|
||||
.writeJSON(pkgJsonPath, pkgJson, { encoding: 'utf8', spaces: 2 })
|
||||
.catch((error) => {
|
||||
throw new Error(
|
||||
`Failed to add resolutions to package.json: ${error.message}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.forItem('executing', 'yarn install', async () => {
|
||||
await exec('yarn install', { cwd: dir }).catch((error) => {
|
||||
process.stdout.write(error.stderr);
|
||||
process.stdout.write(error.stdout);
|
||||
throw new Error(
|
||||
`Could not execute command ${chalk.cyan('yarn install')}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// This takes care of pointing all the installed packages from this repo to
|
||||
// dist instead of the local src.
|
||||
// For example node_modules/@backstage/core/packages.json is rewritten to point
|
||||
// types to dist/index.d.ts and the main:src field is removed.
|
||||
// Without this we get type checking errors in the e2e test
|
||||
if (process.env.BACKSTAGE_E2E_CLI_TEST) {
|
||||
Task.section('Patchling local dependencies for e2e tests');
|
||||
|
||||
for (const name of PATCH_PACKAGES) {
|
||||
await Task.forItem(
|
||||
'patching',
|
||||
`node_modules/@backstage/${name} package.json`,
|
||||
async () => {
|
||||
const depJsonPath = resolvePath(
|
||||
dir,
|
||||
'node_modules/@backstage',
|
||||
name,
|
||||
'package.json',
|
||||
);
|
||||
const depJson = await fs.readJson(depJsonPath);
|
||||
|
||||
// We want dist to be used for e2e tests
|
||||
delete depJson['main:src'];
|
||||
depJson.types = 'dist/index.d.ts';
|
||||
|
||||
await fs
|
||||
.writeJSON(depJsonPath, depJson, { encoding: 'utf8', spaces: 2 })
|
||||
.catch((error) => {
|
||||
throw new Error(
|
||||
`Failed to add resolutions to package.json: ${error.message}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
},
|
||||
"scripts": {
|
||||
"start": "yarn workspace app start",
|
||||
"bundle": "yarn build && yarn workspace app bundle",
|
||||
"bundle": "yarn workspace app bundle",
|
||||
"build": "lerna run build",
|
||||
"test": "yarn build && lerna run test --since origin/master -- --coverage",
|
||||
"test:all": "yarn build && lerna run test -- --coverage",
|
||||
"tsc": "tsc",
|
||||
"clean": "backstage-cli clean && lerna run clean",
|
||||
"diff": "lerna run diff --",
|
||||
"test": "lerna run test --since origin/master -- --coverage",
|
||||
"test:all": "lerna run test -- --coverage",
|
||||
"lint": "lerna run lint --since origin/master --",
|
||||
"lint:all": "lerna run lint --",
|
||||
"create-plugin": "backstage-cli create-plugin",
|
||||
"clean": "lerna run clean"
|
||||
"remove-plugin": "backstage-cli remove-plugin",
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
@@ -25,6 +29,7 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{version}}",
|
||||
"lerna": "^3.20.2",
|
||||
"patch-package": "^6.2.2",
|
||||
"prettier": "^1.19.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<Router>
|
||||
<AppComponent />
|
||||
</Router>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
<Router>
|
||||
<AppComponent />
|
||||
</Router>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1 +1 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# patches
|
||||
|
||||
This folder contains patches for dependency type declarations. Typescript doesn't provide a way to override of fix types that are installed as a part of the dependent package itself.
|
||||
|
||||
Do not add any more patches here, these patches were added as a part of getting the entire repo type-checked, and we depended on some packages with bad types.
|
||||
|
||||
As soon as the below issues are fixed, we should remove the patching functionality completely.
|
||||
|
||||
## material-table
|
||||
|
||||
Fixed in master but not released yet, tracked here: https://github.com/mbrn/material-table/pull/1624
|
||||
@@ -0,0 +1,12 @@
|
||||
diff --git a/node_modules/material-table/types/index.d.ts b/node_modules/material-table/types/index.d.ts
|
||||
index 06b700b..5b3c765 100644
|
||||
--- a/node_modules/material-table/types/index.d.ts
|
||||
+++ b/node_modules/material-table/types/index.d.ts
|
||||
@@ -228,7 +228,6 @@ export interface Options {
|
||||
showTitle?: boolean;
|
||||
showTextRowsSelected?: boolean;
|
||||
search?: boolean;
|
||||
- searchText?: string;
|
||||
searchFieldAlignment?: 'left' | 'right';
|
||||
searchFieldStyle?: React.CSSProperties;
|
||||
searchText?: string;
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "plugin-welcome",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/index.esm.js",
|
||||
"main:src": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
{
|
||||
"extends": "@backstage/cli/config/tsconfig.json"
|
||||
"extends": "@backstage/cli/config/tsconfig.json",
|
||||
"include": ["packages/*/src", "plugins/*/src", "plugins/*/dev"],
|
||||
"exclude": ["**/node_modules"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "@backstage/plugin-{{id}}",
|
||||
"version": "{{version}}",
|
||||
"main": "dist/index.esm.js",
|
||||
"main:src": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src", "dev"],
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"extends": "./config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["**/*.test.*"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"emitDeclarationOnly": false,
|
||||
"removeComments": true,
|
||||
"module": "CommonJS"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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<number>({ id: 'a', description: '' });
|
||||
const apiBRef = new ApiRef<number>({ 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);
|
||||
});
|
||||
});
|
||||
@@ -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<T>(apiRef: ApiRef<T>): T | undefined {
|
||||
for (const holder of this.holders) {
|
||||
const api = holder.get(apiRef);
|
||||
if (api) {
|
||||
return api;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -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<string | undefined>;
|
||||
|
||||
/**
|
||||
* 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<AppThemeApi>({
|
||||
id: 'core.apptheme',
|
||||
description: 'API Used to configure the app theme, and enumerate options',
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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<AppTheme>();
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined>(undefined);
|
||||
|
||||
constructor(private readonly themes: AppTheme[]) {}
|
||||
|
||||
getInstalledThemes(): AppTheme[] {
|
||||
return this.themes.slice();
|
||||
}
|
||||
|
||||
activeThemeId$(): Observable<string | undefined> {
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
getActiveThemeId(): string | undefined {
|
||||
return this.activeThemeId;
|
||||
}
|
||||
|
||||
setActiveThemeId(themeId?: string): void {
|
||||
this.activeThemeId = themeId;
|
||||
this.subject.next(themeId);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
+9
-17
@@ -14,8 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Observable from 'zen-observable';
|
||||
import { OAuthScopes } from '../../definitions';
|
||||
import { BehaviorSubject } from '../lib';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
type RequestQueueEntry<ResultType> = {
|
||||
scopes: OAuthScopes;
|
||||
@@ -44,16 +45,15 @@ export type OAuthPendingRequestsApi<ResultType> = {
|
||||
export class OAuthPendingRequests<ResultType>
|
||||
implements OAuthPendingRequestsApi<ResultType> {
|
||||
private requests: RequestQueueEntry<ResultType>[] = [];
|
||||
private listeners: ZenObservable.SubscriptionObserver<
|
||||
PendingRequest<ResultType>
|
||||
>[] = [];
|
||||
private subject = new BehaviorSubject<PendingRequest<ResultType>>(
|
||||
this.getCurrentPending(),
|
||||
);
|
||||
|
||||
request(scopes: OAuthScopes): Promise<ResultType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.requests.push({ scopes, resolve, reject });
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach((listener) => listener.next(pending));
|
||||
this.subject.next(this.getCurrentPending());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,26 +66,18 @@ export class OAuthPendingRequests<ResultType>
|
||||
return true;
|
||||
});
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach((listener) => listener.next(pending));
|
||||
this.subject.next(this.getCurrentPending());
|
||||
}
|
||||
|
||||
reject(error: Error) {
|
||||
this.requests.forEach((request) => request.reject(error));
|
||||
this.requests = [];
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach((listener) => listener.next(pending));
|
||||
this.subject.next(this.getCurrentPending());
|
||||
}
|
||||
|
||||
pending(): Observable<PendingRequest<ResultType>> {
|
||||
return new Observable((subscriber) => {
|
||||
this.listeners.push(subscriber);
|
||||
subscriber.next(this.getCurrentPending());
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== subscriber);
|
||||
};
|
||||
});
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
private getCurrentPending(): PendingRequest<ResultType> {
|
||||
|
||||
+6
-18
@@ -21,8 +21,9 @@ import {
|
||||
AuthRequester,
|
||||
AuthRequesterOptions,
|
||||
} from '../../definitions';
|
||||
import Observable from 'zen-observable';
|
||||
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
|
||||
import { BehaviorSubject } from '../lib';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
/**
|
||||
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
|
||||
@@ -32,24 +33,10 @@ import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
|
||||
* them all together into a single request for each OAuth provider.
|
||||
*/
|
||||
export class OAuthRequestManager implements OAuthRequestApi {
|
||||
private readonly request$: Observable<PendingAuthRequest[]>;
|
||||
private readonly observers = new Set<
|
||||
ZenObservable.SubscriptionObserver<PendingAuthRequest[]>
|
||||
>();
|
||||
private readonly subject = new BehaviorSubject<PendingAuthRequest[]>([]);
|
||||
private currentRequests: PendingAuthRequest[] = [];
|
||||
private handlerCount = 0;
|
||||
|
||||
constructor() {
|
||||
this.request$ = new Observable<PendingAuthRequest[]>((observer) => {
|
||||
observer.next(this.currentRequests);
|
||||
|
||||
this.observers.add(observer);
|
||||
return () => {
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
}).map((requests) => requests.filter(Boolean)); // Convert from sparse array to array of present items only
|
||||
}
|
||||
|
||||
createAuthRequester<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
|
||||
const handler = new OAuthPendingRequests<T>();
|
||||
|
||||
@@ -66,7 +53,8 @@ export class OAuthRequestManager implements OAuthRequestApi {
|
||||
newRequests[index] = request;
|
||||
}
|
||||
this.currentRequests = newRequests;
|
||||
this.observers.forEach((observer) => observer.next(newRequests));
|
||||
// Convert from sparse array to array of present items only
|
||||
this.subject.next(newRequests.filter(Boolean));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -100,7 +88,7 @@ export class OAuthRequestManager implements OAuthRequestApi {
|
||||
}
|
||||
|
||||
authRequest$(): Observable<PendingAuthRequest[]> {
|
||||
return this.request$;
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './subjects';
|
||||
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { PublishSubject, BehaviorSubject } from './subjects';
|
||||
|
||||
function observerSpy() {
|
||||
return {
|
||||
next: jest.fn(),
|
||||
error: jest.fn(),
|
||||
complete: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PublishSubject', () => {
|
||||
it('should be completed', async () => {
|
||||
const subj = new PublishSubject<number>();
|
||||
subj.complete();
|
||||
|
||||
const spy = observerSpy();
|
||||
subj.subscribe(spy);
|
||||
await 'a tick';
|
||||
|
||||
expect(spy.next).not.toHaveBeenCalled();
|
||||
expect(spy.error).not.toHaveBeenCalled();
|
||||
expect(spy.complete).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(() => subj.next(1)).toThrow('PublishSubject is closed');
|
||||
expect(() => subj.error(new Error())).toThrow('PublishSubject is closed');
|
||||
expect(() => subj.complete()).toThrow('PublishSubject is closed');
|
||||
|
||||
expect(subj.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('should forward values to all subscribers', async () => {
|
||||
const subj = new PublishSubject<number>();
|
||||
|
||||
const spy1 = observerSpy();
|
||||
const spy2 = observerSpy();
|
||||
subj.subscribe(spy1);
|
||||
const sub = subj.subscribe(spy2);
|
||||
|
||||
subj.next(42);
|
||||
|
||||
expect(spy1.next).toHaveBeenCalledTimes(1);
|
||||
expect(spy1.next).toHaveBeenCalledWith(42);
|
||||
expect(spy2.next).toHaveBeenCalledTimes(1);
|
||||
expect(spy2.next).toHaveBeenCalledWith(42);
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
subj.next(1337);
|
||||
|
||||
expect(spy1.next).toHaveBeenCalledTimes(2);
|
||||
expect(spy1.next).toHaveBeenCalledWith(1337);
|
||||
expect(spy2.next).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(spy1.error).not.toHaveBeenCalled();
|
||||
expect(spy2.error).not.toHaveBeenCalled();
|
||||
expect(spy1.complete).not.toHaveBeenCalled();
|
||||
expect(spy2.complete).not.toHaveBeenCalled();
|
||||
|
||||
expect(subj.closed).toBe(false);
|
||||
});
|
||||
|
||||
it('should forward errors', async () => {
|
||||
const subj = new PublishSubject<number>();
|
||||
|
||||
const spy1 = observerSpy();
|
||||
subj.subscribe(spy1);
|
||||
|
||||
const error = new Error('NOPE');
|
||||
subj.error(error);
|
||||
expect(spy1.error).toHaveBeenCalledWith(error);
|
||||
expect(spy1.next).not.toHaveBeenCalled();
|
||||
expect(spy1.complete).not.toHaveBeenCalled();
|
||||
|
||||
const spy2 = observerSpy();
|
||||
subj.subscribe(spy2);
|
||||
await 'a tick';
|
||||
expect(spy2.error).toHaveBeenCalledWith(error);
|
||||
expect(spy2.next).not.toHaveBeenCalled();
|
||||
expect(spy2.complete).not.toHaveBeenCalled();
|
||||
|
||||
expect(subj.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BehaviorSubject', () => {
|
||||
it('should be completed', async () => {
|
||||
const subj = new BehaviorSubject(0);
|
||||
subj.complete();
|
||||
|
||||
const next = jest.fn();
|
||||
const complete = jest.fn();
|
||||
subj.subscribe({ next, complete });
|
||||
await 'a tick';
|
||||
|
||||
expect(complete).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(() => subj.next(1)).toThrow('BehaviorSubject is closed');
|
||||
expect(() => subj.error(new Error())).toThrow('BehaviorSubject is closed');
|
||||
expect(() => subj.complete()).toThrow('BehaviorSubject is closed');
|
||||
|
||||
expect(subj.closed).toBe(true);
|
||||
});
|
||||
|
||||
it('should forward values to all subscribers', async () => {
|
||||
const subj = new BehaviorSubject(0);
|
||||
|
||||
const obs1 = jest.fn();
|
||||
const obs2 = jest.fn();
|
||||
subj.subscribe(obs1);
|
||||
const sub = subj.subscribe(obs2);
|
||||
await 'a tick';
|
||||
|
||||
expect(obs1).toHaveBeenCalledTimes(1);
|
||||
expect(obs1).toHaveBeenCalledWith(0);
|
||||
expect(obs2).toHaveBeenCalledTimes(1);
|
||||
expect(obs2).toHaveBeenCalledWith(0);
|
||||
|
||||
subj.next(42);
|
||||
|
||||
expect(obs1).toHaveBeenCalledTimes(2);
|
||||
expect(obs1).toHaveBeenCalledWith(42);
|
||||
expect(obs2).toHaveBeenCalledTimes(2);
|
||||
expect(obs2).toHaveBeenCalledWith(42);
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
subj.next(1337);
|
||||
|
||||
expect(obs1).toHaveBeenCalledTimes(3);
|
||||
expect(obs1).toHaveBeenCalledWith(1337);
|
||||
expect(obs2).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(subj.closed).toBe(false);
|
||||
});
|
||||
|
||||
it('should forward errors', async () => {
|
||||
const subj = new BehaviorSubject<number>(0);
|
||||
|
||||
const spy1 = observerSpy();
|
||||
subj.subscribe(spy1);
|
||||
await 'a tick';
|
||||
|
||||
expect(spy1.error).not.toHaveBeenCalled();
|
||||
expect(spy1.next).toHaveBeenCalledWith(0);
|
||||
|
||||
const error = new Error('NOPE');
|
||||
subj.error(error);
|
||||
expect(spy1.error).toHaveBeenCalledWith(error);
|
||||
expect(spy1.next).toHaveBeenCalledWith(0);
|
||||
expect(spy1.next).toHaveBeenCalledTimes(1);
|
||||
expect(spy1.complete).not.toHaveBeenCalled();
|
||||
|
||||
const spy2 = observerSpy();
|
||||
subj.subscribe(spy2);
|
||||
await 'a tick';
|
||||
expect(spy2.error).toHaveBeenCalledWith(error);
|
||||
expect(spy2.next).not.toHaveBeenCalled();
|
||||
expect(spy2.complete).not.toHaveBeenCalled();
|
||||
|
||||
expect(subj.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Observable } from '../../../types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
|
||||
// TODO(Rugvip): These are stopgap and probably incomplete implementations of subjects.
|
||||
// If we add a more complete Observables library they should be replaced.
|
||||
|
||||
/**
|
||||
* A basic implementation of ReactiveX publish subjects.
|
||||
*
|
||||
* A subject is a convenient way to create an observable when you want
|
||||
* to fan out a single value to all subscribers.
|
||||
*
|
||||
* See http://reactivex.io/documentation/subject.html
|
||||
*/
|
||||
export class PublishSubject<T>
|
||||
implements Observable<T>, ZenObservable.SubscriptionObserver<T> {
|
||||
private isClosed = false;
|
||||
private terminatingError?: Error;
|
||||
|
||||
private readonly observable = new ObservableImpl<T>((subscriber) => {
|
||||
if (this.isClosed) {
|
||||
if (this.terminatingError) {
|
||||
subscriber.error(this.terminatingError);
|
||||
} else {
|
||||
subscriber.complete();
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
});
|
||||
|
||||
private readonly subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<T>
|
||||
>();
|
||||
|
||||
get closed() {
|
||||
return this.isClosed;
|
||||
}
|
||||
|
||||
next(value: T) {
|
||||
if (this.isClosed) {
|
||||
throw new Error('PublishSubject is closed');
|
||||
}
|
||||
this.subscribers.forEach((subscriber) => subscriber.next(value));
|
||||
}
|
||||
|
||||
error(error: Error) {
|
||||
if (this.isClosed) {
|
||||
throw new Error('PublishSubject is closed');
|
||||
}
|
||||
this.isClosed = true;
|
||||
this.terminatingError = error;
|
||||
this.subscribers.forEach((subscriber) => subscriber.error(error));
|
||||
}
|
||||
|
||||
complete() {
|
||||
if (this.isClosed) {
|
||||
throw new Error('PublishSubject is closed');
|
||||
}
|
||||
this.isClosed = true;
|
||||
this.subscribers.forEach((subscriber) => subscriber.complete());
|
||||
}
|
||||
|
||||
subscribe(observer: ZenObservable.Observer<T>): ZenObservable.Subscription;
|
||||
subscribe(
|
||||
onNext: (value: T) => void,
|
||||
onError?: (error: any) => void,
|
||||
onComplete?: () => void,
|
||||
): ZenObservable.Subscription;
|
||||
subscribe(
|
||||
onNext: ZenObservable.Observer<T> | ((value: T) => void),
|
||||
onError?: (error: any) => void,
|
||||
onComplete?: () => void,
|
||||
): ZenObservable.Subscription {
|
||||
const observer =
|
||||
typeof onNext === 'function'
|
||||
? {
|
||||
next: onNext,
|
||||
error: onError,
|
||||
complete: onComplete,
|
||||
}
|
||||
: onNext;
|
||||
|
||||
return this.observable.subscribe(observer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A basic implementation of ReactiveX behavior subjects.
|
||||
*
|
||||
* A subject is a convenient way to create an observable when you want
|
||||
* to fan out a single value to all subscribers.
|
||||
*
|
||||
* The BehaviorSubject will emit the most recently emitted value or error
|
||||
* whenever a new observer subscribes to the subject.
|
||||
*
|
||||
* See http://reactivex.io/documentation/subject.html
|
||||
*/
|
||||
export class BehaviorSubject<T>
|
||||
implements Observable<T>, ZenObservable.SubscriptionObserver<T> {
|
||||
private isClosed = false;
|
||||
private currentValue: T;
|
||||
private terminatingError?: Error;
|
||||
|
||||
constructor(value: T) {
|
||||
this.currentValue = value;
|
||||
}
|
||||
|
||||
private readonly observable = new ObservableImpl<T>((subscriber) => {
|
||||
if (this.isClosed) {
|
||||
if (this.terminatingError) {
|
||||
subscriber.error(this.terminatingError);
|
||||
} else {
|
||||
subscriber.complete();
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
|
||||
subscriber.next(this.currentValue);
|
||||
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
});
|
||||
|
||||
private readonly subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<T>
|
||||
>();
|
||||
|
||||
get closed() {
|
||||
return this.isClosed;
|
||||
}
|
||||
|
||||
next(value: T) {
|
||||
if (this.isClosed) {
|
||||
throw new Error('BehaviorSubject is closed');
|
||||
}
|
||||
this.currentValue = value;
|
||||
this.subscribers.forEach((subscriber) => subscriber.next(value));
|
||||
}
|
||||
|
||||
error(error: Error) {
|
||||
if (this.isClosed) {
|
||||
throw new Error('BehaviorSubject is closed');
|
||||
}
|
||||
this.isClosed = true;
|
||||
this.terminatingError = error;
|
||||
this.subscribers.forEach((subscriber) => subscriber.error(error));
|
||||
}
|
||||
|
||||
complete() {
|
||||
if (this.isClosed) {
|
||||
throw new Error('BehaviorSubject is closed');
|
||||
}
|
||||
this.isClosed = true;
|
||||
this.subscribers.forEach((subscriber) => subscriber.complete());
|
||||
}
|
||||
|
||||
subscribe(observer: ZenObservable.Observer<T>): ZenObservable.Subscription;
|
||||
subscribe(
|
||||
onNext: (value: T) => void,
|
||||
onError?: (error: any) => void,
|
||||
onComplete?: () => void,
|
||||
): ZenObservable.Subscription;
|
||||
subscribe(
|
||||
onNext: ZenObservable.Observer<T> | ((value: T) => void),
|
||||
onError?: (error: any) => void,
|
||||
onComplete?: () => void,
|
||||
): ZenObservable.Subscription {
|
||||
const observer =
|
||||
typeof onNext === 'function'
|
||||
? {
|
||||
next: onNext,
|
||||
error: onError,
|
||||
complete: onComplete,
|
||||
}
|
||||
: onNext;
|
||||
|
||||
return this.observable.subscribe(observer);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
<Route key="login" path="/login" component={LoginPage} exact />,
|
||||
);
|
||||
|
||||
let rendered = (
|
||||
const rendered = (
|
||||
<Switch>
|
||||
{routes}
|
||||
<Route component={NotFoundErrorPage} />
|
||||
</Switch>
|
||||
);
|
||||
|
||||
if (this.apis) {
|
||||
rendered = <ApiProvider apis={this.apis} children={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 }) => (
|
||||
<AppContextProvider app={this}>{children}</AppContextProvider>
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>{children}</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
);
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<ThemeProvider theme={appTheme.theme}>
|
||||
<CssBaseline>{children}</CssBaseline>
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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<AppComponents>;
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type { NavTarget, NavTargetConfig, NavTargetOverrideConfig } from './types';
|
||||
export * from './types';
|
||||
export { createNavTarget } from './NavTarget';
|
||||
|
||||
@@ -72,7 +72,7 @@ export const Overflow = () => (
|
||||
</>
|
||||
);
|
||||
|
||||
export const Laguages = () => (
|
||||
export const Languages = () => (
|
||||
<>
|
||||
<CodeSnippet text={JAVASCRIPT} language="javascript" showLineNumbers />
|
||||
<CodeSnippet text={TYPESCRIPT} language="typescript" showLineNumbers />
|
||||
|
||||
@@ -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<SVGSVGElement>) => (
|
||||
|
||||
@@ -20,7 +20,7 @@ import React, { FC, useRef, useState } from 'react';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
const useStyles = makeStyles<BackstageTheme>((theme) => ({
|
||||
root: {
|
||||
zIndex: 1000,
|
||||
position: 'relative',
|
||||
@@ -115,7 +115,7 @@ export const Sidebar: FC<Props> = ({
|
||||
onBlur={handleClose}
|
||||
data-testid="sidebar-root"
|
||||
>
|
||||
<SidebarContext.Provider value={state === State.Open}>
|
||||
<SidebarContext.Provider value={{ isOpen: state === State.Open }}>
|
||||
<div
|
||||
className={clsx(classes.drawer, {
|
||||
[classes.drawerPeek]: state === State.Peek,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useContext, useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { Link, Typography, makeStyles, Collapse } from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import {
|
||||
SIDEBAR_INTRO_LOCAL_STORAGE,
|
||||
SidebarContext,
|
||||
sidebarConfig,
|
||||
} from './config';
|
||||
import { SidebarDivider } from './Items';
|
||||
|
||||
const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
introCard: {
|
||||
color: '#b5b5b5',
|
||||
// XXX (@koroeskohr): should I be using a Mui theme variable?
|
||||
fontSize: 12,
|
||||
width: sidebarConfig.drawerWidthOpen,
|
||||
marginTop: 18,
|
||||
marginBottom: 12,
|
||||
paddingLeft: sidebarConfig.iconPadding,
|
||||
paddingRight: sidebarConfig.iconPadding,
|
||||
},
|
||||
introDismiss: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
marginTop: 12,
|
||||
},
|
||||
introDismissLink: {
|
||||
color: '#dddddd',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
'&:hover': {
|
||||
color: theme.palette.linkHover,
|
||||
transition: theme.transitions.create('color', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
},
|
||||
introDismissText: {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 'bold',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
introDismissIcon: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
marginRight: 12,
|
||||
},
|
||||
}));
|
||||
|
||||
type IntroCardProps = {
|
||||
text: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const IntroCard: FC<IntroCardProps> = props => {
|
||||
const classes = useStyles();
|
||||
const { text, onClose } = props;
|
||||
const handleClose = () => onClose();
|
||||
|
||||
return (
|
||||
<div className={classes.introCard}>
|
||||
<Typography variant="subtitle2">{text}</Typography>
|
||||
<div className={classes.introDismiss}>
|
||||
<Link
|
||||
component="button"
|
||||
onClick={handleClose}
|
||||
underline="none"
|
||||
className={classes.introDismissLink}
|
||||
>
|
||||
<CloseIcon className={classes.introDismissIcon} />
|
||||
<Typography component="span" className={classes.introDismissText}>
|
||||
Dismiss
|
||||
</Typography>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarIntroLocalStorage = {
|
||||
starredItemsDismissed: boolean;
|
||||
recentlyViewedItemsDismissed: boolean;
|
||||
};
|
||||
|
||||
type SidebarIntroCardProps = {
|
||||
text: string;
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
const SidebarIntroCard: FC<SidebarIntroCardProps> = props => {
|
||||
const {text, onDismiss} = props
|
||||
const [collapsing, setCollapsing] = useState(false)
|
||||
const startDismissing = () => {
|
||||
setCollapsing(true)
|
||||
}
|
||||
return (
|
||||
<Collapse in={!collapsing} onExited={onDismiss}>
|
||||
<IntroCard
|
||||
text={text}
|
||||
onClose={startDismissing}
|
||||
/>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
const starredIntroText = `Fun fact! As you explore all the awesome plugins in Backstage, you can actually pin them to this side nav.
|
||||
Keep an eye out for the little star icon (⭐) next to the plugin name and give it a click!`;
|
||||
const recentlyViewedIntroText =
|
||||
'And your recently viewed plugins will pop up here!';
|
||||
|
||||
export const SidebarIntro: FC = () => {
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
const [
|
||||
{ starredItemsDismissed, recentlyViewedItemsDismissed },
|
||||
setDismissedIntro,
|
||||
] = useLocalStorage<SidebarIntroLocalStorage>(SIDEBAR_INTRO_LOCAL_STORAGE, {
|
||||
starredItemsDismissed: false,
|
||||
recentlyViewedItemsDismissed: false,
|
||||
});
|
||||
|
||||
const dismissStarred = () => {
|
||||
setDismissedIntro(state => ({ ...state, starredItemsDismissed: true }));
|
||||
};
|
||||
const dismissRecentlyViewed = () => {
|
||||
setDismissedIntro(state => ({
|
||||
...state,
|
||||
recentlyViewedItemsDismissed: true,
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!starredItemsDismissed && (
|
||||
<>
|
||||
<SidebarIntroCard text={starredIntroText} onDismiss={dismissStarred} />
|
||||
<SidebarDivider />
|
||||
</>
|
||||
)}
|
||||
{!recentlyViewedItemsDismissed && (
|
||||
<SidebarIntroCard text={recentlyViewedIntroText} onDismiss={dismissRecentlyViewed} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -14,87 +14,196 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Link, makeStyles, styled, Theme, Typography } from '@material-ui/core';
|
||||
import {
|
||||
makeStyles,
|
||||
styled,
|
||||
TextField,
|
||||
Theme,
|
||||
Typography,
|
||||
Badge,
|
||||
} from '@material-ui/core';
|
||||
import SearchIcon from '@material-ui/icons/Search';
|
||||
import clsx from 'clsx';
|
||||
import React, { FC, useContext } from 'react';
|
||||
import React, { FC, useContext, useState, KeyboardEventHandler } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
import { IconComponent } from '../../icons';
|
||||
|
||||
const useStyles = makeStyles<Theme>((theme) => ({
|
||||
root: {
|
||||
color: '#b5b5b5',
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
height: 40,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
closed: {
|
||||
width: sidebarConfig.drawerWidthClosed,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
open: {
|
||||
width: sidebarConfig.drawerWidthOpen,
|
||||
},
|
||||
label: {
|
||||
fontWeight: 'bolder',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.0,
|
||||
marginLeft: theme.spacing(1),
|
||||
},
|
||||
iconContainer: {
|
||||
height: '100%',
|
||||
width: sidebarConfig.drawerWidthClosed,
|
||||
marginRight: -theme.spacing(2),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}));
|
||||
const useStyles = makeStyles<Theme>((theme) => {
|
||||
const {
|
||||
selectedIndicatorWidth,
|
||||
drawerWidthClosed,
|
||||
drawerWidthOpen,
|
||||
iconContainerWidth,
|
||||
iconSize,
|
||||
} = sidebarConfig;
|
||||
|
||||
return {
|
||||
root: {
|
||||
color: '#b5b5b5',
|
||||
display: 'flex',
|
||||
flexFlow: 'row nowrap',
|
||||
alignItems: 'center',
|
||||
height: 48,
|
||||
cursor: 'pointer',
|
||||
},
|
||||
closed: {
|
||||
width: drawerWidthClosed,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
open: {
|
||||
width: drawerWidthOpen,
|
||||
},
|
||||
label: {
|
||||
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.0,
|
||||
},
|
||||
iconContainer: {
|
||||
boxSizing: 'border-box',
|
||||
height: '100%',
|
||||
width: iconContainerWidth,
|
||||
marginRight: -theme.spacing(2),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
icon: {
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
},
|
||||
searchRoot: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
searchField: {
|
||||
color: '#b5b5b5',
|
||||
fontWeight: 'bold',
|
||||
fontSize: theme.typography.fontSize,
|
||||
},
|
||||
searchContainer: {
|
||||
width: drawerWidthOpen - iconContainerWidth,
|
||||
},
|
||||
selected: {
|
||||
'&$root': {
|
||||
borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`,
|
||||
color: '#ffffff',
|
||||
},
|
||||
'&$closed': {
|
||||
width: drawerWidthClosed - selectedIndicatorWidth,
|
||||
},
|
||||
'& $iconContainer': {
|
||||
marginLeft: -selectedIndicatorWidth,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type SidebarItemProps = {
|
||||
icon: IconComponent;
|
||||
text: string;
|
||||
text?: string;
|
||||
to?: string;
|
||||
disableSelected?: boolean;
|
||||
hasNotifications?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const SidebarItem: FC<SidebarItemProps> = ({
|
||||
icon: Icon,
|
||||
text,
|
||||
to,
|
||||
to = '#',
|
||||
disableSelected = false,
|
||||
hasNotifications = false,
|
||||
onClick,
|
||||
children,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
const isOpen = useContext(SidebarContext);
|
||||
// XXX (@koroeskohr): unsure this is optimal. But I just really didn't want to have the item component
|
||||
// depend on the current location, and at least have it being optionally forced to selected.
|
||||
// Still waiting on a Q answered to fine tune the implementation
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
|
||||
const itemIcon = (
|
||||
<Badge
|
||||
color="secondary"
|
||||
variant="dot"
|
||||
overlap="circle"
|
||||
invisible={!hasNotifications}
|
||||
>
|
||||
<Icon fontSize="small" className={classes.icon} />
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<Link
|
||||
<NavLink
|
||||
className={clsx(classes.root, classes.closed)}
|
||||
href={to}
|
||||
activeClassName={classes.selected}
|
||||
isActive={(match) => Boolean(match && !disableSelected)}
|
||||
exact
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
underline="none"
|
||||
>
|
||||
<Icon fontSize="small" />
|
||||
</Link>
|
||||
{itemIcon}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
<NavLink
|
||||
className={clsx(classes.root, classes.open)}
|
||||
href={to}
|
||||
activeClassName={classes.selected}
|
||||
isActive={(match) => Boolean(match && !disableSelected)}
|
||||
exact
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
underline="none"
|
||||
>
|
||||
<div data-testid="login-button" className={classes.iconContainer}>
|
||||
<Icon fontSize="small" />
|
||||
{itemIcon}
|
||||
</div>
|
||||
<Typography variant="subtitle1" className={classes.label}>
|
||||
{text}
|
||||
</Typography>
|
||||
</Link>
|
||||
{text && (
|
||||
<Typography variant="subtitle2" className={classes.label}>
|
||||
{text}
|
||||
</Typography>
|
||||
)}
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
};
|
||||
|
||||
type SidebarSearchFieldProps = {
|
||||
onSearch: (input: string) => void;
|
||||
};
|
||||
|
||||
export const SidebarSearchField: FC<SidebarSearchFieldProps> = (props) => {
|
||||
const [input, setInput] = useState('');
|
||||
const classes = useStyles();
|
||||
|
||||
const handleEnter: KeyboardEventHandler = (ev) => {
|
||||
if (ev.key === 'Enter') {
|
||||
props.onSearch(input);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInput(ev.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={classes.searchRoot}>
|
||||
<SidebarItem icon={SearchIcon} disableSelected>
|
||||
<TextField
|
||||
placeholder="Search"
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleEnter}
|
||||
className={classes.searchContainer}
|
||||
InputProps={{
|
||||
disableUnderline: true,
|
||||
className: classes.searchField,
|
||||
}}
|
||||
/>
|
||||
</SidebarItem>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -111,4 +220,5 @@ export const SidebarDivider = styled('hr')({
|
||||
width: '100%',
|
||||
background: '#383838',
|
||||
border: 'none',
|
||||
margin: 0,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarIntro,
|
||||
SidebarItem,
|
||||
SidebarDivider,
|
||||
SidebarSearchField,
|
||||
SidebarSpace,
|
||||
SidebarUserBadge,
|
||||
} from '.';
|
||||
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
|
||||
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
export default {
|
||||
title: 'Sidebar',
|
||||
component: Sidebar,
|
||||
decorators: [
|
||||
(storyFn: () => JSX.Element) => (
|
||||
<MemoryRouter initialEntries={['/']}>{storyFn()}</MemoryRouter>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const handleSearch = (input: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(input);
|
||||
};
|
||||
|
||||
export const SampleSidebar = () => (
|
||||
<Sidebar>
|
||||
{/* <SidebarLogo /> */}
|
||||
<SidebarSearchField onSearch={handleSearch} />
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Home" />
|
||||
<SidebarItem icon={HomeOutlinedIcon} to="#" text="Plugins" />
|
||||
<SidebarItem icon={AddCircleOutlineIcon} to="#" text="Create..." />
|
||||
<SidebarDivider />
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarUserBadge />
|
||||
</Sidebar>
|
||||
);
|
||||
@@ -0,0 +1,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 <SidebarItem text={text} onClick={handleToggle} icon={icon} />;
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useContext } from 'react';
|
||||
import { Avatar, makeStyles, Theme, Typography } from '@material-ui/core';
|
||||
import People from '@material-ui/icons/People';
|
||||
import { sidebarConfig, SidebarContext } from './config';
|
||||
import { SidebarItem } from './Items';
|
||||
|
||||
const useStyles = makeStyles<Theme>(() => {
|
||||
const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig;
|
||||
return {
|
||||
root: {
|
||||
width: drawerWidthOpen,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: '#b5b5b5',
|
||||
paddingLeft: 18,
|
||||
paddingTop: 14,
|
||||
paddingBottom: 14,
|
||||
},
|
||||
avatar: {
|
||||
width: userBadgeDiameter,
|
||||
height: userBadgeDiameter,
|
||||
marginRight: 8,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
type Props = {
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
hideName?: boolean;
|
||||
};
|
||||
|
||||
export const UserBadge: FC<Props> = ({ imageUrl, name, hideName = false }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
{!hideName && <Typography variant="subtitle2">{name}</Typography>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SidebarUserBadge: FC = () => {
|
||||
const { isOpen } = useContext(SidebarContext);
|
||||
const isUserLoggedIn = false;
|
||||
return isUserLoggedIn ? (
|
||||
<UserBadge
|
||||
imageUrl="https://via.placeholder.com/200/200"
|
||||
name="Victor Viale"
|
||||
hideName={!isOpen}
|
||||
/>
|
||||
) : (
|
||||
<SidebarItem icon={People} text="Log in" to="/login" disableSelected />
|
||||
);
|
||||
};
|
||||
@@ -16,11 +16,34 @@
|
||||
|
||||
import { createContext } from 'react';
|
||||
|
||||
const drawerWidthClosed = 72;
|
||||
const iconPadding = 24;
|
||||
const userBadgePadding = 18;
|
||||
|
||||
export const sidebarConfig = {
|
||||
drawerWidthClosed: 64,
|
||||
drawerWidthOpen: 220,
|
||||
defaultOpenDelayMs: 400,
|
||||
defaultCloseDelayMs: 200,
|
||||
drawerWidthClosed,
|
||||
drawerWidthOpen: 224,
|
||||
// As per NN/g's guidance on timing for exposing hidden content
|
||||
// See https://www.nngroup.com/articles/timing-exposing-content/
|
||||
defaultOpenDelayMs: 300,
|
||||
defaultCloseDelayMs: 0,
|
||||
defaultFadeDuration: 200,
|
||||
logoHeight: 32,
|
||||
iconContainerWidth: drawerWidthClosed,
|
||||
iconSize: drawerWidthClosed - iconPadding * 2,
|
||||
iconPadding,
|
||||
selectedIndicatorWidth: 3,
|
||||
userBadgePadding,
|
||||
userBadgeDiameter: drawerWidthClosed - userBadgePadding * 2,
|
||||
};
|
||||
|
||||
export const SidebarContext = createContext<boolean>(false);
|
||||
export const SIDEBAR_INTRO_LOCAL_STORAGE =
|
||||
'@backstage/core/sidebar-intro-dismissed';
|
||||
|
||||
export type SidebarContextType = {
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
export const SidebarContext = createContext<SidebarContextType>({
|
||||
isOpen: false,
|
||||
});
|
||||
|
||||
@@ -17,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';
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CssBaseline>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</CssBaseline>
|
||||
</ThemeProvider>
|
||||
<BrowserRouter>
|
||||
<SidebarPage>
|
||||
{sidebar}
|
||||
<AppComponent />
|
||||
</SidebarPage>
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": [".storybook/**/*"],
|
||||
"compilerOptions": {}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user