diff --git a/.eslintrc.js b/.eslintrc.js
index da18d19a95..3681612b35 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,6 +1,4 @@
module.exports = {
- extends: [
- require.resolve('@backstage/cli/config/eslint'),
- '@spotify/eslint-config-oss',
- ],
+ root: true,
+ extends: ['@spotify/eslint-config-oss'],
};
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index f544f9594f..35d354fefc 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -4,4 +4,4 @@
# The last matching pattern takes precedence.
# https://help.github.com/articles/about-codeowners/
-* @spotify/backstage-core
+* @spotify/backstage-core
diff --git a/README.md b/README.md
index 97311b1f4d..f5b863cfa6 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,8 @@ This blog post provides more examples of how Backstage is used inside Spotify:
https://labs.spotify.com/2020/03/17/what-the-heck-is-backstage-anyway/
+https://backstage.io/demos
+
## Project roadmap
We created Backstage about 4 years ago. While our internal version of Backstage has had the benefit of time to mature and evolve, the first iteration of our open source version is still nascent. We are envisioning three phases of the project and we have already begun work on various aspects of these phases:
diff --git a/docs/architecture-decisions/adr001-add-adr-log.md b/docs/architecture-decisions/adr001-add-adr-log.md
new file mode 100644
index 0000000000..23115d43b5
--- /dev/null
+++ b/docs/architecture-decisions/adr001-add-adr-log.md
@@ -0,0 +1,11 @@
+## Date: 26.04.2020
+
+## Title: Architecture Decision Record (ADR) log
+
+## Decision: A decision was made to store ADRs in a log in the project repository
+
+## Discussion: There is a need to store big decisions made in a log as a reference point for the team, help with onboarding new members and give context to others interested in the project.
+
+## Risks: People stop adding ADRs to the log and context gets lost
+
+
diff --git a/docs/design.md b/docs/design.md
index 7fdec77adf..bcbf0bbdc5 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -9,11 +9,11 @@ Backstage Open Source is a newly launched endeavor, and we’re excited to scale
### Collaborative
-The Backstage Design Team is small but mighty, and we truly cherish the amazing opportunity we have to work with the Backstage Open Source community! Have an idea? A component request? Feel free to communicate with us via [Discord](https://discord.gg/PefUsZ) (*#design* channel). Collaboration trumps individual speed, and we want to work with you to make Backstage work for all of our users.
+The Backstage Design Team is small but mighty, and we truly cherish the amazing opportunity we have to work with the Backstage Open Source community! Have an idea? A component request? Feel free to communicate with us via [Discord](https://discord.gg/EBHEGzX) (*#design* channel). Collaboration trumps individual speed, and we want to work with you to make Backstage work for all of our users.
### Transparent
-There are a lot of exciting things coming up and we want to keep you in the loop! Keep an eye on our Milestones in GitHub to see where we’re headed. We’ll also be posting updates in the *#design* channel on [Discord](https://discord.gg/PefUsZ). Not only that, we want to keep you informed on the decisions we’ve made and why we’ve made them.
+There are a lot of exciting things coming up and we want to keep you in the loop! Keep an eye on our Milestones in GitHub to see where we’re headed. We’ll also be posting updates in the *#design* channel on [Discord](https://discord.gg/EBHEGzX). Not only that, we want to keep you informed on the decisions we’ve made and why we’ve made them.
## 🛠 Our Practice
@@ -48,15 +48,15 @@ This is the universal user experience that is shared amongst all Backstage users
## ⭐️ How to Contribute
### Pick up an issue!
-In the beginning, most of our issues will be centered around creating universal components for our Backstage Design System and adding them to our Storybook so plugin developers can reference them. We’ll also be creating issues that are focused on building up our core Backstage user experience. We’ll be labeling our issues in GitHub with ‘design’ and/or ‘storybook’ - so feel free to browse and tackle the tasks that interest you. If you have any questions regarding an issue, you can ask them in the comments section of the issue or on [Discord](https://discord.gg/PefUsZ). We absolutely adore our external contributors and will send you virtual semlas for your contributions!
+In the beginning, most of our issues will be centered around creating universal components for our Backstage Design System and adding them to our Storybook so plugin developers can reference them. We’ll also be creating issues that are focused on building up our core Backstage user experience. We’ll be labeling our issues in GitHub with ‘design’ and/or ‘storybook’ - so feel free to browse and tackle the tasks that interest you. If you have any questions regarding an issue, you can ask them in the comments section of the issue or on [Discord](https://discord.gg/EBHEGzX). We absolutely adore our external contributors and will send you virtual semlas for your contributions!
### Request a component.
-Create an issue (label it design and assign it to katz95) or send us a message on [Discord](https://discord.gg/PefUsZ) (*#design* channel) with details of what the component is and its relevant use cases. Your request will be reviewed by our design team and you should hear back from us within 1-2 business days. We’ll get back to you and let you know whether your requested component will get picked up by our team as something to be added to our design system.
+Create an issue (label it design and assign it to katz95) or send us a message on [Discord](https://discord.gg/EBHEGzX) (*#design* channel) with details of what the component is and its relevant use cases. Your request will be reviewed by our design team and you should hear back from us within 1-2 business days. We’ll get back to you and let you know whether your requested component will get picked up by our team as something to be added to our design system.
## ✏️ Resources
**[Storybook](http://storybook.backstage.io/)** - where you can view our components. If you’d like to help build up our design system, you can also add components we’ve designed to the Storybook as well.
-**[Discord](https://discord.gg/PefUsZ)** - all design questions should be directed to the *#design* channel.
+**[Discord](https://discord.gg/EBHEGzX)** - all design questions should be directed to the *#design* channel.
**Documentation**
- Patterns (stay tuned)
@@ -64,7 +64,7 @@ Create an issue (label it design and assign it to katz95) or send us a message o
## 🔮 Future
### Contributions from designers
-Are you a designer at an organisation that’s implementing Backstage? A designer who’s fascinated by the developer productivity problem space? A designer who’s curious about open source design? We’d love for you to contribute. Behind the scenes, we’re setting up a few foundational elements to make sure that contributing to Backstage as a designer is easy. From styling guidelines to UX principles to Figma documents, we’ll make sure you’re equipped to chip in on this project. We’re excited to work with you! In the meantime, we’d love to hear from you on [Discord](https://discord.gg/PefUsZ).
+Are you a designer at an organisation that’s implementing Backstage? A designer who’s fascinated by the developer productivity problem space? A designer who’s curious about open source design? We’d love for you to contribute. Behind the scenes, we’re setting up a few foundational elements to make sure that contributing to Backstage as a designer is easy. From styling guidelines to UX principles to Figma documents, we’ll make sure you’re equipped to chip in on this project. We’re excited to work with you! In the meantime, we’d love to hear from you on [Discord](https://discord.gg/EBHEGzX).
[Back to Docs](../README.md)
diff --git a/package.json b/package.json
index 03c9b411fe..4f7eb2c5f5 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,7 @@
"husky": "^4.2.3",
"lerna": "^3.20.2",
"lint-staged": "^10.1.0",
- "prettier": "^1.19.1"
+ "prettier": "^2.0.5"
},
"husky": {
"hooks": {
diff --git a/packages/app/package.json b/packages/app/package.json
index 7ccb6c9a91..39aea525c7 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -20,9 +20,11 @@
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.4",
"@backstage/core": "^0.1.1-alpha.4",
+ "@backstage/plugin-explore": "^0.1.1-alpha.4",
"@backstage/plugin-home-page": "^0.1.1-alpha.4",
"@backstage/plugin-inventory": "^0.1.1-alpha.4",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.4",
+ "@backstage/plugin-scaffolder": "^0.1.1-alpha.4",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.4",
"@backstage/plugin-welcome": "^0.1.1-alpha.4",
"@backstage/theme": "^0.1.1-alpha.4",
diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx
index 61adbe9ddc..6854ce015b 100644
--- a/packages/app/src/components/Root/Root.tsx
+++ b/packages/app/src/components/Root/Root.tsx
@@ -18,7 +18,9 @@ import React, { FC, useContext } from 'react';
import PropTypes from 'prop-types';
import { Link, makeStyles, Typography } from '@material-ui/core';
import HomeIcon from '@material-ui/icons/Home';
+import 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,
@@ -80,6 +82,9 @@ const Root: FC<{}> = ({ children }) => (
+
+
+
diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts
index a7e425b869..3669f83dba 100644
--- a/packages/app/src/plugins.ts
+++ b/packages/app/src/plugins.ts
@@ -13,9 +13,10 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
export { plugin as HomePagePlugin } from '@backstage/plugin-home-page';
export { plugin as WelcomePlugin } from '@backstage/plugin-welcome';
export { plugin as LighthousePlugin } from '@backstage/plugin-lighthouse';
export { plugin as InventoryPlugin } from '@backstage/plugin-inventory';
+export { plugin as ScaffolderPlugin } from '@backstage/plugin-scaffolder';
export { plugin as TechRadar } from '@backstage/plugin-tech-radar';
+export { plugin as Explore } from '@backstage/plugin-explore';
diff --git a/packages/backend-common/.eslintrc.js b/packages/backend-common/.eslintrc.js
new file mode 100644
index 0000000000..16a033dbc6
--- /dev/null
+++ b/packages/backend-common/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint.backend')],
+};
diff --git a/packages/backend-common/README.md b/packages/backend-common/README.md
new file mode 100644
index 0000000000..82db0454d2
--- /dev/null
+++ b/packages/backend-common/README.md
@@ -0,0 +1,38 @@
+# @backstage/backend-common
+
+Common functionality library for Backstage backends, implementing logging,
+error handling and similar.
+
+## Usage
+
+Add the library to your backend package:
+
+```sh
+yarn add @backstage/backend-common
+```
+
+then make use of the handlers and logger as necessary:
+
+```typescript
+import {
+ errorHandler,
+ getRootLogger,
+ notFoundHandler,
+ requestLoggingHandler,
+} from '@backstage/backend-common';
+
+const app = express();
+app.use(requestLoggingHandler());
+app.use('/home', myHomeRouter);
+app.use(notFoundHandler());
+app.use(errorHandler());
+
+app.listen(PORT, () => {
+ getRootLogger().info(`Listening on port ${PORT}`);
+});
+```
+
+## Documentation
+
+- [Backstage Readme](https://github.com/spotify/backstage/blob/master/README.md)
+- [Backstage Documentation](https://github.com/spotify/backstage/blob/master/docs/README.md)
diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json
new file mode 100644
index 0000000000..eec21063ce
--- /dev/null
+++ b/packages/backend-common/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@backstage/backend-common",
+ "description": "Common functionality library for Backstage backends",
+ "version": "0.1.1-alpha.4",
+ "main": "dist",
+ "private": false,
+ "publishConfig": {
+ "access": "public"
+ },
+ "homepage": "https://backstage.io",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/spotify/backstage",
+ "directory": "packages/backend-common"
+ },
+ "keywords": [
+ "backstage"
+ ],
+ "license": "Apache-2.0",
+ "scripts": {
+ "build": "backstage-cli build-cache -- tsc",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "clean": "backstage-cli clean"
+ },
+ "dependencies": {
+ "express": "^4.17.1",
+ "morgan": "^1.10.0",
+ "winston": "^3.2.1"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.4",
+ "@types/express": "^4.17.6",
+ "@types/http-errors": "^1.6.3",
+ "@types/morgan": "^1.9.0",
+ "@types/supertest": "^2.0.8",
+ "get-port": "^5.1.1",
+ "http-errors": "^1.7.3",
+ "jest": "^25.1.0",
+ "jest-fetch-mock": "^3.0.3",
+ "supertest": "^4.0.2",
+ "typescript": "^3.8.3"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/packages/backend-common/src/errors.test.ts b/packages/backend-common/src/errors.test.ts
new file mode 100644
index 0000000000..ecff8764c0
--- /dev/null
+++ b/packages/backend-common/src/errors.test.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 * as errors from './errors';
+
+describe('errors', () => {
+ it('extends Error properly', () => {
+ for (const [name, E] of Object.entries(errors)) {
+ const error = new E('abcdef');
+ expect(error.name).toBe(name);
+ expect(error.message).toBe('abcdef');
+ expect(error.stack).toContain(__filename);
+ expect(error.toString()).toContain(name);
+ expect(error.toString()).toContain('abcdef');
+ }
+ });
+
+ it('supports causes', () => {
+ const cause = new Error('hello');
+ for (const [, E] of Object.entries(errors)) {
+ const error = new E('abcdef', cause);
+ expect(error.cause).toBe(cause);
+ expect(error.toString()).toContain(
+ `${name}: abcdef; caused by Error: hello`,
+ );
+ }
+ });
+});
diff --git a/packages/backend-common/src/errors.ts b/packages/backend-common/src/errors.ts
new file mode 100644
index 0000000000..b68dc8e3f0
--- /dev/null
+++ b/packages/backend-common/src/errors.ts
@@ -0,0 +1,77 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/*
+ * A set of common business logic errors.
+ *
+ * The error handler middleware understands these and will translate them to
+ * well formed HTTP responses.
+ *
+ * While these are intentionally analogous to HTTP errors, they are not
+ * intended to be thrown by the request handling layer. In those places, please
+ * use e.g. the http-errors library.
+ */
+
+class CustomErrorBase extends Error {
+ readonly cause?: Error;
+
+ constructor(message?: string, cause?: Error) {
+ let fullMessage = message;
+ if (cause) {
+ if (fullMessage) {
+ fullMessage += `; caused by ${cause}`;
+ } else {
+ fullMessage = `caused by ${cause}`;
+ }
+ }
+
+ super(fullMessage);
+
+ Error.captureStackTrace(this, this.constructor);
+
+ this.name = this.constructor.name;
+ this.cause = cause;
+ }
+}
+
+/**
+ * The given inputs are malformed and cannot be processed.
+ */
+export class InputError extends CustomErrorBase {}
+
+/**
+ * The request requires authentication, which was not properly supplied.
+ */
+export class AuthenticationError extends CustomErrorBase {}
+
+/**
+ * The authenticated caller is not allowed to perform this request.
+ */
+export class NotAllowedError extends CustomErrorBase {}
+
+/**
+ * The requested resource could not be found.
+ *
+ * Note that this error usually is used to indicate that an entity with a given
+ * ID does not exist, rather than signalling that an entire route is missing.
+ */
+export class NotFoundError extends CustomErrorBase {}
+
+/**
+ * The request could not complete due to a conflict in the current state of the
+ * resource.
+ */
+export class ConflictError extends CustomErrorBase {}
diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts
new file mode 100644
index 0000000000..b2c38ab506
--- /dev/null
+++ b/packages/backend-common/src/index.ts
@@ -0,0 +1,19 @@
+/*
+ * 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 './errors';
+export * from './logging';
+export * from './middleware';
diff --git a/packages/core/src/components/SortableTable/index.tsx b/packages/backend-common/src/logging/index.ts
similarity index 93%
rename from packages/core/src/components/SortableTable/index.tsx
rename to packages/backend-common/src/logging/index.ts
index 718f582b8f..06ce76ac54 100644
--- a/packages/core/src/components/SortableTable/index.tsx
+++ b/packages/backend-common/src/logging/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { default } from './SortableTable';
+export * from './rootLogger';
diff --git a/plugins/inventory-backend/src/plugin.ts b/packages/backend-common/src/logging/rootLogger.test.ts
similarity index 58%
rename from plugins/inventory-backend/src/plugin.ts
rename to packages/backend-common/src/logging/rootLogger.test.ts
index 88299a5c0f..48f266aec8 100644
--- a/plugins/inventory-backend/src/plugin.ts
+++ b/packages/backend-common/src/logging/rootLogger.test.ts
@@ -14,17 +14,19 @@
* limitations under the License.
*/
-import express from 'express';
+import winston from 'winston';
+import { getRootLogger, setRootLogger } from './rootLogger';
-export const router = express.Router();
+describe('rootLogger', () => {
+ it('can replace the default logger', () => {
+ const logger = winston.createLogger();
+ jest.spyOn(logger, 'info').mockReturnValue(logger);
-router.get('/', async (_, res) => {
- res
- .status(200)
- .send([
- { id: 'component1' },
- { id: 'component2' },
- { id: 'component3' },
- { id: 'component4' },
- ]);
+ setRootLogger(logger);
+ getRootLogger().info('testing');
+
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('testing'),
+ );
+ });
});
diff --git a/packages/backend-common/src/logging/rootLogger.ts b/packages/backend-common/src/logging/rootLogger.ts
new file mode 100644
index 0000000000..8058e22947
--- /dev/null
+++ b/packages/backend-common/src/logging/rootLogger.ts
@@ -0,0 +1,44 @@
+/*
+ * 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 winston, { Logger } from 'winston';
+
+let rootLogger: Logger = winston.createLogger({
+ level: process.env.LOG_LEVEL || 'info',
+ format:
+ process.env.NODE_ENV === 'production'
+ ? winston.format.json()
+ : winston.format.combine(
+ winston.format.colorize(),
+ winston.format.timestamp(),
+ winston.format.simple(),
+ ),
+ defaultMeta: { service: 'backstage' },
+ transports: [
+ new winston.transports.Console({
+ silent:
+ process.env.JEST_WORKER_ID !== undefined && !process.env.LOG_LEVEL,
+ }),
+ ],
+});
+
+export function getRootLogger(): Logger {
+ return rootLogger;
+}
+
+export function setRootLogger(newLogger: Logger) {
+ rootLogger = newLogger;
+}
diff --git a/packages/backend-common/src/middleware/errorHandler.test.ts b/packages/backend-common/src/middleware/errorHandler.test.ts
new file mode 100644
index 0000000000..022802dff8
--- /dev/null
+++ b/packages/backend-common/src/middleware/errorHandler.test.ts
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import createError from 'http-errors';
+import request from 'supertest';
+import * as errors from '../errors';
+import { errorHandler } from './errorHandler';
+
+describe('errorHandler', () => {
+ it('gives default code and message', async () => {
+ const app = express();
+ app.use('/breaks', () => {
+ throw new Error('some message');
+ });
+ app.use(errorHandler());
+
+ const response = await request(app).get('/breaks');
+
+ expect(response.status).toBe(500);
+ expect(response.text).toBe('some message');
+ });
+
+ it('takes code from http-errors library errors', async () => {
+ const app = express();
+ app.use('/breaks', () => {
+ throw createError(432, 'Some Message');
+ });
+ app.use(errorHandler());
+
+ const response = await request(app).get('/breaks');
+
+ expect(response.status).toBe(432);
+ expect(response.text).toContain('Some Message');
+ });
+
+ it('handles well-known error classes', async () => {
+ const app = express();
+ app.use('/InputError', () => {
+ throw new errors.InputError();
+ });
+ app.use('/AuthenticationError', () => {
+ throw new errors.AuthenticationError();
+ });
+ app.use('/NotAllowedError', () => {
+ throw new errors.NotAllowedError();
+ });
+ app.use('/NotFoundError', () => {
+ throw new errors.NotFoundError();
+ });
+ app.use('/ConflictError', () => {
+ throw new errors.ConflictError();
+ });
+ app.use(errorHandler());
+
+ const r = request(app);
+ expect((await r.get('/InputError')).status).toBe(400);
+ expect((await r.get('/AuthenticationError')).status).toBe(401);
+ expect((await r.get('/NotAllowedError')).status).toBe(403);
+ expect((await r.get('/NotFoundError')).status).toBe(404);
+ expect((await r.get('/ConflictError')).status).toBe(409);
+ });
+});
diff --git a/packages/backend-common/src/middleware/errorHandler.ts b/packages/backend-common/src/middleware/errorHandler.ts
new file mode 100644
index 0000000000..14f6cfa8d7
--- /dev/null
+++ b/packages/backend-common/src/middleware/errorHandler.ts
@@ -0,0 +1,98 @@
+/*
+ * 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 { ErrorRequestHandler, NextFunction, Request, Response } from 'express';
+import * as errors from '../errors';
+
+export type ErrorHandlerOptions = {
+ /**
+ * Whether error response bodies should show error stack traces or not.
+ *
+ * If not specified, by default shows stack traces only in development mode.
+ */
+ showStackTraces?: boolean;
+};
+
+/**
+ * Express middleware to handle errors during request processing.
+ *
+ * This is commonly the very last middleware in the chain.
+ *
+ * Its primary purpose is not to do translation of business logic exceptions,
+ * but rather to be a gobal catch-all for uncaught "fatal" errors that are
+ * expected to result in a 500 error. However, it also does handle some common
+ * error types (such as http-error exceptions) and returns the enclosed status
+ * code accordingly.
+ *
+ * @returns An Express error request handler
+ */
+export function errorHandler(
+ options: ErrorHandlerOptions = {},
+): ErrorRequestHandler {
+ const showStackTraces =
+ options.showStackTraces ?? process.env.NODE_ENV === 'development';
+
+ /* eslint-disable @typescript-eslint/no-unused-vars */
+ return (
+ error: Error,
+ _request: Request,
+ response: Response,
+ next: NextFunction,
+ ) => {
+ if (response.headersSent) {
+ next(error);
+ }
+
+ const status = getStatusCode(error);
+ const message = showStackTraces ? error.stack : error.message;
+ response.status(status).send(message);
+ };
+}
+
+function getStatusCode(error: Error): number {
+ // Look for common http library status codes
+ const knownStatusCodeFields = ['statusCode', 'status'];
+ for (const field of knownStatusCodeFields) {
+ const statusCode = (error as any)[field];
+ if (
+ typeof statusCode === 'number' &&
+ (statusCode | 0) === statusCode && // is whole integer
+ statusCode >= 100 &&
+ statusCode <= 599
+ ) {
+ return statusCode;
+ }
+ }
+
+ // Handle well-known error types
+ switch (error.name) {
+ case errors.InputError.name:
+ return 400;
+ case errors.AuthenticationError.name:
+ return 401;
+ case errors.NotAllowedError.name:
+ return 403;
+ case errors.NotFoundError.name:
+ return 404;
+ case errors.ConflictError.name:
+ return 409;
+ default:
+ break;
+ }
+
+ // Fall back to internal server error
+ return 500;
+}
diff --git a/packages/backend-common/src/middleware/index.ts b/packages/backend-common/src/middleware/index.ts
new file mode 100644
index 0000000000..083b36c3e9
--- /dev/null
+++ b/packages/backend-common/src/middleware/index.ts
@@ -0,0 +1,19 @@
+/*
+ * 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 './errorHandler';
+export * from './notFoundHandler';
+export * from './requestLoggingHandler';
diff --git a/packages/backend-common/src/middleware/notFoundHandler.test.ts b/packages/backend-common/src/middleware/notFoundHandler.test.ts
new file mode 100644
index 0000000000..65858e8cc1
--- /dev/null
+++ b/packages/backend-common/src/middleware/notFoundHandler.test.ts
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import request from 'supertest';
+import { notFoundHandler } from './notFoundHandler';
+
+describe('notFoundHandler', () => {
+ it('handles only missing routes', async () => {
+ const app = express();
+ app.use('/exists', (_, res) => res.status(200).send());
+ app.use(notFoundHandler());
+
+ const existsResponse = await request(app).get('/exists');
+ const doesNotExistResponse = await request(app).get('/doesNotExist');
+
+ expect(existsResponse.status).toBe(200);
+ expect(doesNotExistResponse.status).toBe(404);
+ });
+});
diff --git a/packages/backend-common/src/middleware/notFoundHandler.ts b/packages/backend-common/src/middleware/notFoundHandler.ts
new file mode 100644
index 0000000000..19dd130c64
--- /dev/null
+++ b/packages/backend-common/src/middleware/notFoundHandler.ts
@@ -0,0 +1,32 @@
+/*
+ * 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 { NextFunction, Request, RequestHandler, Response } from 'express';
+
+/**
+ * Express middleware to handle requests for missing routes.
+ *
+ * Should be used as the very last handler in the chain, as it unconditionally
+ * returns a 404 status.
+ *
+ * @returns An Express request handler
+ */
+export function notFoundHandler(): RequestHandler {
+ /* eslint-disable @typescript-eslint/no-unused-vars */
+ return (_request: Request, response: Response, _next: NextFunction) => {
+ response.status(404).send();
+ };
+}
diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.test.ts b/packages/backend-common/src/middleware/requestLoggingHandler.test.ts
new file mode 100644
index 0000000000..11f24c276a
--- /dev/null
+++ b/packages/backend-common/src/middleware/requestLoggingHandler.test.ts
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import express from 'express';
+import request from 'supertest';
+import winston from 'winston';
+import { requestLoggingHandler } from './requestLoggingHandler';
+
+describe('requestLoggingHandler', () => {
+ it('emits logs for each request', async () => {
+ const logger = winston.createLogger();
+ jest.spyOn(logger, 'info').mockReturnValue(logger);
+
+ const app = express();
+ app.use(requestLoggingHandler(logger));
+ app.use('/exists1', (_, res) => res.status(200).send());
+ app.use('/exists2', (_, res) => res.status(201).send());
+
+ const r = request(app);
+ await r.get('/exists1');
+ await r.get('/exists2');
+
+ expect(logger.info).toHaveBeenCalledTimes(2);
+ expect(logger.info).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('200'),
+ );
+ expect(logger.info).toHaveBeenNthCalledWith(
+ 2,
+ expect.stringContaining('201'),
+ );
+ });
+});
diff --git a/packages/backend-common/src/middleware/requestLoggingHandler.ts b/packages/backend-common/src/middleware/requestLoggingHandler.ts
new file mode 100644
index 0000000000..6604ec245c
--- /dev/null
+++ b/packages/backend-common/src/middleware/requestLoggingHandler.ts
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { RequestHandler } from 'express';
+import { Logger } from 'winston';
+import morgan from 'morgan';
+import { getRootLogger } from '../logging';
+
+/**
+ * Logs incoming requests.
+ *
+ * @param logger An optional logger to use. If not specified, the root logger will be used.
+ * @returns An Express request handler
+ */
+export function requestLoggingHandler(logger?: Logger): RequestHandler {
+ const actualLogger = (logger || getRootLogger()).child({
+ type: 'incomingRequest',
+ });
+
+ return morgan('combined', {
+ stream: {
+ write(message: String) {
+ actualLogger.info(message);
+ },
+ },
+ });
+}
diff --git a/packages/backend-common/src/setupTests.ts b/packages/backend-common/src/setupTests.ts
new file mode 100644
index 0000000000..3fa7cb04b4
--- /dev/null
+++ b/packages/backend-common/src/setupTests.ts
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+require('jest-fetch-mock').enableMocks();
diff --git a/packages/backend-common/tsconfig.json b/packages/backend-common/tsconfig.json
new file mode 100644
index 0000000000..6d7ca21afa
--- /dev/null
+++ b/packages/backend-common/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "include": ["src"],
+ "compilerOptions": {
+ "baseUrl": "src",
+ "outDir": "dist",
+ "incremental": true,
+ "sourceMap": true,
+ "declaration": true,
+ "strict": true,
+ "target": "ES2019",
+ "module": "commonjs",
+ "esModuleInterop": true,
+ "types": ["node", "jest"]
+ }
+}
diff --git a/packages/backend/.eslintrc.js b/packages/backend/.eslintrc.js
index f400a039e7..16a033dbc6 100644
--- a/packages/backend/.eslintrc.js
+++ b/packages/backend/.eslintrc.js
@@ -1,6 +1,3 @@
module.exports = {
- rules: {
- 'no-console': 0, // Permitted in console programs
- 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
- },
+ extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
diff --git a/packages/backend/package.json b/packages/backend/package.json
index 0530f04452..32be276ef2 100644
--- a/packages/backend/package.json
+++ b/packages/backend/package.json
@@ -15,7 +15,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
+ "@backstage/backend-common": "0.1.1-alpha.4",
"@backstage/plugin-inventory-backend": "0.1.1-alpha.4",
+ "@backstage/plugin-scaffolder-backend": "0.1.1-alpha.4",
"compression": "^1.7.4",
"cors": "^2.8.5",
"express": "^4.17.1",
@@ -28,6 +30,7 @@
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.45",
+ "jest": "^25.1.0",
"tsc-watch": "^4.2.3",
"typescript": "^3.8.3"
},
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index fd8015c328..a22befd794 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -22,25 +22,60 @@
* Happy hacking!
*/
-import express from 'express';
-import cors from 'cors';
-import helmet from 'helmet';
+import {
+ errorHandler,
+ getRootLogger,
+ notFoundHandler,
+ requestLoggingHandler,
+} from '@backstage/backend-common';
+import {
+ AggregatorInventory,
+ createRouter as inventoryRouter,
+ StaticInventory,
+} from '@backstage/plugin-inventory-backend';
+import {
+ createRouter as scaffolderRouter,
+ DiskStorage,
+ CookieCutter,
+} from '@backstage/plugin-scaffolder-backend';
import compression from 'compression';
-import { testRouter } from './test';
-import { router as inventoryRouter } from '@backstage/plugin-inventory-backend';
+import cors from 'cors';
+import express from 'express';
+import helmet from 'helmet';
const DEFAULT_PORT = 7000;
-
const PORT = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
-const app = express();
+const logger = getRootLogger().child({ type: 'plugin' });
-app.use(helmet());
-app.use(cors());
-app.use(compression());
-app.use(express.json());
-app.use('/test', testRouter);
-app.use('/inventory', inventoryRouter);
+async function main() {
+ const inventory = new AggregatorInventory();
+ inventory.enlist(
+ new StaticInventory([
+ { id: 'component1' },
+ { id: 'component2' },
+ { id: 'component3' },
+ { id: 'component4' },
+ ]),
+ );
-app.listen(PORT, () => {
- console.log(`Listening on port ${PORT}`);
-});
+ const storage = new DiskStorage({ logger });
+ const templater = new CookieCutter();
+
+ const app = express();
+
+ app.use(helmet());
+ app.use(cors());
+ app.use(compression());
+ app.use(express.json());
+ app.use(requestLoggingHandler());
+ app.use('/inventory', await inventoryRouter({ inventory, logger }));
+ app.use('/scaffolder', await scaffolderRouter({ storage, templater, logger }));
+ app.use(notFoundHandler());
+ app.use(errorHandler());
+
+ app.listen(PORT, () => {
+ getRootLogger().info(`Listening on port ${PORT}`);
+ });
+}
+
+main();
diff --git a/packages/cli/config/eslint.backend.js b/packages/cli/config/eslint.backend.js
new file mode 100644
index 0000000000..619d3c72e0
--- /dev/null
+++ b/packages/cli/config/eslint.backend.js
@@ -0,0 +1,73 @@
+/*
+ * 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.
+ */
+
+module.exports = {
+ extends: [
+ '@spotify/eslint-config-base',
+ '@spotify/eslint-config-typescript',
+ 'prettier',
+ 'prettier/@typescript-eslint',
+ 'plugin:jest/recommended',
+ 'plugin:monorepo/recommended',
+ ],
+ parser: '@typescript-eslint/parser',
+ plugins: ['import'],
+ env: {
+ jest: true,
+ },
+ parserOptions: {
+ ecmaVersion: 2018,
+ sourceType: 'module',
+ },
+ ignorePatterns: ['**/dist/**', '**/build/**'],
+ rules: {
+ 'no-console': 0, // Permitted in console programs
+ 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
+ 'import/no-duplicates': 'warn',
+ 'import/no-extraneous-dependencies': [
+ 'error',
+ {
+ devDependencies: false,
+ optionalDependencies: true,
+ peerDependencies: true,
+ bundledDependencies: true,
+ },
+ ],
+ 'no-unused-expressions': 'off',
+ '@typescript-eslint/no-unused-expressions': 'error',
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ { vars: 'all', args: 'after-used', ignoreRestSiblings: true },
+ ],
+ },
+ overrides: [
+ {
+ files: ['*.test.*', 'src/setupTests.*', 'dev/**'],
+ rules: {
+ // Tests are allowed to import dev dependencies
+ 'import/no-extraneous-dependencies': [
+ 'error',
+ {
+ devDependencies: true,
+ optionalDependencies: true,
+ peerDependencies: true,
+ bundledDependencies: true,
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/packages/cli/config/eslint.js b/packages/cli/config/eslint.js
index 230a95c324..6de2cf75d5 100644
--- a/packages/cli/config/eslint.js
+++ b/packages/cli/config/eslint.js
@@ -51,6 +51,8 @@ module.exports = {
bundledDependencies: true,
},
],
+ 'no-unused-expressions': 'off',
+ '@typescript-eslint/no-unused-expressions': 'error',
'@typescript-eslint/no-unused-vars': [
'warn',
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
diff --git a/packages/cli/config/jest.js b/packages/cli/config/jest.js
index fc067edd30..77f345a0dd 100644
--- a/packages/cli/config/jest.js
+++ b/packages/cli/config/jest.js
@@ -29,6 +29,13 @@ if (fs.existsSync('jest.config.js')) {
moduleNameMapper: {
'\\.(css|less|scss|sss|styl)$': require.resolve('jest-css-modules'),
},
+ // 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'),
+ },
+ // Default behaviour is to not apply transforms for node_modules, but we still want to tranform .esm.js files
+ transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'],
};
// Use src/setupTests.ts as the default location for configuring test env
diff --git a/packages/cli/config/tsconfig.json b/packages/cli/config/tsconfig.json
index a70ede280b..a05e809caa 100644
--- a/packages/cli/config/tsconfig.json
+++ b/packages/cli/config/tsconfig.json
@@ -6,9 +6,11 @@
"noEmit": false,
"declarationMap": true,
"incremental": true,
- "module": "es6",
+ "target": "ES2019",
+ "module": "ESNext",
"resolveJsonModule": true,
"esModuleInterop": true,
+ "lib": ["DOM", "DOM.Iterable", "ScriptHost", "ES2019"],
"types": ["node", "jest"]
}
}
diff --git a/packages/cli/e2e-test/helpers.js b/packages/cli/e2e-test/helpers.js
index 7e34f10a63..ed3b7914cc 100644
--- a/packages/cli/e2e-test/helpers.js
+++ b/packages/cli/e2e-test/helpers.js
@@ -95,9 +95,9 @@ async function waitForPageWithText(
browser,
path,
text,
- { intervalMs = 1000, maxAttempts = 240 } = {},
+ { intervalMs = 1000, maxLoadAttempts = 240, maxFindTextAttempts = 3 } = {},
) {
- let attempts = 0;
+ let loadAttempts = 0;
for (;;) {
try {
await new Promise(resolve => setTimeout(resolve, intervalMs));
@@ -105,8 +105,8 @@ async function waitForPageWithText(
break;
} catch (error) {
if (error.message.match(EXPECTED_LOAD_ERRORS)) {
- attempts++;
- if (attempts > maxAttempts) {
+ loadAttempts++;
+ if (loadAttempts >= maxLoadAttempts) {
throw new Error(
`Failed to load page '${path}', max number of attempts reached`,
);
@@ -117,12 +117,28 @@ async function waitForPageWithText(
}
}
+ // The page may not be fully loaded and hence we need to retry.
+ let findTextAttempts = 0;
const escapedText = text.replace(/"/g, '\\"');
- browser.assert.evaluate(
- `Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`,
- true,
- `expected to find text ${text}`,
- );
+ for (;;) {
+ try {
+ browser.assert.evaluate(
+ `Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`,
+ true,
+ `expected to find text ${text}`,
+ );
+ break;
+ } catch (error) {
+ findTextAttempts++;
+ if (findTextAttempts <= maxFindTextAttempts) {
+ await browser.visit(path);
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
+ continue
+ } else {
+ throw error;
+ }
+ }
+ }
}
function print(msg) {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 45d323f748..418e5f9fe2 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -29,6 +29,7 @@
"backstage-cli": "bin/backstage-cli"
},
"devDependencies": {
+ "@types/diff": "^4.0.2",
"@types/fs-extra": "^8.1.0",
"@types/html-webpack-plugin": "^3.2.2",
"@types/inquirer": "^6.5.0",
@@ -58,6 +59,7 @@
"chokidar": "^3.3.1",
"commander": "^4.1.1",
"dashify": "^2.0.0",
+ "diff": "^4.0.2",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-monorepo": "^0.2.1",
"fork-ts-checker-webpack-plugin": "^4.0.5",
@@ -67,6 +69,7 @@
"inquirer": "^7.0.4",
"jest": "^25.1.0",
"jest-css-modules": "^2.1.0",
+ "jest-esm-transformer": "^1.0.0",
"ora": "^4.0.3",
"react": "^16.0.0",
"react-dev-utils": "^10.2.0",
diff --git a/packages/cli/src/commands/plugin/diff/handlers.ts b/packages/cli/src/commands/plugin/diff/handlers.ts
new file mode 100644
index 0000000000..df7ca081de
--- /dev/null
+++ b/packages/cli/src/commands/plugin/diff/handlers.ts
@@ -0,0 +1,209 @@
+/*
+ * 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 chalk from 'chalk';
+import { dirname } from 'path';
+import { diffLines } from 'diff';
+import { paths } from 'lib/paths';
+import { TemplateFile, PromptFunc, FileHandler } from './types';
+
+export async function writeTargetFile(targetPath: string, contents: string) {
+ const path = paths.resolveTarget(targetPath);
+ await fs.ensureDir(dirname(path));
+ await fs.writeFile(path, contents, 'utf8');
+}
+
+class PackageJsonHandler {
+ static async handler(file: TemplateFile, prompt: PromptFunc) {
+ console.log('Checking package.json');
+
+ if (!file.targetExists) {
+ throw new Error(`${file.targetPath} doesn't exist`);
+ }
+
+ const pkg = JSON.parse(file.templateContents);
+ const targetPkg = JSON.parse(file.targetContents);
+
+ const handler = new PackageJsonHandler(file, prompt, pkg, targetPkg);
+ await handler.handle();
+ }
+
+ constructor(
+ private readonly file: TemplateFile,
+ private readonly prompt: PromptFunc,
+ private readonly pkg: any,
+ private readonly targetPkg: any,
+ ) {}
+
+ async handle() {
+ await this.syncField('main');
+ await this.syncField('types');
+ await this.syncField('files');
+ await this.syncScripts();
+ await this.syncDependencies('dependencies');
+ await this.syncDependencies('devDependencies');
+ }
+
+ // Make sure a field inside package.json is in sync. This mutates the targetObj and writes package.json on change.
+ private async syncField(
+ fieldName: string,
+ obj: any = this.pkg,
+ targetObj: any = this.targetPkg,
+ prefix?: string,
+ ) {
+ const fullFieldName = chalk.cyan(
+ prefix ? `${prefix}[${fieldName}]` : fieldName,
+ );
+ const newValue = obj[fieldName];
+ const coloredNewValue = chalk.cyan(JSON.stringify(newValue));
+
+ if (fieldName in targetObj) {
+ const oldValue = targetObj[fieldName];
+ if (JSON.stringify(oldValue) === JSON.stringify(newValue)) {
+ return;
+ }
+
+ const coloredOldValue = chalk.cyan(JSON.stringify(oldValue));
+ const msg = `package.json has mismatched field, ${fullFieldName}, change from ${coloredOldValue} to ${coloredNewValue}?`;
+ if (await this.prompt(msg)) {
+ targetObj[fieldName] = newValue;
+ await this.write();
+ }
+ } else {
+ if (
+ await this.prompt(
+ `package.json is missing field ${fullFieldName}, set to ${coloredNewValue}?`,
+ )
+ ) {
+ targetObj[fieldName] = newValue;
+ await this.write();
+ }
+ }
+ }
+
+ private async syncScripts() {
+ const pkgScripts = this.pkg.scripts;
+ const targetScripts = (this.targetPkg.scripts =
+ this.targetPkg.scripts || {});
+
+ for (const key of Object.keys(pkgScripts)) {
+ await this.syncField(key, pkgScripts, targetScripts, 'scripts');
+ }
+ }
+
+ private async syncDependencies(fieldName: string) {
+ const pkgDeps = this.pkg[fieldName];
+ const targetDeps = (this.targetPkg[fieldName] =
+ this.targetPkg[fieldName] || {});
+
+ for (const key of Object.keys(pkgDeps)) {
+ await this.syncField(key, pkgDeps, targetDeps, fieldName);
+ }
+ }
+
+ private async write() {
+ await fs.writeFile(
+ paths.resolveTarget(this.file.targetPath),
+ `${JSON.stringify(this.targetPkg, null, 2)}\n`,
+ );
+ }
+}
+
+// Make sure the file is an exact match of the template
+async function exactMatchHandler(file: TemplateFile, prompt: PromptFunc) {
+ console.log(`Checking ${file.targetPath}`);
+
+ const { targetPath, templateContents } = file;
+ const coloredPath = chalk.cyan(targetPath);
+
+ if (!file.targetExists) {
+ if (await prompt(`Missing ${coloredPath}, do you want to add it?`)) {
+ await writeTargetFile(targetPath, templateContents);
+ }
+ return;
+ }
+ if (file.targetContents === templateContents) {
+ return;
+ }
+
+ const diffs = diffLines(file.targetContents, templateContents);
+ for (const diff of diffs) {
+ if (diff.added) {
+ process.stdout.write(chalk.green(`+${diff.value}`));
+ } else if (diff.removed) {
+ process.stdout.write(chalk.red(`-${diff.value}`));
+ } else {
+ process.stdout.write(` ${diff.value}`);
+ }
+ }
+
+ if (
+ await prompt(
+ `Outdated ${coloredPath}, do you want to apply the above patch?`,
+ )
+ ) {
+ await writeTargetFile(targetPath, templateContents);
+ }
+}
+
+// Adds the file if it is missing, but doesn't check existing files
+async function existsHandler(file: TemplateFile, prompt: PromptFunc) {
+ console.log(`Making sure ${file.targetPath} exists`);
+
+ const { targetPath, templateContents } = file;
+ const coloredPath = chalk.cyan(targetPath);
+
+ if (!file.targetExists) {
+ if (await prompt(`Missing ${coloredPath}, do you want to add it?`)) {
+ await writeTargetFile(targetPath, templateContents);
+ }
+ return;
+ }
+}
+
+async function skipHandler(file: TemplateFile) {
+ console.log(`Skipping ${file.targetPath}`);
+}
+
+export const handlers = {
+ skip: skipHandler,
+ exists: existsHandler,
+ exactMatch: exactMatchHandler,
+ packageJson: PackageJsonHandler.handler,
+};
+
+export async function handleAllFiles(
+ fileHandlers: FileHandler[],
+ files: TemplateFile[],
+ promptFunc: PromptFunc,
+) {
+ for (const file of files) {
+ const { targetPath } = file;
+ const fileHandler = fileHandlers.find(handler =>
+ handler.patterns.some(pattern =>
+ typeof pattern === 'string'
+ ? pattern === targetPath
+ : pattern.test(targetPath),
+ ),
+ );
+ if (fileHandler) {
+ await fileHandler.handler(file, promptFunc);
+ } else {
+ throw new Error(`No template file handler found for ${targetPath}`);
+ }
+ }
+}
diff --git a/packages/cli/src/commands/plugin/diff/index.ts b/packages/cli/src/commands/plugin/diff/index.ts
new file mode 100644
index 0000000000..116ba9e633
--- /dev/null
+++ b/packages/cli/src/commands/plugin/diff/index.ts
@@ -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 chalk from 'chalk';
+import inquirer from 'inquirer';
+import { Command } from 'commander';
+import { readTemplateFiles } from './read';
+import { handlers, handleAllFiles } from './handlers';
+import { PromptFunc } from './types';
+
+const fileHandlers = [
+ {
+ patterns: ['package.json'],
+ handler: handlers.packageJson,
+ },
+ {
+ patterns: ['tsconfig.json'],
+ handler: handlers.exactMatch,
+ },
+ {
+ // make sure files in 1st level of src/ and dev/ exist
+ patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/],
+ handler: handlers.exists,
+ },
+ {
+ patterns: ['README.md', /^src\//],
+ handler: handlers.skip,
+ },
+];
+
+const inquirerPromptFunc: PromptFunc = async msg => {
+ const { result } = await inquirer.prompt({
+ type: 'confirm',
+ name: 'result',
+ message: chalk.blue(msg),
+ });
+ return result;
+};
+
+const checkPromptFunc: PromptFunc = async msg => {
+ throw new Error(`Check failed, the following change was needed: ${msg}`);
+};
+const yesPromptFunc: PromptFunc = async msg => {
+ console.log(`Accepting: "${msg}"`);
+ return true;
+};
+
+export default async (cmd: Command) => {
+ let promptFunc = inquirerPromptFunc;
+
+ if (cmd.check) {
+ promptFunc = checkPromptFunc;
+ } else if (cmd.yes) {
+ promptFunc = yesPromptFunc;
+ }
+
+ const templateFiles = await readTemplateFiles('default-plugin');
+ await handleAllFiles(fileHandlers, templateFiles, promptFunc);
+};
diff --git a/packages/cli/src/commands/plugin/diff/read.ts b/packages/cli/src/commands/plugin/diff/read.ts
new file mode 100644
index 0000000000..ef981a0081
--- /dev/null
+++ b/packages/cli/src/commands/plugin/diff/read.ts
@@ -0,0 +1,110 @@
+/*
+ * 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 handlebars from 'handlebars';
+import recursiveReadDir from 'recursive-readdir';
+import { paths } from 'lib/paths';
+import { version } from 'lib/version';
+import { PluginInfo, TemplateFile } from './types';
+
+// Reads info from the existing plugin
+export async function readPluginInfo(): Promise {
+ let name: string;
+ try {
+ const pkg = require(paths.resolveTarget('package.json'));
+ name = pkg.name;
+ } catch (error) {
+ throw new Error(`Failed to read target package, ${error}`);
+ }
+
+ const pluginTsContents = await fs.readFile(
+ paths.resolveTarget('src/plugin.ts'),
+ 'utf8',
+ );
+ // TODO: replace with some proper parsing logic or plugin metadata file
+ const pluginIdMatch = pluginTsContents.match(/id: ['"`](.+?)['"`]/);
+ if (!pluginIdMatch) {
+ throw new Error(`Failed to parse plugin.ts, no plugin ID found`);
+ }
+
+ const id = pluginIdMatch[1];
+
+ return { id, name };
+}
+
+export async function readTemplateFile(
+ templateFile: string,
+ templateVars: any,
+): Promise {
+ const contents = await fs.readFile(templateFile, 'utf8');
+
+ if (!templateFile.endsWith('.hbs')) {
+ return contents;
+ }
+
+ return handlebars.compile(contents)(templateVars);
+}
+
+export async function readTemplate(
+ templateDir: string,
+ templateVars: any,
+): Promise {
+ const templateFilePaths = await recursiveReadDir(templateDir).catch(error => {
+ throw new Error(`Failed to read template directory: ${error.message}`);
+ });
+
+ const templateFiles = new Array();
+ for (const templateFile of templateFilePaths) {
+ // Target file inside the target dir without template extension
+ const targetFile = templateFile
+ .replace(templateDir, paths.targetDir)
+ .replace(/\.hbs$/, '');
+ const targetPath = relativePath(paths.targetDir, targetFile);
+
+ const templateContents = await readTemplateFile(templateFile, templateVars);
+
+ const targetExists = await fs.pathExists(targetFile);
+ if (targetExists) {
+ const targetContents = await fs.readFile(targetFile, 'utf8');
+ templateFiles.push({
+ targetPath,
+ targetExists,
+ targetContents,
+ templateContents,
+ });
+ } else {
+ templateFiles.push({
+ targetPath,
+ targetExists,
+ templateContents,
+ });
+ }
+ }
+
+ return templateFiles;
+}
+
+// Read all template files for a given template, along with all matching files in the target dir
+export async function readTemplateFiles(template: string) {
+ const pluginInfo = await readPluginInfo();
+ const templateVars = { version, ...pluginInfo };
+
+ const templateDir = paths.resolveOwn('templates', template);
+
+ return await readTemplate(templateDir, templateVars);
+}
diff --git a/packages/cli/src/commands/plugin/diff/types.ts b/packages/cli/src/commands/plugin/diff/types.ts
new file mode 100644
index 0000000000..7a04cf8d2e
--- /dev/null
+++ b/packages/cli/src/commands/plugin/diff/types.ts
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export type PluginInfo = {
+ id: string;
+ name: string;
+};
+
+export type TemplateFile = {
+ // Relative path within the target directory
+ targetPath: string;
+ // Contents of the compiled template file
+ templateContents: string;
+} & (
+ | {
+ // Whether the template file exists in the target directory
+ targetExists: true;
+ // Contents of the file in the target directory, if it exists
+ targetContents: string;
+ }
+ | {
+ // Whether the template file exists in the target directory
+ targetExists: false;
+ }
+);
+
+export type PromptFunc = (msg: string) => Promise;
+
+export type HandlerFunc = (
+ file: TemplateFile,
+ prompt: PromptFunc,
+) => Promise;
+
+export type FileHandler = {
+ patterns: Array;
+ handler: HandlerFunc;
+};
diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts
index e267bb9582..95077e2df3 100644
--- a/packages/cli/src/commands/plugin/rollup.config.ts
+++ b/packages/cli/src/commands/plugin/rollup.config.ts
@@ -27,8 +27,8 @@ import { paths } from 'lib/paths';
export default {
input: 'src/index.ts',
output: {
- file: 'dist/index.cjs.js',
- format: 'cjs',
+ file: 'dist/index.esm.js',
+ format: 'module',
},
plugins: [
peerDepsExternal({
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 1c3f66bce7..6c6aec3776 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -62,6 +62,13 @@ const main = (argv: string[]) => {
.description('Serves the dev/ folder of a plugin')
.action(actionHandler(() => require('commands/plugin/serve')));
+ program
+ .command('plugin:diff')
+ .option('--check', 'Fail if changes are required')
+ .option('--yes', 'Apply all changes')
+ .description('Diff an existing plugin with the creation template')
+ .action(actionHandler(() => require('commands/plugin/diff')));
+
program
.command('lint')
.option('--fix', 'Attempt to automatically fix violations')
diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
index 0f2cb3e1d0..d936bb0784 100644
--- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
+++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs
@@ -1,7 +1,7 @@
{
"name": "plugin-welcome",
"version": "0.0.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"private": true,
"scripts": {
diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs
index 7603d24618..dad663ce2c 100644
--- a/packages/cli/templates/default-plugin/package.json.hbs
+++ b/packages/cli/templates/default-plugin/package.json.hbs
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-{{id}}",
"version": "{{version}}",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"license": "Apache-2.0",
"private": true,
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
index f0f148db48..34e37f3e7b 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.tsx.hbs
@@ -47,9 +47,7 @@ const ExampleComponent: FC<{}> = () => (
-
-
-
+
diff --git a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
index a198bfcc3f..2dd54f726e 100644
--- a/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
+++ b/packages/cli/templates/default-plugin/src/components/ExampleFetchComponent/ExampleFetchComponent.tsx.hbs
@@ -16,20 +16,11 @@
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/core/styles';
-import Table from '@material-ui/core/Table';
-import TableBody from '@material-ui/core/TableBody';
-import TableCell from '@material-ui/core/TableCell';
-import TableContainer from '@material-ui/core/TableContainer';
-import TableHead from '@material-ui/core/TableHead';
-import TableRow from '@material-ui/core/TableRow';
+import { Table, TableColumn, Progress } from '@backstage/core';
import Alert from '@material-ui/lab/Alert';
import { useAsync } from 'react-use';
-import { Progress } from '@backstage/core';
const useStyles = makeStyles({
- table: {
- minWidth: 650,
- },
avatar: {
height: 32,
width: 32,
@@ -66,37 +57,35 @@ type DenseTableProps = {
export const DenseTable: FC = ({ users }) => {
const classes = useStyles();
+ const columns: TableColumn[] = [
+ { title: 'Avatar', field: 'avatar' },
+ { title: 'Name', field: 'name' },
+ { title: 'Email', field: 'email' },
+ { title: 'Nationality', field: 'nationality' },
+ ];
+
+ const data = users.map((user) => {
+ return {
+ avatar: (
+
+ ),
+ name: `${user.name.first} ${user.name.last}`,
+ email: user.email,
+ nationality: user.nat,
+ };
+ });
+
return (
-
-
);
};
diff --git a/packages/core/package.json b/packages/core/package.json
index aa3efafa95..107cba0196 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -16,7 +16,7 @@
"backstage"
],
"license": "Apache-2.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
@@ -45,6 +45,7 @@
"classnames": "^2.2.6",
"clsx": "^1.1.0",
"lodash": "^4.17.15",
+ "material-table": "^1.57.2",
"prop-types": "^15.7.2",
"rc-progress": "^2.5.2",
"react": "^16.12.0",
@@ -53,8 +54,7 @@
"react-helmet": "5.2.1",
"react-router": "^5.1.2",
"react-router-dom": "^5.1.2",
- "react-sparklines": "^1.7.0",
- "recompose": "0.30.0"
+ "react-sparklines": "^1.7.0"
},
"files": [
"dist"
diff --git a/packages/core/src/api/api.ts b/packages/core/src/api/api.ts
index abaa28cbbf..cafd0af126 100644
--- a/packages/core/src/api/api.ts
+++ b/packages/core/src/api/api.ts
@@ -16,7 +16,6 @@
import ApiRef, { ApiRefConfig } from './apis/ApiRef';
import AppBuilder from './app/AppBuilder';
-import WidgetViewBuilder from './widgetView/WidgetViewBuilder';
import BackstagePlugin, { PluginConfig } from './plugin/Plugin';
export function createApp() {
@@ -27,10 +26,6 @@ export function createApiRef(config: ApiRefConfig) {
return new ApiRef(config);
}
-export function createWidgetView() {
- return new WidgetViewBuilder();
-}
-
export function createPlugin(config: PluginConfig): BackstagePlugin {
return new BackstagePlugin(config);
}
diff --git a/packages/core/src/api/app/AppBuilder.tsx b/packages/core/src/api/app/AppBuilder.tsx
index ccc1303ccb..94d6df1b3d 100644
--- a/packages/core/src/api/app/AppBuilder.tsx
+++ b/packages/core/src/api/app/AppBuilder.tsx
@@ -21,6 +21,8 @@ import { App } from './types';
import BackstagePlugin from 'api/plugin/Plugin';
import { FeatureFlagsRegistryItem } from './FeatureFlags';
import { featureFlagsApiRef } from 'api/apis/definitions/featureFlags';
+import ErrorPage from '../../layout/ErrorPage';
+
import {
IconComponent,
SystemIcons,
@@ -115,7 +117,11 @@ export default class AppBuilder {
let rendered = (
{routes}
- 404 Not Found} />
+ (
+
+ )}
+ />
);
diff --git a/packages/core/src/api/plugin/Plugin.tsx b/packages/core/src/api/plugin/Plugin.tsx
index 47f8331e10..a8b694ae8d 100644
--- a/packages/core/src/api/plugin/Plugin.tsx
+++ b/packages/core/src/api/plugin/Plugin.tsx
@@ -22,7 +22,6 @@ import {
FeatureFlagName,
} from './types';
import { validateBrowserCompat, validateFlagName } from 'api/app/FeatureFlags';
-import { Widget } from 'api/widgetView/types';
export type PluginConfig = {
id: string;
@@ -31,7 +30,6 @@ export type PluginConfig = {
export type PluginHooks = {
router: RouterHooks;
- widgets: WidgetHooks;
featureFlags: FeatureFlagsHooks;
};
@@ -49,10 +47,6 @@ export type RouterHooks = {
): void;
};
-export type WidgetHooks = {
- add(widget: Widget): void;
-};
-
export type FeatureFlagsHooks = {
register(name: FeatureFlagName): void;
};
@@ -88,11 +82,6 @@ export default class Plugin {
outputs.push({ type: 'redirect-route', path, target, options });
},
},
- widgets: {
- add(widget: Widget) {
- outputs.push({ type: 'widget', widget });
- },
- },
featureFlags: {
register(name) {
validateBrowserCompat();
diff --git a/packages/core/src/api/plugin/types.ts b/packages/core/src/api/plugin/types.ts
index 3192dfd095..c4ac426e5c 100644
--- a/packages/core/src/api/plugin/types.ts
+++ b/packages/core/src/api/plugin/types.ts
@@ -15,7 +15,6 @@
*/
import { ComponentType } from 'react';
-import { Widget } from 'api/widgetView/types';
export type RouteOptions = {
// Whether the route path must match exactly, defaults to true.
@@ -38,11 +37,6 @@ export type RedirectRouteOutput = {
options?: RouteOptions;
};
-export type WidgetOutput = {
- type: 'widget';
- widget: Widget;
-};
-
export type FeatureFlagName = string;
export type FeatureFlagOutput = {
@@ -53,5 +47,4 @@ export type FeatureFlagOutput = {
export type PluginOutput =
| RouteOutput
| RedirectRouteOutput
- | WidgetOutput
| FeatureFlagOutput;
diff --git a/packages/core/src/api/widgetView/WidgetViewBuilder.tsx b/packages/core/src/api/widgetView/WidgetViewBuilder.tsx
deleted file mode 100644
index 0bf873df86..0000000000
--- a/packages/core/src/api/widgetView/WidgetViewBuilder.tsx
+++ /dev/null
@@ -1,83 +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, { ComponentType } from 'react';
-import { AppComponentBuilder } from 'api/app/types';
-import { Widget } from './types';
-import BackstagePlugin from 'api/plugin/Plugin';
-import DefaultWidgetView from 'components/DefaultWidgetView';
-
-type WidgetViewRegistration =
- | {
- type: 'component';
- widget: Widget;
- }
- | {
- type: 'plugin';
- plugin: BackstagePlugin;
- };
-
-export default class WidgetViewBuilder extends AppComponentBuilder {
- private readonly registrations = new Array();
- private output?: ComponentType;
-
- add(widget: Widget): WidgetViewBuilder {
- this.registrations.push({ type: 'component', widget });
- return this;
- }
-
- register(plugin: BackstagePlugin): WidgetViewBuilder {
- this.registrations.push({ type: 'plugin', plugin });
- return this;
- }
-
- build(): ComponentType {
- if (this.output) {
- return this.output;
- }
-
- const widgets = new Array();
-
- for (const reg of this.registrations) {
- switch (reg.type) {
- case 'component':
- widgets.push(reg.widget);
- break;
- case 'plugin':
- {
- let added = false;
- for (const output of reg.plugin.output()) {
- if (output.type === 'widget') {
- widgets.push(output.widget);
- added = true;
- }
- }
- if (!added) {
- throw new Error(
- `Plugin ${reg.plugin} was registered as widget provider, but did not provide any widgets`,
- );
- }
- }
- break;
- default:
- throw new Error(`Unknown WidgetViewBuilder registration`);
- }
- }
-
- this.output = () => ;
- return this.output;
- }
-}
diff --git a/packages/core/src/components/BasicStepper/BasicVerticalStep.tsx b/packages/core/src/components/BasicStepper/BasicVerticalStep.tsx
new file mode 100644
index 0000000000..21fbb11cf2
--- /dev/null
+++ b/packages/core/src/components/BasicStepper/BasicVerticalStep.tsx
@@ -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 React, { FC } from 'react';
+import {
+ Step as MuiStep,
+ StepContent,
+ StepLabel,
+ Typography,
+ makeStyles,
+} from '@material-ui/core';
+import SimpleStepperFooter from './SimpleStepperFooter';
+
+const useStyles = makeStyles((theme) => ({
+ end: {
+ padding: theme.spacing(3),
+ },
+}));
+
+export type StepActions = {
+ showNext?: boolean;
+ canNext?: () => boolean;
+ onNext?: () => void;
+ nextStep?: (current: number, last: number) => number;
+ nextText?: string;
+
+ showBack?: boolean;
+ backText?: string;
+ onBack?: () => void;
+
+ showRestart?: boolean;
+ canRestart?: () => boolean;
+ onRestart?: () => void;
+ restartText?: string;
+};
+
+export type StepProps = {
+ title: string;
+ children: React.ReactElement;
+ end?: boolean;
+ actions?: StepActions;
+};
+
+const Step: FC = ({
+ title,
+ children,
+ end,
+ actions,
+ ...muiProps
+}) => {
+ const classes = useStyles();
+
+ // The end step is not a part of the stepper
+ // It simply is the final screen with an option to have buttons such as reset or back
+ return end ? (
+
+ {title}
+ {children}
+
+
+ ) : (
+
+
+ {title}
+
+
+ {children}
+
+
+
+ );
+};
+
+export default Step;
diff --git a/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx b/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx
deleted file mode 100644
index 990ecb0e92..0000000000
--- a/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx
+++ /dev/null
@@ -1,48 +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 { Grid, Paper, makeStyles, Theme } from '@material-ui/core';
-import { WidgetViewProps } from 'api/widgetView/types';
-
-const useStyles = makeStyles(theme => ({
- root: {
- padding: theme.spacing(2),
- },
- widgetWrapper: {
- padding: theme.spacing(2),
- },
-}));
-
-const WidgetViewComponent: FC = ({ widgets }) => {
- const classes = useStyles();
-
- return (
-
- );
-};
-
-export default WidgetViewComponent;
diff --git a/packages/core/src/components/Sequence/Sequence.stories.tsx b/packages/core/src/components/Sequence/Sequence.stories.tsx
deleted file mode 100644
index f1fd817487..0000000000
--- a/packages/core/src/components/Sequence/Sequence.stories.tsx
+++ /dev/null
@@ -1,81 +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 } from 'react';
-import { TextField } from '@material-ui/core';
-import Sequence, { StepType } from '.';
-
-export default {
- title: 'Sequence',
- component: Sequence,
-};
-
-const steps = [
- {
- title: 'Step 1!',
- content:
,
- };
- return (
-
- );
-};
diff --git a/packages/core/src/components/Sequence/Sequence.test.tsx b/packages/core/src/components/Sequence/Sequence.test.tsx
deleted file mode 100644
index 591ea616c2..0000000000
--- a/packages/core/src/components/Sequence/Sequence.test.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import React from 'react';
-import { render, fireEvent } from '@testing-library/react';
-import { wrapInTestApp } from '@backstage/test-utils';
-import Sequence, { StepType } from './Sequence';
-
-describe('Sequence', () => {
- let steps: StepType[];
-
- beforeEach(() => {
- steps = [
- {
- title: 'Step 0',
- content:
- )}
- {/* Horizontal steppers have buttons on the bottom of stepper */}
- {!isVertical && (
- <>
-
- {horizontalStep}
-
-
- >
- )}
-
- );
-};
-
-export default Sequence;
diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx
new file mode 100644
index 0000000000..81f558f49d
--- /dev/null
+++ b/packages/core/src/components/SimpleStepper/SimpleStepper.stories.tsx
@@ -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 React, { useState } from 'react';
+import { TextField } from '@material-ui/core';
+import { SimpleStepper, SimpleStepperStep } from '.';
+
+export default {
+ title: 'SimpleStepper',
+ component: SimpleStepper,
+};
+
+export const Default = () => (
+
+
+
+
+
+ );
+};
diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.test.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.test.tsx
new file mode 100644
index 0000000000..f07ceca6ff
--- /dev/null
+++ b/packages/core/src/components/SimpleStepper/SimpleStepper.test.tsx
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import React from 'react';
+import { render, fireEvent, within } from '@testing-library/react';
+import { wrapInTestApp } from '@backstage/test-utils';
+import Stepper from './SimpleStepper';
+import Step from './SimpleStepperStep';
+
+const getTextInSlide = (rendered: any, index: number) =>
+ within(rendered.getByTestId(`step${index}`)).getByText;
+
+describe('Stepper', () => {
+ it('Handles nextStep property', async () => {
+ const rendered = render(
+ wrapInTestApp(
+
+
+
step0
+
+ 3 }}
+ data-testid="step1"
+ >
+
step1
+
+
+
step2
+
+
+
step3
+
+ ,
+ ),
+ );
+
+ fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Next') as Node);
+ expect(rendered.getByText('step3')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 3)('Back') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+ });
+
+ it('Shows controls and content when going back to first step', () => {
+ const rendered = render(
+ wrapInTestApp(
+
+
+
step0
+
+ 3 }}
+ data-testid="step1"
+ >
+
step1
+
+
+
step2
+
+
+
step3
+
+ ,
+ ),
+ );
+
+ fireEvent.click(getTextInSlide(rendered, 0)('Next') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Next') as Node);
+ expect(rendered.getByText('step3')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 3)('Back') as Node);
+ expect(rendered.getByText('step1')).toBeInTheDocument();
+ expect(getTextInSlide(rendered, 1)('Next')).toBeInTheDocument();
+
+ fireEvent.click(getTextInSlide(rendered, 1)('Back') as Node);
+ expect(rendered.getByText('step0')).toBeInTheDocument();
+ expect(getTextInSlide(rendered, 0)('Next')).toBeInTheDocument();
+ });
+
+ it('uses nextText if specified in all steps', () => {
+ const rendered = render(
+ wrapInTestApp(
+
+
+
+
step0
+
+
+
final step
+
+
+ ,
+ ),
+ );
+ expect(rendered.getByText('Step0Next')).toBeInTheDocument();
+ fireEvent.click(rendered.getByText('Step0Next'));
+
+ expect(rendered.getByText('FinalStepNext')).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/components/SimpleStepper/SimpleStepper.tsx b/packages/core/src/components/SimpleStepper/SimpleStepper.tsx
new file mode 100644
index 0000000000..a13ad6b5ee
--- /dev/null
+++ b/packages/core/src/components/SimpleStepper/SimpleStepper.tsx
@@ -0,0 +1,84 @@
+/*
+ * 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, { Children, isValidElement, FC, useState } from 'react';
+import { Stepper as MuiStepper } from '@material-ui/core';
+
+type InternalState = {
+ stepperLength: number;
+ stepIndex: number;
+ setStepIndex: any;
+ stepHistory: number[];
+ setStepHistory: any;
+ onStepChange?: (prevIndex: number, nextIndex: number) => void;
+};
+
+const noop = () => {};
+export const VerticalStepperContext = React.createContext({
+ stepperLength: 0,
+ stepIndex: 0,
+ setStepIndex: noop,
+ stepHistory: [],
+ setStepHistory: noop,
+ onStepChange: noop,
+});
+
+export interface StepperProps {
+ elevated?: boolean;
+ onStepChange?: (prevIndex: number, nextIndex: number) => void;
+}
+
+const Stepper: FC = ({ children, elevated, onStepChange }) => {
+ const [stepIndex, setStepIndex] = useState(0);
+ const [stepHistory, setStepHistory] = useState([]);
+
+ const steps: React.ReactNode[] = [];
+ let endStep;
+ Children.forEach(children, (child) => {
+ if (isValidElement(child)) {
+ if (child.props.end) {
+ endStep = child;
+ } else {
+ steps.push(child);
+ }
+ }
+ });
+
+ return (
+ <>
+
+
+ {steps}
+
+
+ {stepIndex >= Children.count(children) - 1 && endStep}
+ >
+ );
+};
+
+export default Stepper;
diff --git a/packages/core/src/components/Sequence/SequenceFooter.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx
similarity index 73%
rename from packages/core/src/components/Sequence/SequenceFooter.tsx
rename to packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx
index 475be30b8e..b49a62148d 100644
--- a/packages/core/src/components/Sequence/SequenceFooter.tsx
+++ b/packages/core/src/components/SimpleStepper/SimpleStepperFooter.tsx
@@ -13,11 +13,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import React, { FC, ReactNode } from 'react';
+import React, { useContext, FC, ReactNode } from 'react';
import { Button, makeStyles } from '@material-ui/core';
-import { StepActions } from './Sequence';
+import { StepActions } from './SimpleStepperStep';
+import { VerticalStepperContext } from './SimpleStepper';
-const useStyles = makeStyles(theme => ({
+const useStyles = makeStyles((theme) => ({
root: {
marginTop: theme.spacing(3),
'& button': {
@@ -65,37 +66,31 @@ const BackBtn: FC<{
);
-export type SequenceFooterProps = {
- stepIndex: number;
- setStepIndex: React.Dispatch>;
- stepArray: number[];
- setStepArray: React.Dispatch>;
- length: number;
- actions: StepActions;
- onSequenceStepChange?: (old: number, updated: number) => void;
+export type SimpleStepperFooterProps = {
+ actions?: StepActions;
children?: ReactNode;
- className: string;
};
-const SequenceFooter: FC = ({
- stepIndex,
- setStepIndex,
- stepArray,
- setStepArray,
- length,
- onSequenceStepChange,
- actions,
+const SimpleStepperFooter: FC = ({
+ actions = {},
children,
- className,
}) => {
const classes = useStyles();
+ const {
+ stepperLength,
+ stepIndex,
+ setStepIndex,
+ stepHistory,
+ setStepHistory,
+ onStepChange,
+ } = useContext(VerticalStepperContext);
const onChange = (newIndex: number, callback?: () => void) => {
if (callback) {
callback();
}
- if (onSequenceStepChange) {
- onSequenceStepChange(stepIndex, newIndex);
+ if (onStepChange) {
+ onStepChange(stepIndex, newIndex);
}
setStepIndex(newIndex);
@@ -103,23 +98,23 @@ const SequenceFooter: FC = ({
const handleNext = () => {
const newIndex = actions.nextStep
- ? actions.nextStep(stepIndex, length - 1)
+ ? actions.nextStep(stepIndex, stepperLength - 1)
: stepIndex + 1;
onChange(newIndex, actions.onNext);
- setStepArray([...stepArray, newIndex]);
+ setStepHistory([...stepHistory, newIndex]);
};
const handleBack = () => {
- stepArray.pop();
- onChange(stepArray[stepArray.length - 1], actions.onBack);
- setStepArray([...stepArray]);
+ stepHistory.pop();
+ onChange(stepHistory[stepHistory.length - 1], actions.onBack);
+ setStepHistory([...stepHistory]);
};
const handleRestart = () => {
onChange(0, actions.onRestart);
- setStepArray([0]);
+ setStepHistory([0]);
};
return (
-
+
{[undefined, true].includes(actions.showBack) && stepIndex !== 0 && (
= ({
text={actions.nextText}
handleClick={handleNext}
disabled={
- (!!length && stepIndex >= length) ||
+ (!!stepperLength && stepIndex >= stepperLength) ||
(!!actions.canNext && !actions.canNext())
}
stepIndex={stepIndex}
@@ -151,4 +146,4 @@ const SequenceFooter: FC = ({
);
};
-export default SequenceFooter;
+export default SimpleStepperFooter;
diff --git a/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx b/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx
new file mode 100644
index 0000000000..21fbb11cf2
--- /dev/null
+++ b/packages/core/src/components/SimpleStepper/SimpleStepperStep.tsx
@@ -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 React, { FC } from 'react';
+import {
+ Step as MuiStep,
+ StepContent,
+ StepLabel,
+ Typography,
+ makeStyles,
+} from '@material-ui/core';
+import SimpleStepperFooter from './SimpleStepperFooter';
+
+const useStyles = makeStyles((theme) => ({
+ end: {
+ padding: theme.spacing(3),
+ },
+}));
+
+export type StepActions = {
+ showNext?: boolean;
+ canNext?: () => boolean;
+ onNext?: () => void;
+ nextStep?: (current: number, last: number) => number;
+ nextText?: string;
+
+ showBack?: boolean;
+ backText?: string;
+ onBack?: () => void;
+
+ showRestart?: boolean;
+ canRestart?: () => boolean;
+ onRestart?: () => void;
+ restartText?: string;
+};
+
+export type StepProps = {
+ title: string;
+ children: React.ReactElement;
+ end?: boolean;
+ actions?: StepActions;
+};
+
+const Step: FC = ({
+ title,
+ children,
+ end,
+ actions,
+ ...muiProps
+}) => {
+ const classes = useStyles();
+
+ // The end step is not a part of the stepper
+ // It simply is the final screen with an option to have buttons such as reset or back
+ return end ? (
+
+ {title}
+ {children}
+
+
+ ) : (
+
+
+ {title}
+
+
+ {children}
+
+
+
+ );
+};
+
+export default Step;
diff --git a/packages/core/src/components/SimpleStepper/index.ts b/packages/core/src/components/SimpleStepper/index.ts
new file mode 100644
index 0000000000..00eb92a382
--- /dev/null
+++ b/packages/core/src/components/SimpleStepper/index.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 SimpleStepper from './SimpleStepper';
+import SimpleStepperStep from './SimpleStepperStep';
+
+export { SimpleStepper, SimpleStepperStep };
diff --git a/packages/core/src/components/SortableTable/SortableTable.js b/packages/core/src/components/SortableTable/SortableTable.js
deleted file mode 100644
index ab4e1af2de..0000000000
--- a/packages/core/src/components/SortableTable/SortableTable.js
+++ /dev/null
@@ -1,296 +0,0 @@
-/*
- * Copyright 2020 Spotify AB
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import React from 'react';
-import { pure } from 'recompose';
-import PropTypes from 'prop-types';
-import {
- Table,
- TableBody,
- TableCell,
- TableFooter,
- TableHead,
- TableRow,
- TableSortLabel,
- Tooltip,
-} from '@material-ui/core';
-
-/**
- * Table header which supports sorting ascending and desc
- */
-const EnhancedTableHead = ({ columns, onRequestSort, order, orderBy }) => {
- const createSortHandler = property => event => {
- onRequestSort(event, property);
- };
-
- return (
-
-
- {columns.map(column => (
-
-
-
- {column.label}
-
-
-
- ))}
-
-
- );
-};
-
-EnhancedTableHead.propTypes = {
- columns: PropTypes.arrayOf(
- PropTypes.shape({
- id: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
- numeric: PropTypes.bool,
- disablePadding: PropTypes.bool,
- style: PropTypes.object,
- }),
- ).isRequired,
- onRequestSort: PropTypes.func.isRequired,
- order: PropTypes.string.isRequired,
- orderBy: PropTypes.string.isRequired,
-};
-
-/**
- * CellContent can be an array or a string
- */
-const CellContent = ({ data }) => {
- if (Array.isArray(data)) {
- return data.map((item, index) => (
-
- {item}
-
-
- ));
- }
- return data;
-};
-
-CellContent.propTypes = {
- data: PropTypes.any.isRequired,
-};
-
-const DataTableCell = ({ column, row }) => {
- return (
-
-
-
- );
-};
-
-const noop = () => {};
-const DataTableRow = pure(({ row, columns, handleRowClick, style }) => {
- const onClick = event => (handleRowClick || noop)(event, row.id);
- return (
-
- {columns.map(column => (
-
- ))}
-
- );
-});
-
-/**
- * Table with sorting capabilites automatic rendering of cells
- * Note that the objects in props.data needs have an id property
- * The columns array defines which columns from the data to show.
- *
- * @param {Array[Object]} data A list of data entries, where object properties must
- * be strictly equal to column ids.
- *
- * @param {Array[Object]} columns A list of columns with the following shape:
- * {
- * // The column identifier must be strictly equal the relevant data entry
- * // key:
- * id: String,
- *
- * // The display label for the column:
- * label: String,
- *
- * // If true, the column contents will be right-aligned:
- * numeric: Boolean,
- *
- * // If true, padding will be disabled for table cells:
- * disablePadding: Boolean,
- *
- * // A function taking a data row and returning a suitable primitive for
- * // sorting:
- * sortValue: (Object) => Any
- * }
- *
- * @param {String} orderBy The column ID initially used for sorting
- *
- * @param {String} [dataVersion] A version identifier for the data which *must*
- * be updated when the contents of the data changes. This can be used for
- * components where the same SortableTable element will be used to display
- * variable sets of data.
- *
- * @param {Array[Object]} [footerData] A list of data entries to be placed in
- * the table footer, which will not be sorted.
- *
- * @param {(String, Event) => Void} [onRowClicked] Get notified when a user clicks
- * on the row. The handler will receive the row id as the first argument, and
- * the synthetic click event as the second argument.
- *
- * @example
- * render {
- * const data = [
- * { id: 'buffalos', amount: 1, status: , statusValue: 2 },
- * { id: 'milk', amount: 3, status: , statusValue: 1 }
- * ];
- * const columns = [
- * { id: 'id', label: 'ID' },
- * { id: 'amount', disablePadding: false, numeric: true, label: 'AMOUNT' },
- * { id: 'status', label: 'STATUS', sortValue: row => row.statusValue },
- * ];
- * const footerData = [
- * { id: 'total', amount: 4, statusValue: 2, status: },
- * ];
- * return (
- * {console.log('Row:' + id + ' clicked');
- * ev.preventDefault();}}/>)
- * }
- *
- * @deprecated use shared/components/DataGrid
- */
-class SortableTable extends React.Component {
- static propTypes = {
- // TODO: figure out how to make id of the object requried while others are dynamic
- data: PropTypes.arrayOf(PropTypes.object).isRequired,
- orderBy: PropTypes.string.isRequired,
- columns: PropTypes.arrayOf(PropTypes.object).isRequired,
- onRowClicked: PropTypes.func,
- dataVersion: PropTypes.string,
- };
-
- constructor(props) {
- super(props);
- this.handleRowClick = this.handleRowClick.bind(this);
-
- this.state = {
- orderBy: props.orderBy,
- order: 'asc',
- data: props.data,
- };
- }
-
- handleRequestSort = (event, property) => {
- event.preventDefault();
- const orderBy = property;
- let order = 'desc';
- if (this.state.orderBy === property && this.state.order === 'desc') {
- order = 'asc';
- }
- this.updateData(this.state.data, orderBy, order);
- };
-
- handleRowClick = (event, id) => {
- if (this.props.onRowClicked) {
- this.props.onRowClicked(id, event);
- }
- };
-
- updateData = (data, orderBy, order) => {
- const sortValueFn = (
- this.props.columns.filter(col => col.id === orderBy)[0] || {}
- ).sortValue;
-
- const sortedData = data.slice().sort((a, b) => {
- const valueA = sortValueFn ? sortValueFn(a) : a[orderBy];
- const valueB = sortValueFn ? sortValueFn(b) : b[orderBy];
- const inc = order === 'desc' ? -1 : 1;
- if (valueA === valueB) return 0;
- if (valueA === '' || valueA === null) return inc;
- if (valueB === '' || valueB === null) return -inc;
- return valueA < valueB ? -inc : inc;
- });
- this.setState({ data: sortedData, order, orderBy });
- };
-
- UNSAFE_componentWillReceiveProps(props) {
- if (props.dataVersion !== this.props.dataVersion) {
- this.updateData(props.data, this.state.orderBy, this.state.order);
- }
- }
-
- render() {
- const { data, order, orderBy } = this.state;
- const { columns, dataVersion, footerData } = this.props;
-
- let tableFoot = null;
- if (footerData && footerData.length > 0) {
- tableFoot = (
-
- {footerData.map(row => (
-
- ))}
-
- );
- }
- return (
-
+ );
+};
diff --git a/packages/core/src/components/Table/Table.test.tsx b/packages/core/src/components/Table/Table.test.tsx
new file mode 100644
index 0000000000..f6801359eb
--- /dev/null
+++ b/packages/core/src/components/Table/Table.test.tsx
@@ -0,0 +1,50 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { wrapInTestApp } from '@backstage/test-utils';
+import Table from './';
+
+const minProps = {
+ columns: [
+ {
+ title: 'Column 1',
+ field: 'col1',
+ },
+ {
+ title: 'Column 2',
+ field: 'col2',
+ },
+ ],
+ data: [
+ {
+ col1: 'first value, first row',
+ col2: 'second value, first row',
+ },
+ {
+ col1: 'first value, second row',
+ col2: 'second value, second row',
+ },
+ ],
+};
+
+describe('
));
+ expect(rendered.getByText('second value, second row')).toBeInTheDocument();
+ });
+});
diff --git a/packages/core/src/components/Table/Table.tsx b/packages/core/src/components/Table/Table.tsx
new file mode 100644
index 0000000000..a9a55ae763
--- /dev/null
+++ b/packages/core/src/components/Table/Table.tsx
@@ -0,0 +1,192 @@
+/*
+ * 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, forwardRef } from 'react';
+import MTable, {
+ MTableCell,
+ MTableHeader,
+ MTableToolbar,
+ MaterialTableProps,
+ Options,
+ Column,
+} from 'material-table';
+import { BackstageTheme } from '@backstage/theme';
+import { makeStyles } 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';
+
+const tableIcons = {
+ Add: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Check: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Clear: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Delete: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ DetailPanel: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Edit: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Export: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Filter: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ FirstPage: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ LastPage: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ NextPage: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ PreviousPage: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ ResetSearch: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ Search: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ SortArrow: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ ThirdStateCheck: forwardRef((props, ref: React.Ref) => (
+
+ )),
+ ViewColumn: forwardRef((props, ref: React.Ref) => (
+
+ )),
+};
+
+const useCellStyles = makeStyles(theme => ({
+ root: {
+ color: theme.palette.grey[500],
+ padding: theme.spacing(0, 2, 0, 2.5),
+ height: '56px',
+ },
+}));
+
+const useHeaderStyles = makeStyles(theme => ({
+ header: {
+ padding: theme.spacing(1, 2, 1, 2.5),
+ borderTop: `1px solid ${theme.palette.grey.A100}`,
+ borderBottom: `1px solid ${theme.palette.grey.A100}`,
+ color: theme.palette.textSubtle,
+ fontWeight: 'bold',
+ position: 'static',
+ },
+}));
+
+const useToolbarStyles = makeStyles(theme => ({
+ root: {
+ padding: theme.spacing(3, 0, 2.5, 2.5),
+ },
+ title: {
+ '& > h6': {
+ fontWeight: 'bold',
+ },
+ },
+}));
+
+const convertColumns = (columns: TableColumn[]): TableColumn[] => {
+ return columns.map(column => {
+ const headerStyle: React.CSSProperties = {};
+ const cellStyle: React.CSSProperties = {};
+
+ if (column.highlight) {
+ headerStyle.color = '#000000';
+ cellStyle.fontWeight = 'bold';
+ }
+
+ return {
+ ...column,
+ headerStyle,
+ cellStyle,
+ };
+ });
+};
+
+export interface TableColumn extends Column<{}> {
+ highlight?: boolean;
+}
+
+export interface TableProps extends MaterialTableProps<{}> {
+ columns: TableColumn[];
+}
+
+const Table: FC = ({ columns, options, ...props }) => {
+ const cellClasses = useCellStyles();
+ const headerClasses = useHeaderStyles();
+ const toolbarClasses = useToolbarStyles();
+
+ const MTColumns = convertColumns(columns);
+
+ const defaultOptions: Options = {
+ headerStyle: {
+ textTransform: 'uppercase',
+ },
+ };
+
+ return (
+ (
+
+ ),
+ Header: headerProps => (
+
+ ),
+ Toolbar: toolbarProps => (
+
+ ),
+ }}
+ options={{ ...defaultOptions, ...options }}
+ columns={MTColumns}
+ icons={tableIcons}
+ {...props}
+ />
+ );
+};
+
+export default Table;
diff --git a/packages/core/src/components/Table/index.ts b/packages/core/src/components/Table/index.ts
new file mode 100644
index 0000000000..7ea3ead808
--- /dev/null
+++ b/packages/core/src/components/Table/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export { default } from './Table';
+export type { TableColumn } from './Table';
+export { default as SubvalueCell } from './SubvalueCell';
diff --git a/packages/core/src/components/TrendLine/TrendLine.stories.tsx b/packages/core/src/components/TrendLine/TrendLine.stories.tsx
index fab5388c81..562feeff80 100644
--- a/packages/core/src/components/TrendLine/TrendLine.stories.tsx
+++ b/packages/core/src/components/TrendLine/TrendLine.stories.tsx
@@ -16,7 +16,7 @@
import React from 'react';
import TrendLine from '.';
-import SortableTable from 'components/SortableTable';
+import Table from 'components/Table';
import InfoCard from 'layout/InfoCard';
export default {
@@ -24,7 +24,7 @@ export default {
component: TrendLine,
};
-const containerStyle = { width: 600 };
+const containerStyle = { width: 700 };
const data = [
{
@@ -58,17 +58,27 @@ const data = [
];
const columns = [
- { id: 'stock', label: 'Stock' },
- { id: 'day', label: 'Day' },
- { id: 'week', label: 'Week' },
- { id: 'month', label: 'Month' },
- { id: 'year', label: 'Year' },
+ { field: 'stock', title: 'Stock' },
+ { field: 'day', title: 'Day' },
+ { field: 'week', title: 'Week' },
+ { field: 'month', title: 'Month' },
+ { field: 'year', title: 'Year' },
];
export const Default = () => (
-
-
-
+
+
+
+
+
);
export const TrendingMix = () => (
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 62a89fd337..e817ada9c9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -16,12 +16,14 @@
export * from './api';
export { default as Page } from './layout/Page';
-export { gradients, pageTheme, PageTheme } from './layout/Page';
+export { gradients, pageTheme } from './layout/Page';
+export type { PageTheme } from './layout/Page';
export { default as Content } from './layout/Content/Content';
export { default as ContentHeader } from './layout/ContentHeader/ContentHeader';
export { default as Header } from './layout/Header/Header';
export { default as HeaderLabel } from './layout/HeaderLabel';
export { default as InfoCard } from './layout/InfoCard';
+export { CardTab, TabbedCard } from './layout/TabbedCard';
export { default as ErrorBoundary } from './layout/ErrorBoundary';
export * from './layout/Sidebar';
export { default as HorizontalScrollGrid } from './components/HorizontalScrollGrid';
@@ -30,10 +32,11 @@ export { default as CircleProgress } from './components/ProgressBars/CircleProgr
export { default as HorizontalProgress } from './components/ProgressBars/HorizontalProgress';
export { default as CopyTextButton } from './components/CopyTextButton';
export { default as Progress } from './components/Progress';
-export { default as Sequence } from './components/Sequence';
+export * from './components/SimpleStepper';
export { AlphaLabel, BetaLabel } from './components/Lifecycle';
export { default as SupportButton } from './components/SupportButton';
-export { default as SortableTable } from './components/SortableTable';
+export { default as Table, SubvalueCell } from './components/Table';
+export type { TableColumn } from './components/Table/Table';
export { default as StructuredMetadataTable } from './components/StructuredMetadataTable';
export { default as TrendLine } from './components/TrendLine';
export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular';
diff --git a/packages/core/src/layout/BottomLink/index.ts b/packages/core/src/layout/BottomLink/index.ts
index 5d4845064b..6f2055f4f7 100644
--- a/packages/core/src/layout/BottomLink/index.ts
+++ b/packages/core/src/layout/BottomLink/index.ts
@@ -14,4 +14,5 @@
* limitations under the License.
*/
-export { default, Props } from './BottomLink';
+export { default } from './BottomLink';
+export type { Props } from './BottomLink';
diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx
new file mode 100644
index 0000000000..1309fdf474
--- /dev/null
+++ b/packages/core/src/layout/ErrorPage/ErrorPage.test.tsx
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import ErrorPage from './ErrorPage';
+import { wrapInThemedTestApp } from '@backstage/test-utils';
+
+describe('', () => {
+ it('should render with status code, status message and go back link', () => {
+ const rendered = render(
+ wrapInThemedTestApp(
+ {} }}
+ />,
+ ),
+ );
+ rendered.getByText(/page not found/i);
+ rendered.getByText(/404/i);
+ rendered.getByText(/Looks like someone dropped the mic!/i);
+ expect(rendered.getByTestId('go-back-link')).toBeDefined();
+ });
+});
diff --git a/packages/core/src/layout/ErrorPage/ErrorPage.tsx b/packages/core/src/layout/ErrorPage/ErrorPage.tsx
new file mode 100644
index 0000000000..49b79ef925
--- /dev/null
+++ b/packages/core/src/layout/ErrorPage/ErrorPage.tsx
@@ -0,0 +1,68 @@
+/*
+ * 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 { Typography, Link, Grid } from '@material-ui/core';
+import { makeStyles } from '@material-ui/core/styles';
+import { BackstageTheme } from '@backstage/theme';
+import MicDrop from './MicDrop';
+
+interface IErrorPageProps {
+ status: string;
+ statusMessage: string;
+ history: {
+ goBack: () => void;
+ };
+}
+
+const useStyles = makeStyles((theme) => ({
+ container: {
+ padding: theme.spacing(8),
+ },
+ title: {
+ paddingBottom: theme.spacing(5),
+ },
+ subtitle: {
+ color: theme.palette.textSubtle,
+ },
+}));
+
+const ErrorPage = ({ status, statusMessage, history }: IErrorPageProps) => {
+ const classes = useStyles();
+
+ return (
+
+
+
+
+ ERROR {status}: {statusMessage}
+
+
+ Looks like someone dropped the mic!
+
+
+
+ Go back
+
+ ... or if you think this is a bug, please file an{' '}
+ issue.
+
+
+
+ );
+};
+
+export default ErrorPage;
diff --git a/packages/core/src/layout/ErrorPage/MicDrop.js b/packages/core/src/layout/ErrorPage/MicDrop.js
new file mode 100644
index 0000000000..f951830574
--- /dev/null
+++ b/packages/core/src/layout/ErrorPage/MicDrop.js
@@ -0,0 +1,162 @@
+/*
+ * 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 { makeStyles } from '@material-ui/core';
+
+const useStyles = makeStyles({
+ micDrop: {
+ maxWidth: '60%',
+ bottom: 10,
+ right: 10,
+ position: 'absolute',
+ },
+});
+
+const MicDrop = () => {
+ const classes = useStyles();
+ return (
+
+ );
+};
+
+export default MicDrop;
diff --git a/packages/core/src/components/DefaultWidgetView/index.ts b/packages/core/src/layout/ErrorPage/index.ts
similarity index 92%
rename from packages/core/src/components/DefaultWidgetView/index.ts
rename to packages/core/src/layout/ErrorPage/index.ts
index b4aaece4cb..872e5cce2d 100644
--- a/packages/core/src/components/DefaultWidgetView/index.ts
+++ b/packages/core/src/layout/ErrorPage/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { default } from './DefaultWidgetView';
+export { default } from './ErrorPage';
diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx
index d645b25aaa..7077167828 100644
--- a/packages/core/src/layout/InfoCard/InfoCard.tsx
+++ b/packages/core/src/layout/InfoCard/InfoCard.tsx
@@ -27,18 +27,18 @@ import {
import ErrorBoundary from 'layout/ErrorBoundary/ErrorBoundary';
import BottomLink, { Props as BottomLinkProps } from '../BottomLink';
-const useStyles = makeStyles(theme => ({
+const useStyles = makeStyles((theme) => ({
header: {
padding: theme.spacing(2, 2, 2, 2.5),
},
}));
-const BoldHeader = withStyles(theme => ({
+const BoldHeader = withStyles((theme) => ({
title: { fontWeight: 700 },
subheader: { paddingTop: theme.spacing(1) },
}))(CardHeader);
-const CardActionsTopRight = withStyles(theme => ({
+const CardActionsTopRight = withStyles((theme) => ({
root: {
display: 'inline-block',
padding: theme.spacing(8, 8, 0, 0),
@@ -52,9 +52,6 @@ const VARIANT_STYLES = {
display: 'flex',
flexDirection: 'column',
},
- widget: {
- height: 430,
- },
fullHeight: {
height: '100%',
},
@@ -79,11 +76,6 @@ const VARIANT_STYLES = {
},
},
cardContent: {
- widget: {
- overflowY: 'auto',
- height: 332,
- width: '100%',
- },
fullHeight: {
height: 'calc(100% - 50px)',
},
@@ -162,7 +154,7 @@ const InfoCard: FC = ({
if (variant) {
const variants = variant.split(/[\s]+/g);
- variants.forEach(name => {
+ variants.forEach((name) => {
calculatedStyle = {
...calculatedStyle,
...VARIANT_STYLES.card[name as keyof typeof VARIANT_STYLES['card']],
diff --git a/packages/core/src/layout/Page/index.ts b/packages/core/src/layout/Page/index.ts
index bb7253bb95..50899fa036 100644
--- a/packages/core/src/layout/Page/index.ts
+++ b/packages/core/src/layout/Page/index.ts
@@ -15,4 +15,5 @@
*/
export { default } from './Page';
-export { gradients, pageTheme, PageTheme } from './PageThemeProvider';
+export { gradients, pageTheme } from './PageThemeProvider';
+export type { PageTheme } from './PageThemeProvider';
diff --git a/packages/dev-utils/package.json b/packages/dev-utils/package.json
index 2a241f6a9a..bafbab2e32 100644
--- a/packages/dev-utils/package.json
+++ b/packages/dev-utils/package.json
@@ -16,7 +16,7 @@
"backstage"
],
"license": "Apache-2.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
diff --git a/packages/storybook/.storybook/config.js b/packages/storybook/.storybook/config.js
index fab204fd9f..941b02718b 100644
--- a/packages/storybook/.storybook/config.js
+++ b/packages/storybook/.storybook/config.js
@@ -1,14 +1,21 @@
import React from 'react';
-import { addDecorator } from '@storybook/react';
+import { addDecorator, addParameters } from '@storybook/react';
import { lightTheme, darkTheme } from '@backstage/theme';
import { CssBaseline, ThemeProvider } from '@material-ui/core';
import { useDarkMode } from 'storybook-dark-mode';
import { Content } from '@backstage/core';
-addDecorator(story => (
+addDecorator((story) => (
{story()}
));
+
+addParameters({
+ darkMode: {
+ // Set the initial theme
+ current: 'light',
+ },
+});
diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js
index 56c00d533f..12b9c34435 100644
--- a/packages/storybook/.storybook/main.js
+++ b/packages/storybook/.storybook/main.js
@@ -1,4 +1,5 @@
const path = require('path');
+const WebpackPluginFailBuildOnWarning = require('./webpack-plugin-fail-build-on-warning');
module.exports = {
stories: [
@@ -11,37 +12,52 @@ module.exports = {
'@storybook/addon-storysource',
'storybook-dark-mode/register',
],
- webpackFinal: async config => {
+ 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'),
};
- config.resolve.modules.push(path.resolve(__dirname, '../../core/src'));
- config.module.rules.push(
- {
- test: /\.(ts|tsx)$/,
- use: [
- {
- loader: require.resolve('ts-loader'),
- options: {
- transpileOnly: true,
- },
- },
- ],
- },
- {
- test: /\.(js|jsx)$/,
- loader: 'babel-loader',
- options: {
- presets: ['@babel/preset-react'],
- plugins: ['@babel/plugin-proposal-class-properties'],
- },
- },
- );
+ config.resolve.modules.push(coreSrc);
+
+ // Remove the default babel-loader for js files, we're using ts-loader instead
+ const [jsLoader] = config.module.rules.splice(0, 1);
+ if (jsLoader.use[0].loader !== 'babel-loader') {
+ throw new Error(
+ `Unexpected loader removed from storybook config, ${jsonLoader.use[0].loader}`,
+ );
+ }
+
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,
+ },
+ },
+ ],
+ });
+
// Disable ProgressPlugin which logs verbose webpack build progress. Warnings and Errors are still logged.
- config.plugins = config.plugins.filter(({ constructor }) => constructor.name !== "ProgressPlugin")
+ config.plugins = config.plugins.filter(
+ ({ constructor }) => constructor.name !== 'ProgressPlugin',
+ );
+
+ // Fail storybook build on CI if there are webpack warnings.
+ if (process.env.CI) {
+ config.plugins.push(new WebpackPluginFailBuildOnWarning())
+ }
return config;
},
diff --git a/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js b/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js
new file mode 100644
index 0000000000..1ccf825839
--- /dev/null
+++ b/packages/storybook/.storybook/webpack-plugin-fail-build-on-warning.js
@@ -0,0 +1,68 @@
+/*
+ * 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.
+ */
+
+/**
+ * When building storybook, we can have warnings which may cause issues in the future. One of the example case is
+ * https://github.com/spotify/backstage/issues/718. To make sure new warnings are not introduced with new PRs, we
+ * want to fail CI builds if there are warnings when building storybook.
+ *
+ * This webpack plugin makes sure the CI builds fail on Webpack warnings. We also have a whitelist of warnings here
+ * which we think are non-critical.
+ *
+ * Note that this implementation will not detect other warnings emitted by storybook build that are separate from
+ * Webpack. A better solution over this plugin should be preferred, possibly on Storybook level (CLI options etc.)
+ *
+ * The case with #718 is caused because we are using `ts-loader` for `webpack` to load all our JS/TS files, but we
+ * have disabled type checking during build. This is done by setting `transpileOnly` to `true` in storybook/main.js
+ * and it improves the Storybook build speed. Because of this, Webpack emits warnings when we try to re-export types.
+ * Reference: https://github.com/TypeStrong/ts-loader#transpileonly
+ */
+class WebpackPluginFailBuildOnWarning {
+ // Ignore the following warnings in the Webpack build.
+ warningsWhitelist = new Set([
+ 'AssetsOverSizeLimitWarning',
+ 'EntrypointsOverSizeLimitWarning',
+ 'NoAsyncChunksWarning',
+ ]);
+
+ /* Entry point for the Webpack plugin. */
+ apply(compiler) {
+ // Invoke plugin logic when Webpack build is 'done'.
+ compiler.hooks.done.tap('FailBuildOnWarning', this.execute.bind(this));
+ }
+
+ execute(stats) {
+ // All the compilation warnings are stored in stats.compilation.warnings
+ let warnings = stats.compilation.warnings;
+ if (warnings.length > 0) {
+ // Throw error if there are unexpected warnings.
+ for (let warning of warnings) {
+ if (!this.warningsWhitelist.has(warning.name)) {
+ process.on('beforeExit', () => {
+ console.log(
+ `You have some unexpected warning(s) in your webpack build. Exiting process as error.`,
+ );
+ process.exit(1);
+ });
+ // No need to go over the rest of warnings from here.
+ break;
+ }
+ }
+ }
+ }
+}
+
+module.exports = WebpackPluginFailBuildOnWarning;
diff --git a/packages/storybook/tsconfig.json b/packages/storybook/tsconfig.json
new file mode 100644
index 0000000000..87132f9b4f
--- /dev/null
+++ b/packages/storybook/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": [".storybook/**/*"],
+ "compilerOptions": {}
+}
diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json
index 1b70f4132a..33c27dba62 100644
--- a/packages/test-utils-core/package.json
+++ b/packages/test-utils-core/package.json
@@ -16,12 +16,13 @@
"backstage"
],
"license": "Apache-2.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
"lint": "backstage-cli lint",
- "test": "backstage-cli test"
+ "test": "backstage-cli test",
+ "clean": "backstage-cli clean"
},
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json
index 5326cc2e07..e34a750ae0 100644
--- a/packages/test-utils/package.json
+++ b/packages/test-utils/package.json
@@ -16,7 +16,7 @@
"backstage"
],
"license": "Apache-2.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
diff --git a/packages/theme/.eslintrc.js b/packages/theme/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/packages/theme/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/packages/theme/package.json b/packages/theme/package.json
index 0a30f884e0..c3f7f6c57b 100644
--- a/packages/theme/package.json
+++ b/packages/theme/package.json
@@ -16,7 +16,7 @@
"backstage"
],
"license": "Apache-2.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts
index 3dad461423..9e9e2945fa 100644
--- a/packages/theme/src/themes.ts
+++ b/packages/theme/src/themes.ts
@@ -28,7 +28,7 @@ export const lightTheme = createTheme({
error: '#CA001B',
running: '#BEBEBE',
pending: '#5BC0DE',
- background: '#FEFEFE',
+ background: '#F8F8F8',
},
bursts: {
fontColor: '#FEFEFE',
@@ -68,7 +68,7 @@ export const darkTheme = createTheme({
error: '#CA001B',
running: '#BEBEBE',
pending: '#5BC0DE',
- background: '#FEFEFE',
+ background: '#282828',
},
bursts: {
fontColor: '#FEFEFE',
diff --git a/plugins/explore/.eslintrc.js b/plugins/explore/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/explore/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/explore/README.md b/plugins/explore/README.md
new file mode 100644
index 0000000000..12a4e58380
--- /dev/null
+++ b/plugins/explore/README.md
@@ -0,0 +1,6 @@
+# Title
+Welcome to the explore plugin!
+
+## Sub-section 1
+
+## Sub-section 2
diff --git a/plugins/explore/package.json b/plugins/explore/package.json
new file mode 100644
index 0000000000..fb13381321
--- /dev/null
+++ b/plugins/explore/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@backstage/plugin-explore",
+ "version": "0.1.1-alpha.4",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts",
+ "license": "Apache-2.0",
+ "private": true,
+ "scripts": {
+ "build": "backstage-cli plugin:build",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "clean": "backstage-cli clean"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.4",
+ "@backstage/test-utils": "^0.1.1-alpha.4",
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@testing-library/user-event": "^7.1.2",
+ "@types/jest": "^24.0.0",
+ "@types/node": "^12.0.0",
+ "@types/testing-library__jest-dom": "5.0.2",
+ "jest-fetch-mock": "^3.0.3"
+ },
+ "dependencies": {
+ "@backstage/core": "^0.1.1-alpha.4",
+ "@backstage/theme": "^0.1.1-alpha.4",
+ "@material-ui/core": "^4.9.1",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.45",
+ "classnames": "^2.2.6",
+ "react": "16.13.1",
+ "react-dom": "16.13.1",
+ "react-use": "^13.0.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/explore/src/components/ExploreCard.test.js b/plugins/explore/src/components/ExploreCard.test.js
new file mode 100644
index 0000000000..fde36b529d
--- /dev/null
+++ b/plugins/explore/src/components/ExploreCard.test.js
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+import { render } from '@testing-library/react';
+import { wrapInThemedTestApp } from '@backstage/test-utils';
+
+import ExploreCard from './ExploreCard';
+
+const minProps = {
+ card: {
+ title: 'Title',
+ description: 'Something something',
+ url: 'http://spotify.com/',
+ image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
+ tags: ['tag1', 'tag2'],
+ },
+};
+
+describe('', () => {
+ it('renders without exploding', () => {
+ const { getByText } = render(
+ wrapInThemedTestApp(),
+ );
+ expect(getByText('Explore')).toBeInTheDocument();
+ });
+
+ it('renders props correctly', () => {
+ const { getByText } = render(
+ wrapInThemedTestApp(),
+ );
+ expect(getByText(minProps.card.title)).toBeInTheDocument();
+ expect(getByText(minProps.card.description)).toBeInTheDocument();
+ });
+
+ it('should link out', () => {
+ const rendered = render(wrapInThemedTestApp());
+ const anchor = rendered.container.querySelector('a');
+ expect(anchor.href).toBe(minProps.card.url);
+ });
+
+ it('renders default description when missing', () => {
+ const propsWithoutDescription = {
+ card: {
+ card: {
+ title: 'Title',
+ url: 'http://spotify.com/',
+ image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
+ },
+ },
+ };
+ const { getByText } = render(
+ wrapInThemedTestApp(),
+ );
+ expect(getByText('Description missing')).toBeInTheDocument();
+ });
+
+ it('renders lifecycle correctly', () => {
+ const propsWithLifecycle = {
+ card: {
+ title: 'Title',
+ url: 'http://spotify.com/',
+ image: 'https://developer.spotify.com/assets/WebAPI_intro.png',
+ lifecycle: 'GA',
+ },
+ };
+ const { queryByText } = render(
+ wrapInThemedTestApp(),
+ );
+ expect(queryByText('GA')).not.toBeInTheDocument();
+ });
+
+ it('renders tags correctly', () => {
+ const { getByText } = render(
+ wrapInThemedTestApp(),
+ );
+ expect(getByText(minProps.card.tags[0])).toBeInTheDocument();
+ expect(getByText(minProps.card.tags[1])).toBeInTheDocument();
+ });
+});
diff --git a/plugins/explore/src/components/ExploreCard.tsx b/plugins/explore/src/components/ExploreCard.tsx
new file mode 100644
index 0000000000..af6eca61b7
--- /dev/null
+++ b/plugins/explore/src/components/ExploreCard.tsx
@@ -0,0 +1,134 @@
+/*
+ * 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 classNames from 'classnames';
+import {
+ Button,
+ Card,
+ CardActions,
+ CardContent,
+ CardMedia,
+ Chip,
+ Typography,
+ makeStyles,
+} from '@material-ui/core';
+import { BackstageTheme } from '@backstage/theme';
+
+const useStyles = makeStyles((theme) => ({
+ card: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ cardActions: {
+ flexGrow: 1,
+ alignItems: 'flex-end',
+ },
+ media: {
+ height: 128,
+ },
+ mediaContain: {
+ backgroundSize: 'contain',
+ },
+ lifecycle: {
+ lineHeight: '0.8em',
+ color: 'white',
+ },
+ ga: {
+ backgroundColor: theme.palette.status.ok,
+ },
+ alpha: {
+ backgroundColor: theme.palette.status.error,
+ },
+ beta: {
+ backgroundColor: theme.palette.status.warning,
+ },
+ domains: {
+ position: 'relative',
+ top: theme.spacing(2),
+ },
+ spaceBetween: {
+ justifyContent: 'space-between',
+ },
+}));
+
+export type CardData = {
+ title: string;
+ description: string;
+ url: string;
+ image: string;
+ tags?: string[];
+ lifecycle?: string;
+ newsTag?: string;
+};
+
+type Props = {
+ card: CardData;
+ objectFit?: 'cover' | 'contain';
+};
+
+const ExploreCard: FC = ({ card, objectFit }) => {
+ const classes = useStyles();
+
+ const { title, description, url, image, lifecycle, newsTag, tags } = card;
+
+ return (
+
+
+
+
+ {title}{' '}
+ {lifecycle && lifecycle.toLowerCase() !== 'ga' && (
+
+ )}
+
+
+ {description || 'Description missing'}
+
+ {tags && (
+
+ {tags.map((item, idx) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default ExploreCard;
diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx
new file mode 100644
index 0000000000..48a0f90c32
--- /dev/null
+++ b/plugins/explore/src/components/ExplorePluginPage.tsx
@@ -0,0 +1,93 @@
+/*
+ * 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 { makeStyles, Typography } from '@material-ui/core';
+import {
+ Content,
+ ContentHeader,
+ Header,
+ Page,
+ pageTheme,
+ SupportButton,
+} from '@backstage/core';
+import ExploreCard, { CardData } from './ExploreCard';
+import { BackstageTheme } from '@backstage/theme';
+
+const useStyles = makeStyles((theme) => ({
+ container: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fill, 296px)',
+ gridGap: theme.spacing(3),
+ marginBottom: theme.spacing(6),
+ },
+}));
+
+const toolsCards = [
+ {
+ title: 'Lighthouse',
+ description:
+ "Google's Lighthouse tool is a great resource for benchmarking and improving the accessibility, performance, SEO, and best practices of your website.",
+ url: '/lighthouse',
+ image:
+ 'https://raw.githubusercontent.com/GoogleChrome/lighthouse/8b3d7f052b2e64dd857e741d7395647f487697e7/assets/lighthouse-logo.png',
+ tags: ['web', 'seo', 'accessibility', 'performance'],
+ },
+ {
+ title: 'Tech Radar',
+ description:
+ 'Tech Radar is a list of technologies, complemented by an assessment result, called ring assignment.',
+ url: '/tech-radar',
+ image:
+ 'https://storage.googleapis.com/wf-blogs-engineering-media/2018/09/fe13bb32-wf-tech-radar-hero-1024x597.png',
+ tags: ['standards', 'landscape'],
+ },
+ {
+ title: 'GraphiQL',
+ description:
+ 'Integrates GraphiQL as a tool to browse GraphiQL endpoints inside Backstage.',
+ url: 'graphiql',
+ image:
+ 'https://camo.githubusercontent.com/517398c3fbe0687d3d4dcbe05da82970b882e75a/68747470733a2f2f64337676366c703535716a6171632e636c6f756466726f6e742e6e65742f6974656d732f33413061324e314c3346324f304c3377326e316a2f477261706869514c382e706e673f582d436c6f75644170702d56697369746f722d49643d3433363432',
+ tags: ['graphql', 'dev'],
+ },
+];
+
+const ExplorePluginPage: FC<{}> = () => {
+ const classes = useStyles();
+ return (
+
+
+
+
+
+ Explore tools available in Backstage
+
+
+
+
+
+ );
+};
+
+export default ExplorePluginPage;
diff --git a/packages/core/src/components/Sequence/index.ts b/plugins/explore/src/index.ts
similarity index 92%
rename from packages/core/src/components/Sequence/index.ts
rename to plugins/explore/src/index.ts
index 4391c987b5..3a0a0fe2d3 100644
--- a/packages/core/src/components/Sequence/index.ts
+++ b/plugins/explore/src/index.ts
@@ -14,4 +14,4 @@
* limitations under the License.
*/
-export { default, StepType } from './Sequence';
+export { plugin } from './plugin';
diff --git a/packages/backend/src/test/index.ts b/plugins/explore/src/plugin.test.ts
similarity index 80%
rename from packages/backend/src/test/index.ts
rename to plugins/explore/src/plugin.test.ts
index ce3f9f8c39..d6503c038b 100644
--- a/packages/backend/src/test/index.ts
+++ b/plugins/explore/src/plugin.test.ts
@@ -14,10 +14,10 @@
* limitations under the License.
*/
-import { Router } from 'express';
+import { plugin } from './plugin';
-export const testRouter = Router();
-
-testRouter.get('/', async (_, res) => {
- res.status(200).send('hello');
+describe('explore', () => {
+ it('should export plugin', () => {
+ expect(plugin).toBeDefined();
+ });
});
diff --git a/plugins/explore/src/plugin.ts b/plugins/explore/src/plugin.ts
new file mode 100644
index 0000000000..66e48a9b15
--- /dev/null
+++ b/plugins/explore/src/plugin.ts
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createPlugin } from '@backstage/core';
+import ExplorePluginPage from './components/ExplorePluginPage';
+
+export const plugin = createPlugin({
+ id: 'explore',
+ register({ router }) {
+ router.registerRoute('/explore', ExplorePluginPage);
+ },
+});
diff --git a/plugins/explore/src/setupTests.ts b/plugins/explore/src/setupTests.ts
new file mode 100644
index 0000000000..1a907ab8e6
--- /dev/null
+++ b/plugins/explore/src/setupTests.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+require('jest-fetch-mock').enableMocks();
diff --git a/plugins/explore/tsconfig.json b/plugins/explore/tsconfig.json
new file mode 100644
index 0000000000..7b73db2f0f
--- /dev/null
+++ b/plugins/explore/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "baseUrl": "src"
+ }
+}
diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json
index 2db84d275a..086c639bc1 100644
--- a/plugins/graphiql/package.json
+++ b/plugins/graphiql/package.json
@@ -16,7 +16,7 @@
"backstage"
],
"license": "Apache-2.0",
- "main": "dist/index.cjs.js",
+ "main": "dist/index.esm.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "backstage-cli plugin:build",
diff --git a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx
index e914fd9649..5885183b01 100644
--- a/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx
+++ b/plugins/graphiql/src/components/GraphiQLBrowser/GraphiQLBrowser.tsx
@@ -14,15 +14,17 @@
* limitations under the License.
*/
-import React, { FC, useState } from 'react';
+import React, { FC, useState, Suspense } from 'react';
import { Tabs, Tab, makeStyles, Typography, Divider } from '@material-ui/core';
import 'graphiql/graphiql.css';
-import GraphiQL from 'graphiql';
import { StorageBucket } from 'lib/storage';
import { GraphQLEndpoint } from 'lib/api';
+import { Progress } from '@backstage/core';
import { BackstageTheme } from '@backstage/theme';
-const useStyles = makeStyles(theme => ({
+const GraphiQL = React.lazy(() => import('graphiql'));
+
+const useStyles = makeStyles((theme) => ({
root: {
height: '100%',
display: 'flex',
@@ -58,20 +60,22 @@ export const GraphiQLBrowser: FC = ({ endpoints }) => {
return (
);
};
diff --git a/plugins/lighthouse/src/components/CreateAudit/index.tsx b/plugins/lighthouse/src/components/CreateAudit/index.tsx
index 8ff0ce5ea8..19db33791b 100644
--- a/plugins/lighthouse/src/components/CreateAudit/index.tsx
+++ b/plugins/lighthouse/src/components/CreateAudit/index.tsx
@@ -84,7 +84,14 @@ const CreateAudit: FC<{}> = () => {
} finally {
setSubmitting(false);
}
- }, [url, emulatedFormFactor, lighthouseApi, setSubmitting, errorApi, history]);
+ }, [
+ url,
+ emulatedFormFactor,
+ lighthouseApi,
+ setSubmitting,
+ errorApi,
+ history,
+ ]);
return (
diff --git a/plugins/lighthouse/src/utils.ts b/plugins/lighthouse/src/utils.ts
index 1546521083..cc09b52f4f 100644
--- a/plugins/lighthouse/src/utils.ts
+++ b/plugins/lighthouse/src/utils.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useLocation } from 'react-router-dom';
-import {Website, Audit, LighthouseCategoryId, AuditCompleted} from './api'
+import { Website, Audit, LighthouseCategoryId, AuditCompleted } from './api';
export function useQuery(): URLSearchParams {
return new URLSearchParams(useLocation().search);
}
@@ -45,7 +45,9 @@ export const CATEGORY_LABELS: Record = {
};
export type SparklinesDataByCategory = Record;
-export function buildSparklinesDataForItem(item: Website): SparklinesDataByCategory {
+export function buildSparklinesDataForItem(
+ item: Website,
+): SparklinesDataByCategory {
return item.audits
.filter(
(audit: Audit): audit is AuditCompleted => audit.status === 'COMPLETED',
@@ -63,4 +65,4 @@ export function buildSparklinesDataForItem(item: Website): SparklinesDataByCateg
return scores;
}, {} as SparklinesDataByCategory);
-}
\ No newline at end of file
+}
diff --git a/plugins/scaffolder-backend/.eslintrc.js b/plugins/scaffolder-backend/.eslintrc.js
new file mode 100644
index 0000000000..19c9ad7395
--- /dev/null
+++ b/plugins/scaffolder-backend/.eslintrc.js
@@ -0,0 +1,8 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint.backend')],
+ ignorePatterns: ['sample-templates/'],
+ rules: {
+ 'no-console': 0, // Permitted in console programs
+ 'new-cap': ['error', { capIsNew: false }], // Because Express constructs things e.g. like 'const r = express.Router()'
+ },
+};
diff --git a/plugins/scaffolder-backend/README.md b/plugins/scaffolder-backend/README.md
new file mode 100644
index 0000000000..7ffbeae1d1
--- /dev/null
+++ b/plugins/scaffolder-backend/README.md
@@ -0,0 +1,6 @@
+# Title
+Welcome to the scaffolder plugin!
+
+## Sub-section 1
+
+## Sub-section 2
diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json
new file mode 100644
index 0000000000..ce1161a208
--- /dev/null
+++ b/plugins/scaffolder-backend/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "@backstage/plugin-scaffolder-backend",
+ "version": "0.1.1-alpha.4",
+ "main": "dist",
+ "license": "Apache-2.0",
+ "private": true,
+ "scripts": {
+ "build": "tsc",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "clean": "backstage-cli clean"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.4",
+ "@types/fs-extra": "^8.1.0",
+ "@types/supertest": "^2.0.8",
+ "supertest": "^4.0.2"
+ },
+ "dependencies": {
+ "@backstage/backend-common": "0.1.1-alpha.4",
+ "dockerode": "^3.2.0",
+ "express": "^4.17.1",
+ "fs-extra": "^9.0.0",
+ "globby": "^11.0.0",
+ "compression": "^1.7.4",
+ "cors": "^2.8.5",
+ "express-promise-router": "^3.0.3",
+ "helmet": "^3.22.0",
+ "morgan": "^1.10.0",
+ "winston": "^3.2.1"
+ }
+}
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/hooks/post_gen_project.sh b/plugins/scaffolder-backend/sample-templates/react-ssr-template/hooks/post_gen_project.sh
new file mode 100644
index 0000000000..c6d477d91a
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/hooks/post_gen_project.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+
+# package name is "__component_id__" so that yarn doesn't throw an error
+# about invalid characters when running yarn commands. here we replace it with the actual name
+sed -i -e "s/__component_id__/{{ cookiecutter.component_id }}/g" package.json
+
+# node_modules was moved out of the template folder, during the pre_gen hook,
+# to avoid cookie_cutter from copying all of them. time to move it back
+mv ../../node_modules.tmp ../../\{\{cookiecutter.component_id\}\}/node_modules 2>/dev/null ||:
+
+# move back the build directory that was moved out in the pre_gen hook (if it exists)
+mv ../../build.tmp ../../\{\{cookiecutter.component_id\}\}/build 2>/dev/null ||:
\ No newline at end of file
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/hooks/pre_gen_project.sh b/plugins/scaffolder-backend/sample-templates/react-ssr-template/hooks/pre_gen_project.sh
new file mode 100644
index 0000000000..142fda9ad9
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/hooks/pre_gen_project.sh
@@ -0,0 +1,9 @@
+#!/bin/bash
+
+# no way to ignore files in cookiecutter, so move node_modules out while building
+# to avoid cookiecutter from copying all of them
+mv ../../\{\{cookiecutter.component_id\}\}/node_modules ../../node_modules.tmp 2>/dev/null ||:
+
+# cookicutter really doesn't like the next.js build directory, so if the app has
+# been built from inside the template folder, that folders needs to be moved out as well
+mv ../../\{\{cookiecutter.component_id\}\}/build ../../build.tmp 2>/dev/null ||:
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/template-info.json b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template-info.json
new file mode 100644
index 0000000000..0a03fd95d6
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/template-info.json
@@ -0,0 +1,6 @@
+{
+ "id": "react-ssr-template",
+ "name": "SSR React Website",
+ "description": "Next.js application skeleton for creating isomorphic web applications.",
+ "ownerId": "something"
+}
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.editorconfig b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.editorconfig
new file mode 100644
index 0000000000..23c3268a0f
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.editorconfig
@@ -0,0 +1,13 @@
+# editorconfig.org
+root = true
+
+[*]
+indent_style = space
+indent_size = 2
+end_of_line = lf
+charset = utf-8
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+[*.md]
+insert_final_newline = false
\ No newline at end of file
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.eslintignore b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.eslintignore
new file mode 100644
index 0000000000..8b13789179
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.eslintignore
@@ -0,0 +1 @@
+
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.eslintrc.js b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.eslintrc.js
new file mode 100644
index 0000000000..8623614899
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.eslintrc.js
@@ -0,0 +1 @@
+module.exports = require('@spotify/web-scripts/config/eslintrc.js');
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml
new file mode 100644
index 0000000000..9087876ce2
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.github/workflows/build.yml
@@ -0,0 +1,39 @@
+name: Frontend CI
+
+on:
+ push:
+ paths:
+ - '.'
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ strategy:
+ matrix:
+ node-version: [12.x]
+
+ steps:
+ - name: checkout code
+ uses: actions/checkout@v1
+ - name: get yarn cache
+ id: yarn-cache
+ run: echo "::set-output name=dir::$(yarn cache dir)"
+ - uses: actions/cache@v1
+ with:
+ path: ${{ steps.yarn-cache.outputs.dir }}
+ key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-yarn-
+ - name: use node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - name: yarn install, build, and test
+ working-directory: .
+ run: |
+ yarn install
+ yarn build --if-present
+ yarn test
+ env:
+ CI: true
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.gitignore b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.gitignore
new file mode 100644
index 0000000000..4d1e0d9635
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.gitignore
@@ -0,0 +1,18 @@
+# See https://help.github.com/ignore-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+
+# testing
+/coverage
+junit.xml
+
+# build
+/build
+
+# misc
+.DS_Store
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.nvmrc b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.nvmrc
new file mode 100644
index 0000000000..b009dfb9d9
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/.nvmrc
@@ -0,0 +1 @@
+lts/*
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/README.md b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/README.md
new file mode 100644
index 0000000000..415065179b
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/README.md
@@ -0,0 +1,14 @@
+# {{ cookiecutter.component_id }}
+
+## Description
+
+{{ cookiecutter.description }}
+
+## Develop
+
+```bash
+# install dependencies
+$ yarn
+# start development server
+$ yarn dev
+```
\ No newline at end of file
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/babel.config.js b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/babel.config.js
new file mode 100644
index 0000000000..db2d280677
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/babel.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ plugins: ['babel-plugin-styled-components'],
+ presets: ['next/babel', '@zeit/next-typescript/babel'],
+};
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/jest.config.js b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/jest.config.js
new file mode 100644
index 0000000000..0dc2a9c0ca
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/jest.config.js
@@ -0,0 +1,8 @@
+module.exports = {
+ ...require('@spotify/web-scripts/config/jest.config.js'),
+ testEnvironment: 'jsdom',
+ testPathIgnorePatterns: ['/node_modules/', '/build/'],
+ transform: {
+ '^.+\\.tsx?$': 'babel-jest',
+ },
+};
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/next-env.d.ts b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/next-env.d.ts
new file mode 100644
index 0000000000..fd10310f68
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/next-env.d.ts
@@ -0,0 +1,5 @@
+// read more about this file here ---> https://github.com/zeit/next.js/blob/canary/docs/basic-features/typescript.md
+/* eslint spaced-comment: ["error", "always", { "markers": ["/"] }] */
+
+///
+///
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/next.config.js b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/next.config.js
new file mode 100644
index 0000000000..be8b159307
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/next.config.js
@@ -0,0 +1,3 @@
+module.exports = {
+ distDir: 'build',
+};
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/package.json b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/package.json
new file mode 100644
index 0000000000..cf9d469e39
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/package.json
@@ -0,0 +1,52 @@
+{
+ "name": "__component_id__",
+ "version": "0.0.0",
+ "description": "{{ cookiecutter.description }}",
+ "license": "UNLICENSED",
+ "scripts": {
+ "dev": "next",
+ "build": "next build",
+ "start": "next start",
+ "lint": "web-scripts lint --ignore-path=.gitignore",
+ "test": "web-scripts test --config jest.config.js",
+ "pretest:ci": "yarn lint",
+ "test:ci": "yarn test --ci --coverage --reporters=default --reporters=jest-junit"
+ },
+ "dependencies": {
+ "@zeit/next-typescript": "^1.1.1",
+ "babel-plugin-styled-components": "^1.10.6",
+ "next": "^9.1.1",
+ "react": "^16.8.5",
+ "react-dom": "^16.8.5",
+ "styled-components": "^4.3.2"
+ },
+ "devDependencies": {
+ "@spotify/tsconfig": "^5.0.0",
+ "@spotify/web-scripts": "^5.0.0",
+ "@testing-library/react": "^8.0.1",
+ "@types/node": "^13.1.4",
+ "@types/react": "^16.8.7",
+ "@types/react-dom": "^16.8.2",
+ "@types/styled-components": "^4.1.18",
+ "husky": "^2.7.0",
+ "jest-junit": "^8.0.0",
+ "typescript": "^3.4.5"
+ },
+ "husky": {
+ "hooks": {
+ "pre-commit": "web-scripts precommit"
+ }
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/prettier.config.js b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/prettier.config.js
new file mode 100644
index 0000000000..93df970dd6
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/prettier.config.js
@@ -0,0 +1 @@
+module.exports = require('@spotify/web-scripts/config/prettier.config.js');
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/public/static/fonts.css b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/public/static/fonts.css
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/__tests__/index.test.tsx b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/__tests__/index.test.tsx
new file mode 100644
index 0000000000..8612ead08f
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/__tests__/index.test.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import { cleanup, render } from '@testing-library/react';
+
+import Index from '../pages/index';
+
+afterEach(cleanup);
+
+describe('Index', () => {
+ it('Says hello', () => {
+ const { queryByText } = render();
+ expect(queryByText('Hello!')).toBeTruthy();
+ });
+});
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/components/Header.tsx b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/components/Header.tsx
new file mode 100644
index 0000000000..f8ba94310a
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/components/Header.tsx
@@ -0,0 +1,3 @@
+import React from 'react';
+
+export const Header = () =>
Header
;
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/_app.tsx b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/_app.tsx
new file mode 100644
index 0000000000..a3853e47c9
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/_app.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import App from 'next/app';
+import Head from 'next/head';
+import styled from 'styled-components';
+import { Header } from '../components/Header';
+
+const StyledApp = styled.div`
+ > * {
+ padding-left: 16px;
+ padding-right: 16px;
+ }
+`;
+
+const Main = styled.div`
+ margin: 2em auto;
+ height: 85vh;
+`;
+
+class CustomApp extends App {
+ render() {
+ const { Component, pageProps } = this.props;
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+export default CustomApp;
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/_document.tsx b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/_document.tsx
new file mode 100644
index 0000000000..9b01a6a74c
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/_document.tsx
@@ -0,0 +1,41 @@
+/**
+ * This file extends the and injects the server side rendered styles into the
+ * By server-side rendering CSS we avoid visual changes in the layout while loading the JS.
+ *
+ * Taken from this example:
+ * https://github.com/zeit/next.js/tree/master/examples/with-styled-components
+ */
+
+import React from 'react';
+import Document, { DocumentContext, DocumentInitialProps } from 'next/document';
+import { ServerStyleSheet } from 'styled-components';
+
+export default class MyDocument extends Document {
+ static async getInitialProps(
+ ctx: DocumentContext,
+ ): Promise {
+ const sheet = new ServerStyleSheet();
+ const originalRenderPage = ctx.renderPage;
+
+ try {
+ ctx.renderPage = () =>
+ originalRenderPage({
+ enhanceApp: (App: any) => (props) =>
+ sheet.collectStyles(),
+ });
+
+ const initialProps = await Document.getInitialProps(ctx);
+ return {
+ ...initialProps,
+ styles: (
+ <>
+ {initialProps.styles}
+ {sheet.getStyleElement()}
+ >
+ ),
+ };
+ } finally {
+ sheet.seal();
+ }
+ }
+}
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/api/ping.ts b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/api/ping.ts
new file mode 100644
index 0000000000..8bc772c182
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/api/ping.ts
@@ -0,0 +1,5 @@
+import { NextApiRequest, NextApiResponse } from 'next';
+
+export default function handle(_: NextApiRequest, res: NextApiResponse) {
+ res.status(200).send('ok');
+}
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/index.tsx b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/index.tsx
new file mode 100644
index 0000000000..c01d844f9f
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/src/pages/index.tsx
@@ -0,0 +1,5 @@
+import React from 'react';
+
+const Index = () =>
Hello!
;
+
+export default Index;
diff --git a/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/tsconfig.json b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/tsconfig.json
new file mode 100644
index 0000000000..03508ff043
--- /dev/null
+++ b/plugins/scaffolder-backend/sample-templates/react-ssr-template/{{cookiecutter.componentId}}/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "@spotify/tsconfig",
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": false,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve"
+ },
+ "exclude": ["node_modules", "output/node_modules"],
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"]
+}
diff --git a/plugins/scaffolder-backend/src/index.ts b/plugins/scaffolder-backend/src/index.ts
new file mode 100644
index 0000000000..c461bfede6
--- /dev/null
+++ b/plugins/scaffolder-backend/src/index.ts
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export * from './scaffolder';
+export * from './service/router';
diff --git a/plugins/scaffolder-backend/src/run.ts b/plugins/scaffolder-backend/src/run.ts
new file mode 100644
index 0000000000..133aad163e
--- /dev/null
+++ b/plugins/scaffolder-backend/src/run.ts
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { getRootLogger } from '@backstage/backend-common';
+import { startStandaloneServer } from './service/standaloneServer';
+
+const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3004;
+const enableCors = process.env.PLUGIN_CORS
+ ? Boolean(process.env.PLUGIN_CORS)
+ : false;
+const logger = getRootLogger();
+
+startStandaloneServer({ port, enableCors, logger }).catch((err) => {
+ logger.error(err);
+ process.exit(1);
+});
+
+process.on('SIGINT', () => {
+ logger.info('CTRL+C pressed; exiting.');
+ process.exit(0);
+});
diff --git a/plugins/scaffolder-backend/src/scaffolder/index.ts b/plugins/scaffolder-backend/src/scaffolder/index.ts
new file mode 100644
index 0000000000..a665b32ead
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/index.ts
@@ -0,0 +1,20 @@
+/*
+ * 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 './storage';
+export * from './templater';
+
+export * from './storage/disk';
+export * from './templater/cookiecutter';
diff --git a/plugins/scaffolder-backend/src/scaffolder/storage/disk.test.ts b/plugins/scaffolder-backend/src/scaffolder/storage/disk.test.ts
new file mode 100644
index 0000000000..9cebb0d380
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/storage/disk.test.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { DiskStorage } from './disk';
+import * as path from 'path';
+
+describe('Disk Storage', () => {
+ it('should load a simple template from a simple directory', async () => {
+ const testTemplateDir = path.resolve(
+ __dirname,
+ '../../../test/mock-simple-template-dir',
+ );
+ const templateInfo = require(`${testTemplateDir}/mock-template/template-info.json`);
+
+ const repository = new DiskStorage({ directory: testTemplateDir });
+
+ await repository.reindex();
+
+ const templates = await repository.list();
+
+ expect(templates).toHaveLength(1);
+ expect(templates[0].id).toBe(templateInfo.id);
+ expect(templates[0].name).toBe(templateInfo.name);
+ expect(templates[0].description).toBe(templateInfo.description);
+ expect(templates[0].ownerId).toBe(templateInfo.ownerId);
+ });
+
+ it('should successfully load multiple templates from the same folder', async () => {
+ const testTemplateDir = path.resolve(
+ __dirname,
+ '../../../test/mock-multiple-templates-dir',
+ );
+
+ const repository = new DiskStorage({ directory: testTemplateDir });
+
+ await repository.reindex();
+
+ const templates = await repository.list();
+
+ expect(templates).toHaveLength(2);
+ });
+
+ it('should return empty array when there are no templates', async () => {
+ const testTemplateDir = path.resolve(
+ __dirname,
+ '/some-folder-that-deffo-does-not-exist',
+ );
+
+ const repository = new DiskStorage({ directory: testTemplateDir });
+
+ await repository.reindex();
+
+ const templates = await repository.list();
+
+ expect(templates).toHaveLength(0);
+ });
+
+ it('should be able to handle templates with invalid json and ignore them from the returned array', async () => {
+ const testTemplateDir = path.resolve(
+ __dirname,
+ '../../../test/mock-failing-template-dir',
+ );
+
+ const repository = new DiskStorage({ directory: testTemplateDir });
+
+ await repository.reindex();
+
+ const templates = await repository.list();
+
+ expect(templates).toHaveLength(1);
+ });
+});
diff --git a/plugins/scaffolder-backend/src/scaffolder/storage/disk.ts b/plugins/scaffolder-backend/src/scaffolder/storage/disk.ts
new file mode 100644
index 0000000000..28236cb86f
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/storage/disk.ts
@@ -0,0 +1,99 @@
+/*
+ * 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 globby from 'globby';
+import fs from 'fs-extra';
+import { Template, StorageBase as Base } from '.';
+import { Logger } from 'winston';
+
+interface DiskIndexEntry {
+ contents: Template;
+ location: string;
+}
+
+export class DiskStorage implements Base {
+ private repository: Template[] = [];
+ private localIndex: DiskIndexEntry[] = [];
+
+ private repoDir: string;
+ private logger?: Logger;
+ constructor({
+ directory = `${__dirname}/../../../sample-templates`,
+ logger,
+ }: {
+ directory?: string;
+ logger?: Logger;
+ }) {
+ this.repoDir = directory;
+ this.logger = logger;
+ }
+
+ public async list(): Promise {
+ if (this.repository.length === 0) {
+ await this.reindex();
+ }
+
+ return this.repository;
+ }
+
+ public async reindex(): Promise {
+ this.localIndex = await this.index();
+ this.repository = this.localIndex.map(({ contents }) => contents);
+ }
+
+ public async prepare(templateId: string): Promise {
+ const template = this.localIndex.find(
+ ({ contents }) => contents.id === templateId,
+ );
+
+ if (!template) {
+ throw new Error('Template no found');
+ }
+
+ const tempDir = await fs.promises.mkdtemp(templateId);
+ await fs.copy(template.location, tempDir);
+ return tempDir;
+ }
+
+ private async index(): Promise {
+ const matches = await globby(`${this.repoDir}/**/template-info.json`);
+
+ const fileContents: Array<{
+ location: string;
+ contents: string;
+ }> = await Promise.all(
+ matches.map(async (location: string) => ({
+ location,
+ contents: await fs.readFile(location, 'utf-8'),
+ })),
+ );
+
+ const validFiles: DiskIndexEntry[] = [];
+
+ for (const file of fileContents) {
+ try {
+ const contents: Template = JSON.parse(file.contents);
+ validFiles.push({ location: file.location, contents });
+ } catch (ex) {
+ this.logger?.error('Failure parsing JSON for template', {
+ path: file.location,
+ });
+ }
+ }
+
+ return validFiles;
+ }
+}
diff --git a/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts b/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts
new file mode 100644
index 0000000000..fa3967f30a
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/storage/index.test.ts
@@ -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 { StorageBase, createStorage } from '.';
+import winston from 'winston';
+
+describe('Storage Interface Test', () => {
+ const mockStore = new (class MockStorage implements StorageBase {
+ list = jest.fn();
+ prepare = jest.fn();
+ reindex = jest.fn();
+
+ public reset = () => {
+ this.list.mockReset();
+ this.prepare.mockReset();
+ this.reindex.mockReset();
+ };
+ })();
+
+ const logger = winston.createLogger();
+
+ afterEach(() => mockStore.reset());
+
+ it('should call list of the set repo when calling list', async () => {
+ const store = createStorage({ store: mockStore, logger });
+ await store.list();
+
+ expect(mockStore.list).toHaveBeenCalled();
+ });
+
+ it('should reindex on the repo when calling reindex', async () => {
+ const store = createStorage({ store: mockStore, logger });
+
+ await store.reindex();
+
+ expect(mockStore.reindex).toHaveBeenCalled();
+ });
+
+ it('should call prepare with the correct id when calling prepare', async () => {
+ const store = createStorage({ store: mockStore, logger });
+
+ await store.prepare('testid');
+
+ expect(mockStore.prepare).toHaveBeenCalledWith('testid');
+ });
+});
diff --git a/plugins/scaffolder-backend/src/scaffolder/storage/index.ts b/plugins/scaffolder-backend/src/scaffolder/storage/index.ts
new file mode 100644
index 0000000000..4d16224ef7
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/storage/index.ts
@@ -0,0 +1,53 @@
+import { Logger } from 'winston';
+
+/*
+ * 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 interface Template {
+ id: string;
+ name: string;
+ description: string;
+ ownerId: string;
+}
+
+export abstract class StorageBase {
+ // lists all templates available
+ abstract async list(): Promise;
+ // can be used to build an index of the available templates;
+ abstract async reindex(): Promise;
+ // returns a directory to run the templaterin
+ abstract async prepare(id: string): Promise;
+}
+
+export interface StorageConfig {
+ store?: StorageBase;
+ logger?: Logger;
+}
+
+class Storage implements StorageBase {
+ store?: StorageBase;
+
+ constructor({ store }: StorageConfig) {
+ this.store = store;
+ }
+
+ list = () => this.store!.list();
+ prepare = (id: string) => this.store!.prepare(id);
+ reindex = () => this.store!.reindex();
+}
+
+export const createStorage = (storageConfig: StorageConfig): StorageBase => {
+ return new Storage(storageConfig);
+};
diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts
new file mode 100644
index 0000000000..c2bf472165
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/templater/cookiecutter.ts
@@ -0,0 +1,32 @@
+import { TemplaterBase, TemplaterRunOptions } from '.';
+
+/*
+ * 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';
+
+export class CookieCutter implements TemplaterBase {
+ public async run(options: TemplaterRunOptions): Promise {
+ // first we need to make cookiecutter.json in the directory provided with the input values.
+ const cookieInfo = {
+ _copy_without_render: ['.github/workflows/*'],
+ ...options.values,
+ };
+
+ await fs.writeJSON(options.directory, cookieInfo);
+ return '';
+ // run cookie cutter with new json
+ }
+}
diff --git a/plugins/scaffolder-backend/src/scaffolder/templater/index.ts b/plugins/scaffolder-backend/src/scaffolder/templater/index.ts
new file mode 100644
index 0000000000..6f728d4829
--- /dev/null
+++ b/plugins/scaffolder-backend/src/scaffolder/templater/index.ts
@@ -0,0 +1,51 @@
+/*
+ * 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 interface RequiredTemplateValues {
+ componentId: string;
+}
+
+export interface TemplaterRunOptions {
+ directory: string;
+ values: RequiredTemplateValues & object;
+}
+
+export abstract class TemplaterBase {
+ // runs the templating with the values and returns the directory to push the VCS
+ abstract async run(opts: TemplaterRunOptions): Promise;
+}
+
+export interface TemplaterConfig {
+ templater?: TemplaterBase;
+}
+
+class Templater implements TemplaterBase {
+ templater?: TemplaterBase;
+
+ constructor({ templater }: TemplaterConfig) {
+ this.templater = templater;
+ }
+
+ public async run(opts: TemplaterRunOptions) {
+ return this.templater!.run(opts);
+ }
+}
+
+export const createTemplater = (
+ templaterConfig: TemplaterConfig,
+): TemplaterBase => {
+ return new Templater(templaterConfig);
+};
diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts
new file mode 100644
index 0000000000..97c177b915
--- /dev/null
+++ b/plugins/scaffolder-backend/src/service/router.ts
@@ -0,0 +1,54 @@
+/*
+ * 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 { Logger } from 'winston';
+import Router from 'express-promise-router';
+import express from 'express';
+import { StorageBase, TemplaterBase } from '../scaffolder';
+
+export interface RouterOptions {
+ storage: StorageBase;
+ templater: TemplaterBase;
+ logger: Logger;
+}
+
+export async function createRouter(
+ options: RouterOptions,
+): Promise {
+ const router = Router();
+ const { storage, templater, logger: parentLogger } = options;
+ const logger = parentLogger.child({ plugin: 'scaffolder' });
+
+ router
+ .get('/v1/templates', async (_, res) => {
+ const templates = await storage.list();
+ res.status(200).json(templates);
+ })
+ .post('/v1/jobs', async (_, res) => {
+ // TODO(blam): Actually make this function work
+ const mock = 'templateid';
+ res.status(201).json({ accepted: true });
+
+ const path = await storage.prepare(mock);
+ await templater.run({ directory: path, values: { componentId: 'test' } });
+ });
+
+ const app = express();
+ app.set('logger', logger);
+ app.use('/', router);
+
+ return app;
+}
diff --git a/plugins/scaffolder-backend/src/service/standaloneApplication.ts b/plugins/scaffolder-backend/src/service/standaloneApplication.ts
new file mode 100644
index 0000000000..3ff1de01eb
--- /dev/null
+++ b/plugins/scaffolder-backend/src/service/standaloneApplication.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2020 Spotify AB
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ errorHandler,
+ notFoundHandler,
+ requestLoggingHandler,
+} from '@backstage/backend-common';
+import compression from 'compression';
+import cors from 'cors';
+import express from 'express';
+import helmet from 'helmet';
+import { Logger } from 'winston';
+import { StorageBase, TemplaterBase } from '../scaffolder';
+import { createRouter } from './router';
+
+export interface ApplicationOptions {
+ enableCors: boolean;
+ storage: StorageBase;
+ templater: TemplaterBase;
+ logger: Logger;
+}
+
+export async function createStandaloneApplication(
+ options: ApplicationOptions,
+): Promise {
+ const { enableCors, storage, templater, logger } = options;
+ const app = express();
+
+ app.use(helmet());
+ if (enableCors) {
+ app.use(cors());
+ }
+ app.use(compression());
+ app.use(express.json());
+ app.use(requestLoggingHandler());
+ app.use('/', await createRouter({ templater, storage, logger }));
+ app.use(notFoundHandler());
+ app.use(errorHandler());
+
+ return app;
+}
diff --git a/plugins/scaffolder-backend/src/service/standaloneServer.ts b/plugins/scaffolder-backend/src/service/standaloneServer.ts
new file mode 100644
index 0000000000..b89e806f72
--- /dev/null
+++ b/plugins/scaffolder-backend/src/service/standaloneServer.ts
@@ -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 { Server } from 'http';
+import { Logger } from 'winston';
+import {
+ createStorage,
+ createTemplater,
+ DiskStorage,
+ CookieCutter,
+} from '../scaffolder';
+import { createStandaloneApplication } from './standaloneApplication';
+
+export interface ServerOptions {
+ port: number;
+ enableCors: boolean;
+ logger: Logger;
+}
+
+export async function startStandaloneServer(
+ options: ServerOptions,
+): Promise {
+ const logger = options.logger.child({ service: 'scaffolder-backend' });
+ const store = new DiskStorage({ logger });
+ const templater = new CookieCutter();
+ logger.debug('Creating application...');
+
+ const app = await createStandaloneApplication({
+ enableCors: options.enableCors,
+ storage: createStorage({ store, logger }),
+ templater: createTemplater({ templater }),
+ logger,
+ });
+
+ logger.debug('Starting application server...');
+ return await new Promise((resolve, reject) => {
+ const server = app.listen(options.port, (err?: Error) => {
+ if (err) {
+ reject(err);
+ return;
+ }
+
+ logger.info(`Listening on port ${options.port}`);
+ resolve(server);
+ });
+ });
+}
diff --git a/plugins/scaffolder-backend/test/mock-failing-template-dir/mock-template-2/template-info.json b/plugins/scaffolder-backend/test/mock-failing-template-dir/mock-template-2/template-info.json
new file mode 100644
index 0000000000..47f83f9f5c
--- /dev/null
+++ b/plugins/scaffolder-backend/test/mock-failing-template-dir/mock-template-2/template-info.json
@@ -0,0 +1,6 @@
+{
+ "id": "mock-template-2",
+ "name": "mockmannen",
+ "description": "mock template for building stuff in backstage",
+ "ownerId": "blam"
+}
diff --git a/plugins/scaffolder-backend/test/mock-failing-template-dir/mock-template/template-info.json b/plugins/scaffolder-backend/test/mock-failing-template-dir/mock-template/template-info.json
new file mode 100644
index 0000000000..9a3458fba1
--- /dev/null
+++ b/plugins/scaffolder-backend/test/mock-failing-template-dir/mock-template/template-info.json
@@ -0,0 +1,7 @@
+{
+ "id": "mock-template",
+ "name": "mockmannen",
+ "description": "mock template for building stuff in backstage",
+ "ownerId": "blam",
+
+}
diff --git a/plugins/scaffolder-backend/test/mock-multiple-templates-dir/mock-template-2/template-info.json b/plugins/scaffolder-backend/test/mock-multiple-templates-dir/mock-template-2/template-info.json
new file mode 100644
index 0000000000..47f83f9f5c
--- /dev/null
+++ b/plugins/scaffolder-backend/test/mock-multiple-templates-dir/mock-template-2/template-info.json
@@ -0,0 +1,6 @@
+{
+ "id": "mock-template-2",
+ "name": "mockmannen",
+ "description": "mock template for building stuff in backstage",
+ "ownerId": "blam"
+}
diff --git a/plugins/scaffolder-backend/test/mock-multiple-templates-dir/mock-template/template-info.json b/plugins/scaffolder-backend/test/mock-multiple-templates-dir/mock-template/template-info.json
new file mode 100644
index 0000000000..e0d323c987
--- /dev/null
+++ b/plugins/scaffolder-backend/test/mock-multiple-templates-dir/mock-template/template-info.json
@@ -0,0 +1,6 @@
+{
+ "id": "mock-template",
+ "name": "mockmannen",
+ "description": "mock template for building stuff in backstage",
+ "ownerId": "blam"
+}
diff --git a/plugins/scaffolder-backend/test/mock-simple-template-dir/mock-template/template-info.json b/plugins/scaffolder-backend/test/mock-simple-template-dir/mock-template/template-info.json
new file mode 100644
index 0000000000..e0d323c987
--- /dev/null
+++ b/plugins/scaffolder-backend/test/mock-simple-template-dir/mock-template/template-info.json
@@ -0,0 +1,6 @@
+{
+ "id": "mock-template",
+ "name": "mockmannen",
+ "description": "mock template for building stuff in backstage",
+ "ownerId": "blam"
+}
diff --git a/plugins/scaffolder-backend/tsconfig.json b/plugins/scaffolder-backend/tsconfig.json
new file mode 100644
index 0000000000..7d4ea182e2
--- /dev/null
+++ b/plugins/scaffolder-backend/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../packages/backend/tsconfig.json",
+ "include": [
+ "./src"
+ ],
+ "compilerOptions": {
+ "baseUrl": "./src",
+ "outDir": "./dist",
+ "skipLibCheck": true
+ }
+}
diff --git a/plugins/scaffolder/.eslintrc.js b/plugins/scaffolder/.eslintrc.js
new file mode 100644
index 0000000000..13573efa9c
--- /dev/null
+++ b/plugins/scaffolder/.eslintrc.js
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: [require.resolve('@backstage/cli/config/eslint')],
+};
diff --git a/plugins/scaffolder/README.md b/plugins/scaffolder/README.md
new file mode 100644
index 0000000000..e77a77148f
--- /dev/null
+++ b/plugins/scaffolder/README.md
@@ -0,0 +1,13 @@
+# Inventory Frontend
+
+WORK IN PROGRESS
+
+This is the frontend part of the default inventory plugin.
+
+It will implement the core API for handling your inventory of software, and
+supply the base views to show and manage them.
+
+## Links
+
+- (Backend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/inventory-backend]
+- (The Backstage homepage)[https://backstage.io]
diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json
new file mode 100644
index 0000000000..878b4ddbf2
--- /dev/null
+++ b/plugins/scaffolder/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "@backstage/plugin-scaffolder",
+ "version": "0.1.1-alpha.4",
+ "main": "dist/index.esm.js",
+ "types": "dist/index.d.ts",
+ "license": "Apache-2.0",
+ "private": true,
+ "scripts": {
+ "build": "backstage-cli plugin:build",
+ "start": "backstage-cli plugin:serve",
+ "lint": "backstage-cli lint",
+ "test": "backstage-cli test",
+ "clean": "backstage-cli clean"
+ },
+ "devDependencies": {
+ "@backstage/cli": "^0.1.1-alpha.4",
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@testing-library/user-event": "^7.1.2",
+ "@types/jest": "^24.0.0",
+ "@types/node": "^12.0.0",
+ "@types/testing-library__jest-dom": "5.0.2",
+ "jest-fetch-mock": "^3.0.3"
+ },
+ "dependencies": {
+ "@backstage/core": "^0.1.1-alpha.4",
+ "@backstage/theme": "^0.1.1-alpha.4",
+ "@material-ui/core": "^4.9.1",
+ "@material-ui/icons": "^4.9.1",
+ "@material-ui/lab": "4.0.0-alpha.45",
+ "react": "16.13.1",
+ "react-dom": "16.13.1",
+ "react-use": "^13.0.0"
+ },
+ "files": [
+ "dist"
+ ]
+}
diff --git a/plugins/scaffolder/src/components/ScaffolderPage/index.tsx b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx
new file mode 100644
index 0000000000..9c3801702a
--- /dev/null
+++ b/plugins/scaffolder/src/components/ScaffolderPage/index.tsx
@@ -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 React from 'react';
+import {
+ AlphaLabel,
+ Content,
+ ContentHeader,
+ InfoCard,
+ Header,
+ Page,
+ pageTheme,
+} from '@backstage/core';
+import { Typography, Link } from '@material-ui/core';
+
+// TODO(blam): Connect to backend
+const STATIC_DATA = [
+ {
+ id: 'react-ssr-template',
+ name: 'SSR React Website',
+ description:
+ 'Next.js application skeleton for creating isomorphic web applications.',
+ ownerId: 'something',
+ },
+];
+const ScaffolderPage: React.FC<{}> = () => {
+ return (
+
+
+ Create a new component {' '}
+ >
+ }
+ subtitle="Create new software components using standard templates"
+ />
+
+
+
+ NOTE! This feature is WIP. You can follow progress{' '}
+
+ here
+
+ .
+
+