Merge pull request #5 from spotify/master

Sync forks
This commit is contained in:
Ivan Shmidt
2020-05-06 15:23:18 +02:00
committed by GitHub
189 changed files with 5677 additions and 1573 deletions
+2 -4
View File
@@ -1,6 +1,4 @@
module.exports = {
extends: [
require.resolve('@backstage/cli/config/eslint'),
'@spotify/eslint-config-oss',
],
root: true,
extends: ['@spotify/eslint-config-oss'],
};
+1 -1
View File
@@ -4,4 +4,4 @@
# The last matching pattern takes precedence.
# https://help.github.com/articles/about-codeowners/
* @spotify/backstage-core
* @spotify/backstage-core
+2
View File
@@ -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:
@@ -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
+6 -6
View File
@@ -9,11 +9,11 @@ Backstage Open Source is a newly launched endeavor, and were 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 were headed. Well 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 weve made and why weve 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 were headed. Well 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 weve made and why weve 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. Well also be creating issues that are focused on building up our core Backstage user experience. Well 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. Well also be creating issues that are focused on building up our core Backstage user experience. Well 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. Well 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. Well 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 youd like to help build up our design system, you can also add components weve 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 thats implementing Backstage? A designer whos fascinated by the developer productivity problem space? A designer whos curious about open source design? Wed love for you to contribute. Behind the scenes, were 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, well make sure youre equipped to chip in on this project. Were excited to work with you! In the meantime, wed love to hear from you on [Discord](https://discord.gg/PefUsZ).
Are you a designer at an organisation thats implementing Backstage? A designer whos fascinated by the developer productivity problem space? A designer whos curious about open source design? Wed love for you to contribute. Behind the scenes, were 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, well make sure youre equipped to chip in on this project. Were excited to work with you! In the meantime, wed love to hear from you on [Discord](https://discord.gg/EBHEGzX).
[Back to Docs](../README.md)
+1 -1
View File
@@ -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": {
+2
View File
@@ -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",
@@ -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 }) => (
<SidebarSpacer />
<SidebarDivider />
<SidebarItem icon={HomeIcon} to="/" text="Home" />
<SidebarItem icon={ExploreIcon} to="/explore" text="Explore" />
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
<SidebarDivider />
<SidebarItem icon={AccountTreeIcon} to="/inventory" text="Inventory" />
<SidebarItem icon={AccountCircle} to="/login" text="Login" />
<SidebarDivider />
+2 -1
View File
@@ -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';
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+38
View File
@@ -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)
+47
View File
@@ -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"
]
}
@@ -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`,
);
}
});
});
+77
View File
@@ -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 {}
+19
View File
@@ -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';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './SortableTable';
export * from './rootLogger';
@@ -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'),
);
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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;
}
@@ -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';
@@ -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);
});
});
@@ -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();
};
}
@@ -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'),
);
});
});
@@ -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);
},
},
});
}
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
require('jest-fetch-mock').enableMocks();
+15
View File
@@ -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"]
}
}
+1 -4
View File
@@ -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')],
};
+3
View File
@@ -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"
},
+51 -16
View File
@@ -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();
+73
View File
@@ -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,
},
],
},
},
],
};
+2
View File
@@ -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 },
+7
View File
@@ -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
+3 -1
View File
@@ -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"]
}
}
+25 -9
View File
@@ -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) {
+3
View File
@@ -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",
@@ -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}`);
}
}
}
@@ -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);
};
@@ -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<PluginInfo> {
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<string> {
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<TemplateFile[]> {
const templateFilePaths = await recursiveReadDir(templateDir).catch(error => {
throw new Error(`Failed to read template directory: ${error.message}`);
});
const templateFiles = new Array<TemplateFile>();
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);
}
@@ -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<boolean>;
export type HandlerFunc = (
file: TemplateFile,
prompt: PromptFunc,
) => Promise<void>;
export type FileHandler = {
patterns: Array<string | RegExp>;
handler: HandlerFunc;
};
@@ -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({
+7
View File
@@ -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')
@@ -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": {
@@ -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,
@@ -47,9 +47,7 @@ const ExampleComponent: FC<{}> = () => (
</InfoCard>
</Grid>
<Grid item>
<InfoCard title="Example User List (fetching data from randomuser.me)">
<ExampleFetchComponent />
</InfoCard>
<ExampleFetchComponent />
</Grid>
</Grid>
</Content>
@@ -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<DenseTableProps> = ({ 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: (
<img
src={user.picture.medium}
className={classes.avatar}
alt={user.name.first}
/>
),
name: `${user.name.first} ${user.name.last}`,
email: user.email,
nationality: user.nat,
};
});
return (
<TableContainer>
<Table className={classes.table} size="small" aria-label="a dense table">
<TableHead>
<TableRow>
<TableCell>Avatar</TableCell>
<TableCell>Name</TableCell>
<TableCell>Email</TableCell>
<TableCell>Nationality</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.map(user => (
<TableRow key={user.email}>
<TableCell>
<img
src={user.picture.medium}
className={classes.avatar}
alt={user.name.first}
/>
</TableCell>
<TableCell>
{user.name.first} {user.name.last}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.nat}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Table
title="Example User List (fetching data from randomuser.me)"
options=\{{ search: false, paging: false }}
columns={columns}
data={data}
/>
);
};
+3 -3
View File
@@ -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"
-5
View File
@@ -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<T>(config: ApiRefConfig) {
return new ApiRef<T>(config);
}
export function createWidgetView() {
return new WidgetViewBuilder();
}
export function createPlugin(config: PluginConfig): BackstagePlugin {
return new BackstagePlugin(config);
}
+7 -1
View File
@@ -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 = (
<Switch>
{routes}
<Route component={() => <span>404 Not Found</span>} />
<Route
render={(props) => (
<ErrorPage {...props} status="404" statusMessage="PAGE NOT FOUND" />
)}
/>
</Switch>
);
-11
View File
@@ -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();
-7
View File
@@ -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;
@@ -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<WidgetViewRegistration>();
private output?: ComponentType<any>;
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<any> {
if (this.output) {
return this.output;
}
const widgets = new Array<Widget>();
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 = () => <DefaultWidgetView widgets={widgets} />;
return this.output;
}
}
@@ -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<StepProps> = ({
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 ? (
<div className={classes.end}>
<Typography variant="h6">{title}</Typography>
{children}
<SimpleStepperFooter actions={{ ...(actions || {}), showNext: false }} />
</div>
) : (
<MuiStep {...muiProps}>
<StepLabel>
<Typography variant="h6">{title}</Typography>
</StepLabel>
<StepContent>
{children}
<SimpleStepperFooter actions={actions} />
</StepContent>
</MuiStep>
);
};
export default Step;
@@ -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>(theme => ({
root: {
padding: theme.spacing(2),
},
widgetWrapper: {
padding: theme.spacing(2),
},
}));
const WidgetViewComponent: FC<WidgetViewProps> = ({ widgets }) => {
const classes = useStyles();
return (
<div className={classes.root}>
<Grid container direction="row" spacing={2}>
{widgets.map(({ size, component: WidgetComponent }, index) => (
<Grid key={index} item xs={size}>
<Paper className={classes.widgetWrapper}>
<WidgetComponent />
</Paper>
</Grid>
))}
</Grid>
</div>
);
};
export default WidgetViewComponent;
@@ -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: <div>Slide 1 content</div>,
},
{
title: 'Step 2!',
content: <div>Slide 2 content</div>,
},
{
title: 'Step 3!',
content: <div>Slide 3 content</div>,
},
];
export const Horizontal = () => (
<Sequence steps={steps} orientation="horizontal" />
);
export const Vertical = () => <Sequence steps={steps} orientation="vertical" />;
export const ConditionalButtons = () => {
const [required, setRequired] = useState(false);
const customSteps: StepType[] = [...steps];
customSteps[0] = {
title: 'Step 1 with required field',
actions: {
canNext: () => required,
},
content: (
<div>
<TextField
variant="outlined"
placeholder="Required*"
onChange={e => setRequired(!!e.target.value)}
/>
</div>
),
};
return <Sequence steps={customSteps} orientation="vertical" />;
};
export const CompletionStep = () => {
const completed = {
title: 'Succcess!',
content: <div>You've completed the sequence</div>,
};
return (
<Sequence
steps={steps.slice(0, 1)}
completed={completed}
orientation="vertical"
/>
);
};
@@ -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: <div>step0</div>,
},
{
title: 'Step 1',
content: <div>step1</div>,
actions: { nextStep: () => 3 },
},
{
title: 'Step 2',
content: <div>step2</div>,
},
{
title: 'Step 3',
content: <div>step3</div>,
},
];
});
it('Handles nextStep property', () => {
const rendered = render(
wrapInTestApp(<Sequence orientation="horizontal" steps={steps} />),
);
fireEvent.click(rendered.getByText('Next'));
expect(rendered.getByText('step1')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Next'));
expect(rendered.getByText('step3')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Back'));
expect(rendered.getByText('step1')).toBeInTheDocument();
});
it('Shows controls and content when going back to first step', () => {
const rendered = render(
wrapInTestApp(<Sequence orientation="horizontal" steps={steps} />),
);
fireEvent.click(rendered.getByText('Next'));
expect(rendered.getByText('step1')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Next'));
expect(rendered.getByText('step3')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Back'));
expect(rendered.getByText('step1')).toBeInTheDocument();
expect(rendered.getByText('Next')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Back'));
expect(rendered.getByText('step0')).toBeInTheDocument();
expect(rendered.getByText('Next')).toBeInTheDocument();
});
it('uses nextText if specified in all steps', () => {
const nextTextSteps = [
{
title: 'Step 0',
actions: {
nextText: 'Step0Next',
},
content: <div>step0</div>,
},
{
title: 'Final Step',
actions: {
nextText: 'FinalStepNext',
},
content: <div>final step</div>,
},
];
const rendered = render(
wrapInTestApp(
<Sequence orientation="horizontal" steps={nextTextSteps} />,
),
);
expect(rendered.getByText('Step0Next')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Step0Next'));
expect(rendered.getByText('FinalStepNext')).toBeInTheDocument();
});
});
@@ -1,156 +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, { Fragment, FC, useState } from 'react';
import {
Stepper,
Step,
StepContent,
StepLabel,
Typography,
makeStyles,
} from '@material-ui/core';
import SequenceFooter from './SequenceFooter';
const useStyles = makeStyles((theme: any) => ({
content: {
padding: theme.spacing(4, 4, 4, 4),
},
completionBox: {
padding: theme.spacing(3),
},
}));
type Orientation = 'horizontal' | 'vertical';
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;
completedButton?: JSX.Element;
};
export type StepType = {
title: string;
actions?: StepActions;
content: JSX.Element;
};
export interface SequenceProps {
completed?: StepType;
elevated?: boolean;
orientation: Orientation;
onSequenceStepChange?: (prevIndex: number, nextIndex: number) => void;
steps: StepType[];
footerProps?: any;
}
const Sequence: FC<SequenceProps> = ({
completed,
elevated,
orientation,
onSequenceStepChange,
steps,
footerProps,
}) => {
const classes = useStyles();
const [stepIndex, setStepIndex] = useState<number>(0);
const [stepArray, setStepArray] = useState<number[]>([0]);
const isVertical = orientation === 'vertical';
const isCompleted = !!completed && stepIndex === steps.length;
const horizontalStep = !isVertical && steps[stepIndex]?.content;
return (
<Fragment>
<Stepper
activeStep={stepIndex}
orientation={orientation}
elevation={elevated ? 2 : 0}
>
{steps.map((step, i) => (
<Step key={i}>
<StepLabel>
<Typography variant="h6">{step.title}</Typography>
</StepLabel>
{isVertical && (
<StepContent>
{step.content}
<SequenceFooter
stepIndex={i}
setStepIndex={setStepIndex}
stepArray={stepArray}
setStepArray={setStepArray}
length={steps.length}
onSequenceStepChange={onSequenceStepChange}
actions={step.actions || {}}
{...footerProps}
/>
</StepContent>
)}
</Step>
))}
</Stepper>
{isCompleted && (
<div key="completed" className={classes.completionBox}>
<Typography variant="h6"> {completed?.title} </Typography>
{completed?.content}
<SequenceFooter
stepIndex={stepIndex}
setStepIndex={setStepIndex}
stepArray={stepArray}
setStepArray={setStepArray}
length={steps.length}
actions={{ ...(completed?.actions || {}), showNext: false }}
{...footerProps}
>
{completed?.actions?.completedButton}
</SequenceFooter>
</div>
)}
{/* Horizontal steppers have buttons on the bottom of stepper */}
{!isVertical && (
<>
<div className={classes.content}>
{horizontalStep}
<SequenceFooter
stepIndex={stepIndex}
setStepIndex={setStepIndex}
stepArray={stepArray}
setStepArray={setStepArray}
length={steps.length}
onSequenceStepChange={onSequenceStepChange}
actions={steps[stepIndex]?.actions || {}}
{...footerProps}
/>
</div>
</>
)}
</Fragment>
);
};
export default Sequence;
@@ -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 = () => (
<SimpleStepper>
<SimpleStepperStep title="Step 1">
<div>This is the content for step 1</div>
</SimpleStepperStep>
<SimpleStepperStep title="Step 2">
<div>This is the content for step 2</div>
</SimpleStepperStep>
<SimpleStepperStep title="Step 3">
<div>This is the content for step 3</div>
</SimpleStepperStep>
</SimpleStepper>
);
export const ConditionalButtons = () => {
const [required, setRequired] = useState(false);
return (
<SimpleStepper>
<SimpleStepperStep
title="Step 1 with required field"
actions={{
canNext: () => required,
}}
>
<TextField
variant="outlined"
placeholder="Required*"
onChange={(e) => setRequired(!!e.target.value)}
/>
</SimpleStepperStep>
<SimpleStepperStep title="Step 2">
<div>This is the content for step 2</div>
</SimpleStepperStep>
<SimpleStepperStep title="Step 3">
<div>This is the content for step 3</div>
</SimpleStepperStep>
</SimpleStepper>
);
};
export const CompletionStep = () => {
return (
<SimpleStepper>
<SimpleStepperStep title="Step 1">
<div>This is the content for step 1</div>
</SimpleStepperStep>
<SimpleStepperStep title="Step 2">
<div>This is the content for step 2</div>
</SimpleStepperStep>
<SimpleStepperStep title="Success!" end>
<div>You've completed the Stepper</div>
</SimpleStepperStep>
</SimpleStepper>
);
};
@@ -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(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{ nextStep: () => 3 }}
data-testid="step1"
>
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
<Step title="Step 3" data-testid="step3">
<div>step3</div>
</Step>
</Stepper>,
),
);
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(
<Stepper>
<Step title="Step 0" data-testid="step0">
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{ nextStep: () => 3 }}
data-testid="step1"
>
<div>step1</div>
</Step>
<Step title="Step 2" data-testid="step2">
<div>step2</div>
</Step>
<Step title="Step 3" data-testid="step3">
<div>step3</div>
</Step>
</Stepper>,
),
);
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(
<Stepper>
<Stepper>
<Step
title="Step 0"
actions={{
nextText: 'Step0Next',
}}
>
<div>step0</div>
</Step>
<Step
title="Step 1"
actions={{
nextText: 'FinalStepNext',
}}
>
<div>final step</div>
</Step>
</Stepper>
</Stepper>,
),
);
expect(rendered.getByText('Step0Next')).toBeInTheDocument();
fireEvent.click(rendered.getByText('Step0Next'));
expect(rendered.getByText('FinalStepNext')).toBeInTheDocument();
});
});
@@ -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<InternalState>({
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<StepperProps> = ({ children, elevated, onStepChange }) => {
const [stepIndex, setStepIndex] = useState<number>(0);
const [stepHistory, setStepHistory] = useState<number[]>([]);
const steps: React.ReactNode[] = [];
let endStep;
Children.forEach(children, (child) => {
if (isValidElement(child)) {
if (child.props.end) {
endStep = child;
} else {
steps.push(child);
}
}
});
return (
<>
<VerticalStepperContext.Provider
value={{
stepIndex,
setStepIndex,
stepHistory,
setStepHistory,
onStepChange,
stepperLength: Children.count(children),
}}
>
<MuiStepper
activeStep={stepIndex}
orientation="vertical"
elevation={elevated ? 2 : 0}
>
{steps}
</MuiStepper>
</VerticalStepperContext.Provider>
{stepIndex >= Children.count(children) - 1 && endStep}
</>
);
};
export default Stepper;
@@ -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<{
</Button>
);
export type SequenceFooterProps = {
stepIndex: number;
setStepIndex: React.Dispatch<React.SetStateAction<number>>;
stepArray: number[];
setStepArray: React.Dispatch<React.SetStateAction<number[]>>;
length: number;
actions: StepActions;
onSequenceStepChange?: (old: number, updated: number) => void;
export type SimpleStepperFooterProps = {
actions?: StepActions;
children?: ReactNode;
className: string;
};
const SequenceFooter: FC<SequenceFooterProps> = ({
stepIndex,
setStepIndex,
stepArray,
setStepArray,
length,
onSequenceStepChange,
actions,
const SimpleStepperFooter: FC<SimpleStepperFooterProps> = ({
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<SequenceFooterProps> = ({
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 (
<div className={`${classes.root} ${className}`}>
<div className={classes.root}>
{[undefined, true].includes(actions.showBack) && stepIndex !== 0 && (
<BackBtn
text={actions.backText}
@@ -133,7 +128,7 @@ const SequenceFooter: FC<SequenceFooterProps> = ({
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<SequenceFooterProps> = ({
);
};
export default SequenceFooter;
export default SimpleStepperFooter;
@@ -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<StepProps> = ({
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 ? (
<div className={classes.end}>
<Typography variant="h6">{title}</Typography>
{children}
<SimpleStepperFooter actions={{ ...(actions || {}), showNext: false }} />
</div>
) : (
<MuiStep {...muiProps}>
<StepLabel>
<Typography variant="h6">{title}</Typography>
</StepLabel>
<StepContent>
{children}
<SimpleStepperFooter actions={actions} />
</StepContent>
</MuiStep>
);
};
export default Step;
@@ -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 };
@@ -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 (
<TableHead>
<TableRow>
{columns.map(column => (
<TableCell
key={column.id}
align={column.numeric ? 'right' : 'left'}
size={column.disablePadding ? 'small' : 'medium'}
sortDirection={orderBy === column.id ? order : false}
style={column.style}
>
<Tooltip
title="Sort"
placement={column.numeric ? 'bottom-end' : 'bottom-start'}
enterDelay={300}
>
<TableSortLabel
active={orderBy === column.id}
direction={order}
onClick={createSortHandler(column.id)}
style={{ whiteSpace: 'nowrap' }}
>
{column.label}
</TableSortLabel>
</Tooltip>
</TableCell>
))}
</TableRow>
</TableHead>
);
};
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) => (
<span key={index}>
{item}
<br />
</span>
));
}
return data;
};
CellContent.propTypes = {
data: PropTypes.any.isRequired,
};
const DataTableCell = ({ column, row }) => {
return (
<TableCell
align={column.numeric ? 'right' : 'left'}
key={column.id}
style={{ verticalAlign: 'top' }}
>
<CellContent data={row[column.id] || ''} />
</TableCell>
);
};
const noop = () => {};
const DataTableRow = pure(({ row, columns, handleRowClick, style }) => {
const onClick = event => (handleRowClick || noop)(event, row.id);
return (
<TableRow onClick={onClick} key={row.id} style={style}>
{columns.map(column => (
<DataTableCell key={column.id} column={column} row={row} />
))}
</TableRow>
);
});
/**
* 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: <Error />, statusValue: 2 },
* { id: 'milk', amount: 3, status: <Warning />, 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: <Error /> },
* ];
* return (
* <SortableTable data={data} footerData={footerData} orderBy={'id'} columns={columns}
* onRowClicked={(id, ev) => {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 = (
<TableFooter>
{footerData.map(row => (
<DataTableRow
key={row.id}
columns={columns}
row={row}
style={{ height: 'auto' }}
/>
))}
</TableFooter>
);
}
return (
<Table>
<EnhancedTableHead
columns={columns}
onRequestSort={this.handleRequestSort}
order={order}
orderBy={orderBy}
/>
<TableBody key={dataVersion}>
{data.map(row => (
<DataTableRow
key={row.id}
columns={columns}
row={row}
handleRowClick={this.handleRowClick}
/>
))}
</TableBody>
{tableFoot}
</Table>
);
}
}
export default SortableTable;
@@ -1,61 +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 { StatusError, StatusOK, StatusWarning } from 'components/Status';
import SortableTable from './SortableTable';
export default {
title: 'Sortable Table',
component: SortableTable,
};
const containerStyle = { width: 600, padding: 20 };
const data = [
{ id: 'buffalos', amount: 1, status: <StatusError />, statusValue: 2 },
{ id: 'milk', amount: 3, status: <StatusWarning />, statusValue: 1 },
{ id: 'cheese', amount: 8, status: <StatusWarning />, statusValue: 1 },
{ id: 'bread', amount: 2, status: <StatusOK />, statusValue: 0 },
];
const columns = [
{ id: 'id', label: 'ID' },
{ id: 'amount', disablePadding: false, numeric: true, label: 'AMOUNT' },
{
id: 'status',
label: 'STATUS',
sortValue: (row: typeof data[0]) => row.statusValue,
},
];
const footerData = [
{ id: 'total', amount: 4, statusValue: 2, status: <StatusError /> },
];
export const Default = () => (
<div style={containerStyle}>
<SortableTable orderBy="desc" data={data} columns={columns} />
</div>
);
export const WithFooter = () => (
<div style={containerStyle}>
<SortableTable
orderBy="asc"
data={data}
columns={columns}
footerData={footerData}
/>
</div>
);
@@ -24,44 +24,80 @@ import {
StatusRunning,
StatusWarning,
} from './Status';
import Table from 'components/Table';
import InfoCard from 'layout/InfoCard';
export default {
title: 'Status',
component: StatusOK,
};
export const statusOK = () => (
<>
<StatusOK /> Status OK
</>
);
export const statusWarning = () => (
<>
<StatusWarning /> Status Warning
</>
);
export const statusError = () => (
<>
<StatusError /> Status Error
</>
);
export const statusFailed = () => (
<>
<StatusFailed /> Status Failed
</>
);
export const statusPending = () => (
<>
<StatusPending /> Status Pending
</>
);
export const statusRunning = () => (
<>
<StatusRunning /> Status Running
</>
);
export const statusNA = () => (
<>
<StatusNA /> Status NA
</>
const data = [
{
status: <StatusOK />,
label: 'OK',
usage: 'Deployment successful',
},
{
status: <StatusWarning />,
label: 'Warning',
usage: 'CPU utilization at 90%',
},
{
status: <StatusError />,
label: 'Error',
usage: 'Service could not be created',
},
{
status: <StatusFailed />,
label: 'Failed',
usage: 'Build for PR #34 failed',
},
{
status: <StatusPending />,
label: 'Pending',
usage: 'Job is waiting',
},
{
status: <StatusRunning />,
label: 'Running',
usage: 'Job is running',
},
{
status: <StatusNA />,
label: 'N/A',
usage: 'Not sure what to do',
},
];
const columns = [
{ field: 'status', title: 'Status' },
{ field: 'label', title: 'Label' },
{ field: 'usage', title: 'Example usage' },
];
const containerStyle = { width: 600 };
export const Default = () => (
<div style={containerStyle}>
<InfoCard title="Available status types">
<Table
options={{
search: false,
paging: false,
toolbar: false,
}}
data={data}
columns={columns}
/>
</InfoCard>
</div>
);
export const statusOK = () => <StatusOK />;
export const statusWarning = () => <StatusWarning />;
export const statusError = () => <StatusError />;
export const statusFailed = () => <StatusFailed />;
export const statusPending = () => <StatusPending />;
export const statusRunning = () => <StatusRunning />;
export const statusNA = () => <StatusNA />;
+11 -13
View File
@@ -19,7 +19,7 @@ import { BackstageTheme } from '@backstage/theme';
import classNames from 'classnames';
import React, { FC } from 'react';
const useStyles = makeStyles<BackstageTheme>(theme => ({
const useStyles = makeStyles<BackstageTheme>((theme) => ({
status: {
width: 12,
height: 12,
@@ -34,6 +34,7 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
backgroundColor: theme.palette.status.warning,
},
error: {
// Use same for Failed status.
width: '0',
height: '0',
borderLeft: '7px solid transparent',
@@ -43,11 +44,8 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
pending: {
backgroundColor: theme.palette.status.pending,
},
failed: {
backgroundColor: 'rgba(245, 155, 35, 0.5)',
},
running: {
animation: 'blink 0.8s step-start 0s infinite',
animation: '$blink 0.8s step-start 0s infinite',
backgroundColor: theme.palette.status.running,
},
'@keyframes blink': {
@@ -57,7 +55,7 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
},
}));
export const StatusOK: FC<{}> = props => {
export const StatusOK: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
@@ -68,7 +66,7 @@ export const StatusOK: FC<{}> = props => {
);
};
export const StatusWarning: FC<{}> = props => {
export const StatusWarning: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
@@ -79,7 +77,7 @@ export const StatusWarning: FC<{}> = props => {
);
};
export const StatusError: FC<{}> = props => {
export const StatusError: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
@@ -90,13 +88,13 @@ export const StatusError: FC<{}> = props => {
);
};
export const StatusNA: FC<{}> = props => (
export const StatusNA: FC<{}> = (props) => (
<span aria-label="Status N/A" {...props}>
N/A
</span>
);
export const StatusPending: FC<{}> = props => {
export const StatusPending: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
@@ -107,7 +105,7 @@ export const StatusPending: FC<{}> = props => {
);
};
export const StatusRunning: FC<{}> = props => {
export const StatusRunning: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
@@ -118,11 +116,11 @@ export const StatusRunning: FC<{}> = props => {
);
};
export const StatusFailed: FC<{}> = props => {
export const StatusFailed: FC<{}> = (props) => {
const classes = useStyles(props);
return (
<span
className={classNames(classes.status, classes.failed)}
className={classNames(classes.status, classes.error)}
aria-label="Status failed"
{...props}
/>
@@ -21,44 +21,55 @@ import {
TableCell,
TableRow,
withStyles,
createStyles,
WithStyles,
Theme,
} from '@material-ui/core';
const tableTitleCellStyles = theme => ({
root: {
fontWeight: 'bolder',
whiteSpace: 'nowrap',
paddingRight: theme.spacing(4),
border: '0',
verticalAlign: 'top',
},
});
const tableTitleCellStyles = (theme: Theme) =>
createStyles({
root: {
fontWeight: 'bolder',
whiteSpace: 'nowrap',
paddingRight: theme.spacing(4),
border: '0',
verticalAlign: 'top',
},
});
const tableContentCellStyles = {
root: {
color: 'rgba(0, 0, 0, 0.6)',
border: '0',
verticalAlign: 'top',
},
};
const listStyles = theme => ({
root: {
listStyle: 'none',
margin: theme.spacing(0, 0, -1, 0),
padding: '0',
},
});
const listStyles = (theme: Theme) =>
createStyles({
root: {
listStyle: 'none',
margin: theme.spacing(0, 0, -1, 0),
padding: '0',
},
});
const listItemStyles = theme => ({
root: {
padding: theme.spacing(0, 0, 1, 0),
},
});
const listItemStyles = (theme: Theme) =>
createStyles({
root: {
padding: theme.spacing(0, 0, 1, 0),
},
random: {},
});
const TitleCell = withStyles(tableTitleCellStyles)(TableCell);
const ContentCell = withStyles(tableContentCellStyles)(TableCell);
export const MetadataTable = ({ dense, children }) => (
export const MetadataTable = ({
dense,
children,
}: {
dense?: boolean;
children: React.ReactNode;
}) => (
<Table>
{!dense && (
<colgroup>
@@ -70,7 +81,14 @@ export const MetadataTable = ({ dense, children }) => (
</Table>
);
export const MetadataTableItem = ({ title, children, ...rest }) => (
export const MetadataTableItem = ({
title,
children,
...rest
}: {
title: string;
children: React.ReactNode;
}) => (
<TableRow>
{title && <TitleCell>{title}</TitleCell>}
<ContentCell colSpan={title ? 1 : 2} {...rest}>
@@ -79,10 +97,18 @@ export const MetadataTableItem = ({ title, children, ...rest }) => (
</TableRow>
);
export const MetadataList = withStyles(listStyles)(({ classes, children }) => (
interface StyleProps extends WithStyles {
children?: React.ReactNode;
}
export const MetadataList = withStyles(
listStyles,
)(({ classes, children }: StyleProps) => (
<ul className={classes.root}>{children}</ul>
));
export const MetadataListItem = withStyles(
listItemStyles,
)(({ classes, children }) => <li className={classes.root}>{children}</li>);
)(({ classes, children }: StyleProps) => (
<li className={classes.root}>{children}</li>
));
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import React, { Component, Fragment } from 'react';
import { withStyles } from '@material-ui/core';
import React, { Component, Fragment, ReactElement } from 'react';
import { withStyles, createStyles, WithStyles, Theme } from '@material-ui/core';
import startCase from 'lodash/startCase';
import {
@@ -25,22 +25,23 @@ import {
MetadataListItem,
} from './MetadataTable';
const listStyle = {
const listStyle = createStyles({
root: {
margin: '0 0',
listStyleType: 'none',
},
};
});
const nestedListStyle = {
root: {
...listStyle.root,
paddingLeft: '8px',
},
};
const nestedListStyle = (theme: Theme) =>
createStyles({
root: {
...listStyle.root,
paddingLeft: theme.spacing(1),
},
});
function renderList(list, options, nested) {
const values = list.map((item, index) => (
function renderList(list: Array<any>, nested?: boolean) {
const values = list.map((item: any, index: number) => (
<MetadataListItem key={index}>{toValue(item)}</MetadataListItem>
));
return nested ? (
@@ -50,8 +51,12 @@ function renderList(list, options, nested) {
);
}
function renderMap(map, options, nested) {
const values = Object.keys(map).map(key => {
function renderMap(
map: { [key: string]: any },
nested?: boolean,
options?: any,
) {
const values = Object.keys(map).map((key) => {
const value = toValue(map[key], true);
const fmtKey =
options && options.titleFormat
@@ -72,7 +77,11 @@ function renderMap(map, options, nested) {
);
}
function toValue(value, options, nested) {
function toValue(
value: ReactElement | object | Array<any>,
options?: any,
nested?: boolean,
) {
if (React.isValidElement(value)) {
return <Fragment>{value}</Fragment>;
}
@@ -82,31 +91,44 @@ function toValue(value, options, nested) {
}
if (Array.isArray(value)) {
return renderList(value, options, nested);
return renderList(value, nested);
}
return <Fragment>{value}</Fragment>;
}
function mapToItems(info, options) {
return Object.keys(info).map(key => (
function mapToItems(info: { [key: string]: string }, options: any) {
return Object.keys(info).map((key) => (
<TableItem key={key} title={key} value={info[key]} options={options} />
));
}
interface StyleProps extends WithStyles {
children?: React.ReactNode;
}
// Sub Components
const StyledList = withStyles(listStyle)(({ classes, children }) => (
const StyledList = withStyles(
listStyle,
)(({ classes, children }: StyleProps) => (
<MetadataList classes={classes}>{children}</MetadataList>
));
const StyledNestedList = withStyles(
nestedListStyle,
)(({ classes, children }) => (
)(({ classes, children }: StyleProps) => (
<MetadataList classes={classes}>{children}</MetadataList>
));
const ItemValue = ({ value, options }) => (
const ItemValue = ({ value, options }: { value: any; options: any }) => (
<Fragment>{toValue(value, options)}</Fragment>
);
const TableItem = ({ title, value, options }) => {
const TableItem = ({
title,
value,
options,
}: {
title: string;
value: any;
options: any;
}) => {
return (
<MetadataTableItem
title={
@@ -120,7 +142,12 @@ const TableItem = ({ title, value, options }) => {
);
};
export default class StructuredMetadataTable extends Component {
interface ComponentProps {
metadata: { [key: string]: any };
dense?: boolean;
options?: any;
}
export default class StructuredMetadataTable extends Component<ComponentProps> {
render() {
const { metadata, dense, options } = this.props;
const metadataItems = mapToItems(metadata, options || {});
@@ -0,0 +1,47 @@
/*
* 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 { BackstageTheme } from '@backstage/theme';
import { makeStyles } from '@material-ui/core';
const useSubvalueCellStyles = makeStyles<BackstageTheme>(theme => ({
value: {
marginBottom: '6px',
},
subvalue: {
color: theme.palette.textSubtle,
fontWeight: 'normal',
},
}));
type SubvalueCellProps = {
value: React.ReactNode;
subvalue: React.ReactNode;
};
const SubvalueCell: FC<SubvalueCellProps> = ({ value, subvalue }) => {
const classes = useSubvalueCellStyles();
return (
<>
<div className={classes.value}>{value}</div>
<div className={classes.subvalue}>{subvalue}</div>
</>
);
};
export default SubvalueCell;
@@ -0,0 +1,143 @@
/*
* 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 Table, { SubvalueCell, TableColumn } from './';
export default {
title: 'Table',
component: Table,
};
const generateTestData: (number: number) => Array<{}> = (rows = 20) => {
const data: Array<{}> = [];
while (data.length <= rows) {
data.push({
col1: `Some value ${data.length}`,
col2: `More data ${data.length}`,
subvalue: `Subvalue ${data.length}`,
number: Math.floor(Math.random() * 1000),
date: new Date(Math.random() * 10000000000000),
});
}
return data;
};
const testData100 = generateTestData(100);
export const DefaultTable = () => {
const columns: TableColumn[] = [
{
title: 'Column 1',
field: 'col1',
highlight: true,
},
{
title: 'Column 2',
field: 'col2',
},
{
title: 'Numeric value',
field: 'number',
type: 'numeric',
},
{
title: 'A Date',
field: 'date',
type: 'date',
},
];
return (
<Table
options={{ paging: false }}
data={testData100}
columns={columns}
title="Backstage Table"
/>
);
};
export const HiddenSearchTable = () => {
const columns: TableColumn[] = [
{
title: 'Column 1',
field: 'col1',
highlight: true,
},
{
title: 'Column 2',
field: 'col2',
},
{
title: 'Numeric value',
field: 'number',
type: 'numeric',
},
{
title: 'A Date',
field: 'date',
type: 'date',
},
];
return (
<Table
options={{ paging: false, search: false }}
data={testData100}
columns={columns}
/>
);
};
export const SubvalueTable = () => {
const columns: TableColumn[] = [
{
title: 'Column 1',
customFilterAndSearch: (
query,
row: any, // Only needed if you want subvalue searchable
) =>
`${row.col1} ${row.subvalue}`
.toUpperCase()
.includes(query.toUpperCase()),
field: 'col1',
highlight: true,
render: (row: any): React.ReactNode => (
<SubvalueCell value={row.col1} subvalue={row.subvalue} />
),
},
{
title: 'Column 2',
field: 'col2',
},
{
title: 'Numeric value',
field: 'number',
type: 'numeric',
},
{
title: 'A Date',
field: 'date',
type: 'date',
},
];
return (
<Table options={{ paging: false }} data={testData100} columns={columns} />
);
};
@@ -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('<Table />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<Table {...minProps} />));
expect(rendered.getByText('second value, second row')).toBeInTheDocument();
});
});
@@ -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<SVGSVGElement>) => (
<AddBox {...props} ref={ref} />
)),
Check: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Check {...props} ref={ref} />
)),
Clear: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Clear {...props} ref={ref} />
)),
Delete: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<DeleteOutline {...props} ref={ref} />
)),
DetailPanel: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ChevronRight {...props} ref={ref} />
)),
Edit: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Edit {...props} ref={ref} />
)),
Export: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<SaveAlt {...props} ref={ref} />
)),
Filter: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<FilterList {...props} ref={ref} />
)),
FirstPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<FirstPage {...props} ref={ref} />
)),
LastPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<LastPage {...props} ref={ref} />
)),
NextPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ChevronRight {...props} ref={ref} />
)),
PreviousPage: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ChevronLeft {...props} ref={ref} />
)),
ResetSearch: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Clear {...props} ref={ref} />
)),
Search: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Search {...props} ref={ref} />
)),
SortArrow: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ArrowUpward {...props} ref={ref} />
)),
ThirdStateCheck: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<Remove {...props} ref={ref} />
)),
ViewColumn: forwardRef((props, ref: React.Ref<SVGSVGElement>) => (
<ViewColumn {...props} ref={ref} />
)),
};
const useCellStyles = makeStyles<BackstageTheme>(theme => ({
root: {
color: theme.palette.grey[500],
padding: theme.spacing(0, 2, 0, 2.5),
height: '56px',
},
}));
const useHeaderStyles = makeStyles<BackstageTheme>(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<BackstageTheme>(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<TableProps> = ({ columns, options, ...props }) => {
const cellClasses = useCellStyles();
const headerClasses = useHeaderStyles();
const toolbarClasses = useToolbarStyles();
const MTColumns = convertColumns(columns);
const defaultOptions: Options = {
headerStyle: {
textTransform: 'uppercase',
},
};
return (
<MTable
components={{
Cell: cellProps => (
<MTableCell className={cellClasses.root} {...cellProps} />
),
Header: headerProps => (
<MTableHeader classes={headerClasses} {...headerProps} />
),
Toolbar: toolbarProps => (
<MTableToolbar classes={toolbarClasses} {...toolbarProps} />
),
}}
options={{ ...defaultOptions, ...options }}
columns={MTColumns}
icons={tableIcons}
{...props}
/>
);
};
export default Table;
@@ -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';
@@ -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 = () => (
<InfoCard style={containerStyle} title="Trends over time">
<SortableTable data={data} columns={columns} />
</InfoCard>
<div style={containerStyle}>
<InfoCard title="Trends over time">
<Table
options={{
search: false,
paging: false,
toolbar: false,
}}
data={data}
columns={columns}
/>
</InfoCard>
</div>
);
export const TrendingMix = () => (
+6 -3
View File
@@ -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';
+2 -1
View File
@@ -14,4 +14,5 @@
* limitations under the License.
*/
export { default, Props } from './BottomLink';
export { default } from './BottomLink';
export type { Props } from './BottomLink';
@@ -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('<ErrorPage/>', () => {
it('should render with status code, status message and go back link', () => {
const rendered = render(
wrapInThemedTestApp(
<ErrorPage
status="404"
statusMessage="PAGE NOT FOUND"
history={{ goBack: () => {} }}
/>,
),
);
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();
});
});
@@ -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<BackstageTheme>((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 (
<Grid container className={classes.container}>
<MicDrop />
<Grid item xs={12} sm={4}>
<Typography variant="body1" className={classes.subtitle}>
ERROR {status}: {statusMessage}
</Typography>
<Typography variant="h2" className={classes.title}>
Looks like someone dropped the mic!
</Typography>
<Typography variant="h6">
<Link data-testid="go-back-link" onClick={history.goBack}>
Go back
</Link>
... or if you think this is a bug, please file an{' '}
<Link href="https://github.com/spotify/backstage/issues">issue.</Link>
</Typography>
</Grid>
</Grid>
);
};
export default ErrorPage;
@@ -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 (
<svg
className={classes.micDrop}
width="765"
height="549"
viewBox="0 0 765 549"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M233.254 18.1123C287.613 12.9406 348.953 -19.7941 390.737 17.9035C433.262 56.2682 419.83 127.396 420.495 187.038C421.173 247.834 429.724 312.626 394.555 360.272C356.272 412.137 294.782 444.433 233.254 440.709C174.088 437.128 130.793 387.084 89.1619 341.79C47.9247 296.924 -2.50454 250.018 0.0966297 187.038C2.64988 125.217 53.6362 80.0377 101.402 45.4309C139.977 17.4827 186.903 22.5221 233.254 18.1123Z"
fill="#9BF0E1"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M513.385 20.4439C566.693 14.8938 613.809 62.3867 650.746 106.814C683.285 145.952 687.847 201.929 705.832 251.918C725.902 307.696 777.756 358.692 762.097 416.298C746.456 473.841 682.485 490.291 634.414 514.658C595.842 534.21 555.492 539.703 513.385 542.369C468.505 545.211 418.55 561.006 382.241 530.624C345.943 500.251 351.456 437.745 332.509 391.022C309.653 334.66 245.906 289.233 260.017 229.218C273.979 169.835 349.469 169.535 393.023 133.647C436.218 98.0537 460.413 25.959 513.385 20.4439Z"
fill="#69DDC7"
/>
<path
d="M584.384 331.262L618.437 362.37C620.096 363.886 622.677 363.738 624.152 362.042L625.747 360.209C627.216 358.521 627.014 355.956 625.299 354.519L590.183 325.076C589.459 324.469 588.534 324.093 587.606 324.266C584.444 324.854 583.314 327.325 583.371 329.355C583.392 330.105 583.83 330.756 584.384 331.262Z"
fill="black"
stroke="black"
/>
<circle
cx="630.54"
cy="366.006"
r="11.6617"
transform="rotate(131.021 630.54 366.006)"
fill="#616161"
/>
<rect
x="639.69"
y="357.103"
width="25.4436"
height="3.18045"
rx="1.59023"
transform="rotate(131.021 639.69 357.103)"
fill="#282828"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M331.081 60.2902C334.528 72.9923 339.02 101.544 334.436 106.022H305.436C305.433 105.971 305.43 105.92 305.427 105.868C294.679 104.679 286.319 95.5658 286.319 84.5V63.5C286.319 51.6259 295.945 42 307.819 42H309.819C320.602 42 329.531 49.9385 331.081 60.2902Z"
fill="#181818"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M297.581 80.4901C292.973 75.0814 289.854 68.4529 290.376 61.0255C291.877 39.625 321.667 44.3328 327.462 55.1339C333.257 65.9351 332.568 93.333 325.079 95.2635C322.092 96.0333 315.73 94.1475 309.254 90.2443L313.319 119H289.319L297.581 80.4901Z"
fill="#EDB795"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M304.069 76.8748C305.175 88.7528 304.732 102.317 301.436 105.537H272.436C270.82 77.4635 286.436 88.3349 286.436 61.9028C287.41 60.9515 288.33 60.01 289.24 59.1981L289.208 58.4207C294.229 48.1402 300.6 43 308.32 43C319.9 43 323.555 48.3883 326.668 52.9631C324.34 60.9482 315.309 62.1322 307.285 67.1095C306.339 66.341 305.133 65.8804 303.819 65.8804C300.781 65.8804 298.319 68.3428 298.319 71.3804C298.319 74.418 300.781 76.8804 303.819 76.8804C303.903 76.8804 303.986 76.8785 304.069 76.8748Z"
fill="#181818"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M250.727 300.088C274.716 306.798 295.413 310.153 312.818 310.153C330.224 310.153 346.39 304.986 361.319 294.653C337.843 287.766 318.51 284.323 303.319 284.323C288.128 284.323 270.597 289.578 250.727 300.088Z"
fill="#C52B86"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M333.586 319.824C333.354 319.477 333.159 319.098 333.004 318.689C329.797 310.208 278.436 218.726 273.319 202H331.145C335.035 214.717 352.546 294.52 355.01 310.442C362.785 335.557 378.794 416.895 380.838 422.53C382.988 428.458 370.891 434.633 367.668 427.346C362.539 415.75 350.317 383.665 344.876 364.074C339.775 345.708 335.621 329.184 333.586 319.824Z"
fill="#E7B291"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M286.276 330.994C262.794 336.928 176.903 347.658 171.009 349.341C164.945 351.072 159.629 338.574 167.124 335.867C179.049 331.559 211.908 321.606 231.831 317.544C247.855 314.277 262.478 311.611 272.295 310.05C271.464 282.473 267.662 216.294 270.154 202H321.319C318.929 215.707 298.729 315.718 294.975 325.729C293.783 329.302 290.228 330.882 286.276 330.994Z"
fill="#EDB795"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M167.668 332.539C164.532 331.956 162.235 331.793 160.774 332.05C158.998 332.363 156.459 333.116 153.156 334.309C153.52 336.369 156.784 354.884 162.951 389.854C167.977 390.456 170.406 389.168 170.238 385.991C170.07 382.814 169.959 380.17 169.902 378.059L176.859 350.213C176.993 349.677 176.667 349.134 176.132 349C176.123 348.998 176.113 348.996 176.104 348.994L171.786 348.044C169.935 345.402 168.851 343.181 168.534 341.381C168.276 339.919 168.51 337.795 169.237 335.01L169.237 335.01C169.516 333.941 168.876 332.849 167.807 332.57C167.761 332.558 167.714 332.548 167.668 332.539Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M363.997 426.402C362.878 429.388 362.319 431.623 362.319 433.106C362.319 434.909 362.619 437.54 363.22 441C365.312 441 384.113 441 419.622 441C421.088 436.155 420.242 433.539 417.084 433.152C413.926 432.766 411.303 432.417 409.214 432.106L382.999 420.419C382.494 420.194 381.903 420.421 381.678 420.925C381.674 420.934 381.671 420.942 381.667 420.951L379.982 425.039C377.059 426.403 374.683 427.085 372.856 427.085C371.371 427.085 369.32 426.485 366.703 425.286L366.703 425.285C365.699 424.825 364.512 425.266 364.052 426.27C364.032 426.313 364.014 426.357 363.997 426.402Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M270.319 202C292.866 195.549 314.199 195.549 334.319 202C341.319 233.937 358.328 246.613 361.319 295.579C327.319 312.473 285.319 283.53 250.319 300.005C240.319 276.961 254.319 221.464 270.319 202Z"
fill="#F038A5"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M367.319 162.311L406.802 156.925C414.7 152.532 421.966 149.558 428.599 148.003C430.466 147.948 433.436 148.627 429.012 152.315C424.588 156.003 420.099 160.083 420.887 162.051C421.675 164.02 425.79 163.287 426.471 166.315C426.926 168.333 420.248 168.661 406.44 167.299L374.403 180L367.319 162.311ZM243.288 171.45L265.186 171.532C241.175 225.566 228.655 253.857 227.627 256.406C225.313 262.14 230.209 270.874 232.303 275.567C225.483 278.619 226.21 267.317 217.607 271.319C209.754 274.973 203.78 281.592 194.708 275.994C193.592 275.306 192.37 272.715 195.323 270.69C202.681 265.645 213.284 256.815 214.717 253.908C216.67 249.945 226.194 222.459 243.288 171.45Z"
fill="#EDB795"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M303.268 102L320.413 103.142C324.635 141.343 347.635 165.473 399.13 157.304L405.971 206.499C357.841 212.719 315.632 193.45 306.981 141.278C304.742 127.773 302.973 113.718 303.268 102Z"
fill="#EEEEEE"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M294.809 97.0159L320.786 102.965C320.786 147.644 335.019 176.15 342.319 209H272.162C271.174 220.351 270.567 232.018 270.251 244H217.72C229.624 180.664 254.749 131.664 293.094 97H294.796L294.809 97.0159Z"
fill="white"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M283.231 145.259C281.423 173.101 282.486 194.347 286.421 209H272.162C274.12 186.514 277.575 165.267 283.231 145.259V145.259Z"
fill="#EEEEEE"
/>
<path
d="M420 184C438.174 208.939 473.047 287.772 467.144 403.592C469.74 373.957 481.656 315.366 508.548 318.087C535.441 320.808 531.505 401.163 526.176 441C532.052 385.292 552.334 355.087 573.5 351"
stroke="#F9F9F9"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="10 15"
/>
</svg>
);
};
export default MicDrop;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './DefaultWidgetView';
export { default } from './ErrorPage';
+4 -12
View File
@@ -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<Props> = ({
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']],
+2 -1
View File
@@ -15,4 +15,5 @@
*/
export { default } from './Page';
export { gradients, pageTheme, PageTheme } from './PageThemeProvider';
export { gradients, pageTheme } from './PageThemeProvider';
export type { PageTheme } from './PageThemeProvider';
+1 -1
View File
@@ -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",
+9 -2
View File
@@ -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) => (
<ThemeProvider theme={useDarkMode() ? darkTheme : lightTheme}>
<CssBaseline>
<Content>{story()}</Content>
</CssBaseline>
</ThemeProvider>
));
addParameters({
darkMode: {
// Set the initial theme
current: 'light',
},
});
+40 -24
View File
@@ -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;
},
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.json",
"include": [".storybook/**/*"],
"compilerOptions": {}
}
+3 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+1 -1
View File
@@ -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",
+2 -2
View File
@@ -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',
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+6
View File
@@ -0,0 +1,6 @@
# Title
Welcome to the explore plugin!
## Sub-section 1
## Sub-section 2
+39
View File
@@ -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"
]
}
@@ -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('<ExploreCard />', () => {
it('renders without exploding', () => {
const { getByText } = render(
wrapInThemedTestApp(<ExploreCard {...minProps} />),
);
expect(getByText('Explore')).toBeInTheDocument();
});
it('renders props correctly', () => {
const { getByText } = render(
wrapInThemedTestApp(<ExploreCard {...minProps} />),
);
expect(getByText(minProps.card.title)).toBeInTheDocument();
expect(getByText(minProps.card.description)).toBeInTheDocument();
});
it('should link out', () => {
const rendered = render(wrapInThemedTestApp(<ExploreCard {...minProps} />));
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(<ExploreCard {...propsWithoutDescription} />),
);
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(<ExploreCard {...propsWithLifecycle} />),
);
expect(queryByText('GA')).not.toBeInTheDocument();
});
it('renders tags correctly', () => {
const { getByText } = render(
wrapInThemedTestApp(<ExploreCard {...minProps} />),
);
expect(getByText(minProps.card.tags[0])).toBeInTheDocument();
expect(getByText(minProps.card.tags[1])).toBeInTheDocument();
});
});
@@ -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<BackstageTheme>((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<Props> = ({ card, objectFit }) => {
const classes = useStyles();
const { title, description, url, image, lifecycle, newsTag, tags } = card;
return (
<Card key={title} className={classes.card}>
<CardMedia
image={image}
title={title}
className={classNames(classes.media, {
[classes.mediaContain]: objectFit === 'contain',
})}
/>
<CardContent>
<Typography gutterBottom variant="h5">
{title}{' '}
{lifecycle && lifecycle.toLowerCase() !== 'ga' && (
<Chip
label={lifecycle}
className={classNames(
classes.lifecycle,
classes[lifecycle.toLowerCase()],
)}
/>
)}
</Typography>
<Typography paragraph>
{description || 'Description missing'}
</Typography>
{tags && (
<div className={classes.domains}>
{tags.map((item, idx) => (
<Chip key={idx} label={item} />
))}
</div>
)}
</CardContent>
<CardActions
className={classNames(classes.cardActions, {
[classes.spaceBetween]: newsTag,
})}
>
<Button size="small" color="primary" href={url} disabled={!url}>
Explore
</Button>
</CardActions>
</Card>
);
};
export default ExploreCard;
@@ -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<BackstageTheme>((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 (
<Page theme={pageTheme.home}>
<Header
title="Explore"
subtitle="Tools and services available in Backstage"
/>
<Content>
<ContentHeader title="Tools">
<SupportButton>
<Typography>Explore tools available in Backstage</Typography>
</SupportButton>
</ContentHeader>
<div className={classes.container}>
{toolsCards.map((card: CardData, ix: any) => (
<ExploreCard card={card} key={ix} />
))}
</div>
</Content>
</Page>
);
};
export default ExplorePluginPage;

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