frontend: move to repo root
This commit is contained in:
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
require('../cjs');
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@spotify-backstage/cli",
|
||||
"version": "1.3.0",
|
||||
"main": "src/index.ts",
|
||||
"main:src": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"exec": "npx ts-node ./src",
|
||||
"build": "web-scripts build",
|
||||
"lint": "web-scripts lint",
|
||||
"test": "web-scripts test",
|
||||
"start": "nodemon ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spotify/web-scripts": "^6.0.0",
|
||||
"@types/fs-extra": "^8.1.0",
|
||||
"@types/html-webpack-plugin": "^3.2.2",
|
||||
"@types/inquirer": "^6.5.0",
|
||||
"@types/node": "^13.7.2",
|
||||
"@types/react-dev-utils": "^9.0.4",
|
||||
"@types/recursive-readdir": "^2.2.0",
|
||||
"@types/webpack": "^4.41.7",
|
||||
"@types/webpack-dev-server": "^3.10.0",
|
||||
"del": "^5.1.0",
|
||||
"nodemon": "^2.0.2",
|
||||
"ts-node": "^8.6.2"
|
||||
},
|
||||
"bin": {
|
||||
"backstage-cli": "bin/backstage-cli"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.3.1",
|
||||
"commander": "^4.1.1",
|
||||
"dashify": "^2.0.0",
|
||||
"fork-ts-checker-webpack-plugin": "^4.0.5",
|
||||
"fs-extra": "^8.1.0",
|
||||
"handlebars": "^4.7.3",
|
||||
"html-webpack-plugin": "^3.2.0",
|
||||
"inquirer": "^7.0.4",
|
||||
"react-dev-utils": "^10.2.0",
|
||||
"recursive-readdir": "^2.2.2",
|
||||
"replace-in-file": "^5.0.2",
|
||||
"ts-loader": "^6.2.1",
|
||||
"webpack": "^4.41.6",
|
||||
"webpack-dev-server": "^3.10.3"
|
||||
},
|
||||
"files": [
|
||||
"templates",
|
||||
"bin",
|
||||
"cjs"
|
||||
],
|
||||
"nodemonConfig": {
|
||||
"watch": "./src",
|
||||
"exec": "ts-node",
|
||||
"ext": "ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import del from 'del';
|
||||
import {
|
||||
createFileFromTemplate,
|
||||
createFromTemplateDir,
|
||||
createPluginFolder,
|
||||
} from './createPlugin';
|
||||
|
||||
describe('createPlugin', () => {
|
||||
describe('createPluginFolder', () => {
|
||||
it('should create a plugin directory in the correct place', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
|
||||
try {
|
||||
const pluginFolder = createPluginFolder(tempDir, 'foo');
|
||||
expect(fs.existsSync(pluginFolder)).toBe(true);
|
||||
expect(pluginFolder).toMatch(/packages\/plugins\/foo/);
|
||||
} finally {
|
||||
del.sync(tempDir, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should not create a plugin directory if it already exists', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
|
||||
try {
|
||||
const pluginFolder = createPluginFolder(tempDir, 'foo');
|
||||
expect(fs.existsSync(pluginFolder)).toBe(true);
|
||||
expect(() => createPluginFolder(tempDir, 'foo')).toThrowError(
|
||||
/A plugin with the same name already exists/,
|
||||
);
|
||||
} finally {
|
||||
del.sync(tempDir, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFileFromTemplate', () => {
|
||||
it('should generate a valid output with inserted values', () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
|
||||
try {
|
||||
const sourceData = '{"name": "@spotify-backstage/{{id}}"}';
|
||||
const targetData = '{"name": "@spotify-backstage/foo"}';
|
||||
const sourcePath = path.join(tempDir, 'in.hbs');
|
||||
const targetPath = path.join(tempDir, 'out.json');
|
||||
fs.writeFileSync(sourcePath, sourceData);
|
||||
|
||||
createFileFromTemplate(sourcePath, targetPath, { id: 'foo' });
|
||||
|
||||
expect(fs.existsSync(targetPath)).toBe(true);
|
||||
expect(fs.readFileSync(targetPath).toString()).toBe(targetData);
|
||||
} finally {
|
||||
del.sync(tempDir, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createFromTemplateDir', () => {
|
||||
it('should create sub-directories and files', async () => {
|
||||
const templateRootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-'));
|
||||
const templateSubDir = fs.mkdtempSync(path.join(templateRootDir, 'sub-'));
|
||||
fs.writeFileSync(path.join(templateSubDir, 'test.txt'), 'testing');
|
||||
|
||||
const destinationRootDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'test-'),
|
||||
);
|
||||
const subDir = path.join(
|
||||
destinationRootDir,
|
||||
path.basename(templateSubDir),
|
||||
);
|
||||
const testFile = path.join(
|
||||
destinationRootDir,
|
||||
path.basename(templateSubDir),
|
||||
'test.txt',
|
||||
);
|
||||
try {
|
||||
await createFromTemplateDir(templateRootDir, destinationRootDir, {});
|
||||
expect(fs.existsSync(subDir)).toBe(true);
|
||||
expect(fs.existsSync(testFile)).toBe(true);
|
||||
} finally {
|
||||
await del(templateRootDir, { force: true });
|
||||
await del(destinationRootDir, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import handlebars from 'handlebars';
|
||||
import chalk from 'chalk';
|
||||
import inquirer, { Answers, Question } from 'inquirer';
|
||||
import recursive from 'recursive-readdir';
|
||||
import { promisify } from 'util';
|
||||
import { exec } from 'child_process';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { realpathSync, existsSync } from 'fs';
|
||||
|
||||
const MARKER_SUCCESS = chalk.green(` ✓\n`);
|
||||
const MARKER_FAILURE = chalk.red(` ✗\n`);
|
||||
|
||||
export const createPluginFolder = (rootDir: string, id: string): string => {
|
||||
console.log();
|
||||
console.log(chalk.green(' Creating the plugin directory:'));
|
||||
|
||||
const destination = path.join(rootDir, 'packages', 'plugins', id);
|
||||
|
||||
if (fs.existsSync(destination)) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
` failed:\t ✗ ${chalk.cyan(destination.replace(`${rootDir}/`, ''))}`,
|
||||
),
|
||||
);
|
||||
throw new Error(
|
||||
`A plugin with the same name already exists: ${chalk.cyan(
|
||||
destination.replace(`${rootDir}/`, ''),
|
||||
)}\nPlease try again with a different Plugin ID`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
chalk.green(
|
||||
` creating\t${chalk.cyan(destination.replace(`${rootDir}/`, ''))}`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
process.stdout.write(chalk.green(' ✓\n'));
|
||||
return destination;
|
||||
} catch (e) {
|
||||
process.stdout.write(chalk.red(` ✗\n`));
|
||||
throw new Error(
|
||||
`Failed to create plugin directory: ${destination}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const createFileFromTemplate = (
|
||||
source: string,
|
||||
destination: string,
|
||||
answers: Answers,
|
||||
) => {
|
||||
const template = fs.readFileSync(source);
|
||||
const compiled = handlebars.compile(template.toString());
|
||||
const contents = compiled({
|
||||
name: path.basename(destination),
|
||||
...answers,
|
||||
});
|
||||
try {
|
||||
fs.writeFileSync(destination, contents);
|
||||
process.stdout.write(MARKER_SUCCESS);
|
||||
} catch (e) {
|
||||
process.stdout.write(MARKER_FAILURE);
|
||||
throw new Error(`Failed to create file: ${destination}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const sortObjectByKeys = (obj: { [name in string]: string }) => {
|
||||
return Object.keys(obj)
|
||||
.sort()
|
||||
.reduce((result, key: string) => {
|
||||
result[key] = obj[key];
|
||||
return result;
|
||||
}, {} as { [name in string]: string });
|
||||
};
|
||||
|
||||
const capitalize = (str: string): string =>
|
||||
str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
const addExportStatement = (file: string, exportStatement: string) => {
|
||||
const newContents = fs
|
||||
.readFileSync(file, 'utf8')
|
||||
.split('\n')
|
||||
.filter(Boolean) // get rid of empty lines
|
||||
.concat([exportStatement])
|
||||
.sort()
|
||||
.concat(['']) // newline at end of file
|
||||
.join('\n');
|
||||
|
||||
fs.writeFileSync(file, newContents, 'utf8');
|
||||
};
|
||||
|
||||
export const addPluginDependencyToApp = (
|
||||
rootDir: string,
|
||||
pluginName: string,
|
||||
) => {
|
||||
console.log();
|
||||
console.log(chalk.green(' Adding plugin as dependency in app:'));
|
||||
|
||||
const pluginPackage = `@spotify-backstage/plugin-${pluginName}`;
|
||||
const pluginPackageVersion = '0.0.0';
|
||||
const packageFile = path.join(rootDir, 'packages', 'app', 'package.json');
|
||||
|
||||
process.stdout.write(
|
||||
chalk.green(
|
||||
` processing\t${chalk.cyan(packageFile.replace(`${rootDir}/`, ''))}`,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const packageFileContent = fs.readFileSync(packageFile, 'utf-8');
|
||||
const packageFileJson = JSON.parse(packageFileContent);
|
||||
const dependencies = packageFileJson.dependencies;
|
||||
|
||||
if (dependencies[pluginPackage]) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginPackage} already exists in ${packageFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
dependencies[pluginPackage] = pluginPackageVersion;
|
||||
packageFileJson.dependencies = sortObjectByKeys(dependencies);
|
||||
fs.writeFileSync(
|
||||
packageFile,
|
||||
`${JSON.stringify(packageFileJson, null, 2)}\n`,
|
||||
'utf-8',
|
||||
);
|
||||
} catch (e) {
|
||||
process.stdout.write(chalk.red(` ✗\n`));
|
||||
throw new Error(
|
||||
`Failed to add plugin as dependency in app: ${packageFile}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(MARKER_SUCCESS);
|
||||
};
|
||||
|
||||
export const addPluginToApp = (rootDir: string, pluginName: string) => {
|
||||
console.log();
|
||||
console.log(chalk.green(' Import plugin in app:'));
|
||||
|
||||
const pluginPackage = `@spotify-backstage/plugin-${pluginName}`;
|
||||
const pluginNameCapitalized = pluginName
|
||||
.split('-')
|
||||
.map(name => capitalize(name))
|
||||
.join('');
|
||||
const pluginExport = `export { default as ${pluginNameCapitalized} } from '${pluginPackage}';`;
|
||||
const pluginsFile = path.join(
|
||||
rootDir,
|
||||
'packages',
|
||||
'app',
|
||||
'src',
|
||||
'plugins.ts',
|
||||
);
|
||||
process.stdout.write(
|
||||
chalk.green(
|
||||
` processing\t${chalk.cyan(pluginsFile.replace(`${rootDir}/`, ''))}`,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
addExportStatement(pluginsFile, pluginExport);
|
||||
} catch (e) {
|
||||
process.stdout.write(chalk.red(` ✗\n`));
|
||||
throw new Error(
|
||||
`Failed to import plugin in app: ${pluginsFile}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
process.stdout.write(MARKER_SUCCESS);
|
||||
};
|
||||
|
||||
export const createFromTemplateDir = async (
|
||||
templateFolder: string,
|
||||
destinationFolder: string,
|
||||
answers: Answers,
|
||||
) => {
|
||||
console.log();
|
||||
console.log(chalk.green(' Reading template files:'));
|
||||
|
||||
let files = [];
|
||||
|
||||
process.stdout.write(chalk.green(` reading\t`));
|
||||
try {
|
||||
files = await recursive(templateFolder);
|
||||
process.stdout.write(
|
||||
chalk.green(`${chalk.cyan(`${files.length} files`)} ✓\n`),
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(chalk.red(` ✗ 0 files\n`));
|
||||
throw new Error(`Failed to read files in template directory: ${e.message}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(chalk.green(' Setting up the plugin files:'));
|
||||
files.forEach(file => {
|
||||
process.stdout.write(
|
||||
chalk.green(` processing\t${chalk.cyan(path.basename(file))}`),
|
||||
);
|
||||
fs.ensureDirSync(
|
||||
file
|
||||
.replace(templateFolder, destinationFolder)
|
||||
.replace(path.basename(file), ''),
|
||||
);
|
||||
if (file.endsWith('hbs')) {
|
||||
createFileFromTemplate(
|
||||
file,
|
||||
file.replace(templateFolder, destinationFolder).replace(/\.hbs$/, ''),
|
||||
answers,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
fs.copyFileSync(file, file.replace(templateFolder, destinationFolder));
|
||||
process.stdout.write(MARKER_SUCCESS);
|
||||
} catch (e) {
|
||||
process.stdout.write(MARKER_FAILURE);
|
||||
throw new Error(
|
||||
`Failed to copy file: ${file.replace(
|
||||
templateFolder,
|
||||
destinationFolder,
|
||||
)}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const cleanUp = async (rootDir: string, id: string) => {
|
||||
const destination = path.join(rootDir, 'packages', 'plugins', id);
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'cleanup',
|
||||
message: chalk.yellow(
|
||||
`It seems that something went wrong when creating the plugin 🤔\nDo you want to remove the following directory and all the files in it:\n${chalk.cyan(
|
||||
destination,
|
||||
)}`,
|
||||
),
|
||||
},
|
||||
];
|
||||
const answers: Answers = await inquirer.prompt(questions);
|
||||
|
||||
if (answers.cleanup) {
|
||||
console.log();
|
||||
console.log(chalk.green(`🧹 Cleaning up...`));
|
||||
console.log();
|
||||
console.log(chalk.green(` Removing plugin:`));
|
||||
process.stdout.write(
|
||||
chalk.green(
|
||||
` deleting\t${chalk.cyan(destination.replace(`${rootDir}/`, ''))}`,
|
||||
),
|
||||
);
|
||||
try {
|
||||
// Not using recursion here, so only empty directories can be removed
|
||||
fs.rmdirSync(destination);
|
||||
process.stdout.write(MARKER_SUCCESS);
|
||||
console.log();
|
||||
} catch (e) {
|
||||
process.stdout.write(MARKER_FAILURE);
|
||||
console.log();
|
||||
console.log(chalk.red(`Failed to cleanup: ${e.message}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildPlugin = async (pluginFolder: string) => {
|
||||
console.log();
|
||||
console.log(chalk.green(` Building the plugin:`));
|
||||
|
||||
const prom_exec = promisify(exec);
|
||||
|
||||
const commands = ['yarn install', 'yarn build'];
|
||||
for (const command of commands) {
|
||||
process.stdout.write(chalk.green(` executing\t${chalk.cyan(command)}`));
|
||||
try {
|
||||
process.chdir(pluginFolder);
|
||||
await prom_exec(command, { timeout: 60000 });
|
||||
process.stdout.write(MARKER_SUCCESS);
|
||||
} catch (e) {
|
||||
process.stdout.write(MARKER_FAILURE);
|
||||
throw new Error(
|
||||
`Could not execute command ${chalk.cyan(command)}: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createPlugin = async (): Promise<any> => {
|
||||
const questions: Question[] = [
|
||||
{
|
||||
type: 'input',
|
||||
name: 'id',
|
||||
message: chalk.blue('Enter an ID for the plugin [required]'),
|
||||
validate: (value: any) =>
|
||||
value ? true : chalk.red('Please enter an ID for the plugin'),
|
||||
},
|
||||
];
|
||||
const answers: Answers = await inquirer.prompt(questions);
|
||||
|
||||
const rootDir = realpathSync(process.cwd());
|
||||
const appPackage = resolvePath(rootDir, 'packages', 'app');
|
||||
const cliPackage = resolvePath(__dirname, '..', '..');
|
||||
const templateFolder = resolvePath(cliPackage, 'templates', 'default-plugin');
|
||||
|
||||
try {
|
||||
console.log();
|
||||
console.log(chalk.green('🧩 Creating the plugin...'));
|
||||
|
||||
const destinationFolder = createPluginFolder(rootDir, answers.id);
|
||||
await createFromTemplateDir(templateFolder, destinationFolder, answers);
|
||||
await buildPlugin(destinationFolder);
|
||||
|
||||
if (existsSync(appPackage)) {
|
||||
addPluginDependencyToApp(rootDir, answers.id);
|
||||
addPluginToApp(rootDir, answers.id);
|
||||
}
|
||||
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.green(
|
||||
`🥇 Successfully created ${chalk.cyan(
|
||||
`@spotify-backstage/plugin-${answers.id}`,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
console.log();
|
||||
|
||||
return destinationFolder;
|
||||
} catch (e) {
|
||||
console.log();
|
||||
console.log(`${chalk.red(e.message)}`);
|
||||
console.log();
|
||||
console.log(`🔥 ${chalk.red('Failed to create plugin!')}`);
|
||||
console.log();
|
||||
|
||||
await cleanUp(rootDir, answers.id);
|
||||
}
|
||||
};
|
||||
|
||||
export default createPlugin;
|
||||
@@ -0,0 +1,111 @@
|
||||
import webpack from 'webpack';
|
||||
import HtmlWebpackPlugin from 'html-webpack-plugin';
|
||||
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
|
||||
import ModuleScopePlugin from 'react-dev-utils/ModuleScopePlugin';
|
||||
import { Paths } from './paths';
|
||||
// import checkRequiredFiles from 'react-dev-utils/checkRequiredFiles';
|
||||
// import ModuleNotFoundPlugin from 'react-dev-utils/ModuleNotFoundPlugin';
|
||||
// import errorOverlayMiddleware from 'react-dev-utils/errorOverlayMiddleware';
|
||||
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
|
||||
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
|
||||
|
||||
export function createConfig(paths: Paths): webpack.Configuration {
|
||||
return {
|
||||
mode: 'development',
|
||||
profile: false,
|
||||
bail: false,
|
||||
devtool: 'cheap-module-eval-source-map',
|
||||
context: paths.appPath,
|
||||
entry: [
|
||||
`${require.resolve('webpack-dev-server/client')}?/`,
|
||||
require.resolve('webpack/hot/dev-server'),
|
||||
paths.appDevEntry,
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.ts', '.tsx', '.js', '.jsx'],
|
||||
plugins: [
|
||||
new ModuleScopePlugin(
|
||||
[paths.appSrc, paths.appDev],
|
||||
[paths.appPackageJson],
|
||||
),
|
||||
],
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(tsx?|jsx?|mjs)$/,
|
||||
enforce: 'pre',
|
||||
include: [paths.appSrc, paths.appDev],
|
||||
use: {
|
||||
loader: 'eslint-loader',
|
||||
options: {
|
||||
emitWarning: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(tsx?|jsx?|mjs)$/,
|
||||
include: [paths.appSrc, paths.appDev],
|
||||
exclude: /node_modules/,
|
||||
loader: 'ts-loader',
|
||||
options: {
|
||||
// disable type checker - handled by ForkTsCheckerWebpackPlugin
|
||||
transpileOnly: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
|
||||
loader: 'url-loader',
|
||||
include: paths.appAssets,
|
||||
options: {
|
||||
limit: 10000,
|
||||
name: 'static/media/[name].[hash:8].[ext]',
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.ya?ml$/,
|
||||
use: 'yml-loader',
|
||||
},
|
||||
{
|
||||
include: /\.(md)$/,
|
||||
use: 'raw-loader',
|
||||
},
|
||||
{
|
||||
test: /\.css$/i,
|
||||
use: ['style-loader', 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
output: {
|
||||
publicPath: '/',
|
||||
filename: 'bundle.js',
|
||||
},
|
||||
plugins: [
|
||||
new HtmlWebpackPlugin({
|
||||
template: paths.appHtml,
|
||||
}),
|
||||
new ForkTsCheckerWebpackPlugin({
|
||||
tsconfig: paths.appTsConfig,
|
||||
eslint: true,
|
||||
eslintOptions: {
|
||||
parserOptions: {
|
||||
project: paths.appTsConfig,
|
||||
tsconfigRootDir: paths.appPath,
|
||||
},
|
||||
},
|
||||
reportFiles: ['**', '!**/__tests__/**', '!**/?(*.)(spec|test).*'],
|
||||
}),
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
],
|
||||
node: {
|
||||
module: 'empty',
|
||||
dgram: 'empty',
|
||||
dns: 'mock',
|
||||
fs: 'empty',
|
||||
http2: 'empty',
|
||||
net: 'empty',
|
||||
tls: 'empty',
|
||||
child_process: 'empty',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import chalk from 'chalk';
|
||||
import { startDevServer } from './server';
|
||||
|
||||
export default async () => {
|
||||
try {
|
||||
await startDevServer();
|
||||
} catch (error) {
|
||||
process.stderr.write(`${chalk.red(error.message)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { existsSync, realpathSync } from 'fs';
|
||||
|
||||
export function getPaths() {
|
||||
const appDir = realpathSync(process.cwd());
|
||||
|
||||
const resolveApp = (path: string) => resolvePath(appDir, path);
|
||||
const resolveOwn = (path: string) => resolvePath(__dirname, '..', path);
|
||||
const resolveAppModule = (path: string) => {
|
||||
for (const ext of ['mjs', 'js', 'ts', 'tsx', 'jsx']) {
|
||||
const filePath = resolveApp(`${path}.${ext}`);
|
||||
if (existsSync(filePath)) {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
return resolveApp(`${path}.js`);
|
||||
};
|
||||
|
||||
let appHtml = resolveApp('dev/index.html');
|
||||
if (!existsSync(appHtml)) {
|
||||
appHtml = resolveOwn('../../templates/serve_index.html');
|
||||
}
|
||||
|
||||
return {
|
||||
appHtml,
|
||||
appPath: resolveApp('.'),
|
||||
appAssets: resolveApp('assets'),
|
||||
appSrc: resolveApp('src'),
|
||||
appDev: resolveApp('dev'),
|
||||
appDevEntry: resolveAppModule('dev/index'),
|
||||
appTsConfig: resolveApp('tsconfig.json'),
|
||||
appNodeModules: resolveApp('node_modules'),
|
||||
appPackageJson: resolveApp('package.json'),
|
||||
};
|
||||
}
|
||||
|
||||
export type Paths = ReturnType<typeof getPaths>;
|
||||
@@ -0,0 +1,43 @@
|
||||
import webpack from 'webpack';
|
||||
import WebpackDevServer from 'webpack-dev-server';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
|
||||
import { getPaths } from './paths';
|
||||
import { createConfig } from './config';
|
||||
|
||||
export async function startDevServer() {
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
|
||||
|
||||
const port = await choosePort(host, defaultPort);
|
||||
if (!port) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = process.env.HTTPS === 'true' ? 'https' : 'http';
|
||||
const urls = prepareUrls(protocol, host, port);
|
||||
|
||||
const paths = getPaths();
|
||||
const config = createConfig(paths);
|
||||
const compiler = webpack(config);
|
||||
const server = new WebpackDevServer(compiler, {
|
||||
hot: true,
|
||||
publicPath: '/',
|
||||
quiet: true,
|
||||
https: protocol === 'https',
|
||||
host,
|
||||
port,
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.listen(port, host, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
openBrowser(urls.localUrlForBrowser);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import { createLogger } from './logger';
|
||||
|
||||
export function startChild(args: string[]) {
|
||||
const [command, ...commandArgs] = args;
|
||||
const child = spawn(command, commandArgs, {
|
||||
env: { FORCE_COLOR: 'true', ...process.env },
|
||||
stdio: ['inherit', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// We need to avoid clearing the terminal, or the build feedback of dependencies will be lost
|
||||
const log = createLogger();
|
||||
child.stdout!.on('data', (data: Buffer) => {
|
||||
log.out(data.toString('utf8'));
|
||||
});
|
||||
child.stderr!.on('data', data => {
|
||||
log.err(data.toString('utf8'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { Logger } from './logger';
|
||||
import chalk from 'chalk';
|
||||
import { Package } from './packages';
|
||||
|
||||
export function startCompiler(pkg: Package, log: Logger) {
|
||||
// First we figure out which yarn script is a available, falling back to "build --watch"
|
||||
const scriptName = ['build:watch', 'watch'].find(
|
||||
script => script in pkg.scripts,
|
||||
);
|
||||
const args = scriptName ? [scriptName] : ['build', '--watch'];
|
||||
|
||||
// Start the watch script inside the dependency
|
||||
const watch = spawn('yarn', ['run', ...args], {
|
||||
cwd: pkg.location,
|
||||
env: { FORCE_COLOR: 'true', ...process.env },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
watch.stdin.end();
|
||||
watch.stdout!.on('data', (data: Buffer) => {
|
||||
log.out(data.toString('utf8'));
|
||||
});
|
||||
watch.stderr!.on('data', data => {
|
||||
log.err(data.toString('utf8'));
|
||||
});
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
watch.on('error', error => {
|
||||
reject(error);
|
||||
});
|
||||
|
||||
watch.on('close', (code: number) => {
|
||||
if (code !== 0) {
|
||||
const msg = `Compiler exited with code ${code}`;
|
||||
log.err(chalk.red(msg));
|
||||
reject(new Error(msg));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
close() {
|
||||
watch.kill('SIGINT');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { createLoggerFactory } from './logger';
|
||||
import { getPackageDeps } from './packages';
|
||||
import { startWatcher, startPackageWatcher } from './watcher';
|
||||
import { startCompiler } from './compiler';
|
||||
import { startChild } from './child';
|
||||
|
||||
const PACKAGE_BLACKLIST = [
|
||||
// We never want to watch for changes in the cli, but all packages will depend on it.
|
||||
'@spotify-backstage/cli',
|
||||
];
|
||||
|
||||
const WATCH_LOCATIONS = ['package.json', 'src', 'assets'];
|
||||
|
||||
/*
|
||||
* The watch-deps command is meant to improve iteration speed while working in a large monorepo
|
||||
* with packages that are built independently, meaning packages depends on each other's build output.
|
||||
*
|
||||
* The command traverses all dependencies of the current package within the monorepo, and starts
|
||||
* watching for updates in all those packages. If a change is detected, we stop listening for changes,
|
||||
* and instead start up watch mode for that package. Starting watch mode means running the first
|
||||
* available yarn script out of "build:watch", "watch", or "build" --watch.
|
||||
*/
|
||||
export default async (_command: any, args: string[]) => {
|
||||
const localPackagePath = resolvePath('package.json');
|
||||
|
||||
// Rotate through different prefix colors to make it easier to differenciate between different deps
|
||||
const logFactory = createLoggerFactory([
|
||||
chalk.yellow,
|
||||
chalk.blue,
|
||||
chalk.magenta,
|
||||
chalk.green,
|
||||
chalk.cyan,
|
||||
]);
|
||||
|
||||
// Find all direct and transitive local dependencies of the current package.
|
||||
const deps = await getPackageDeps(localPackagePath, PACKAGE_BLACKLIST);
|
||||
|
||||
// We lazily watch all our deps, as in we don't start the actual watch compiler until a change is detected
|
||||
const watcher = await startWatcher(deps, WATCH_LOCATIONS, pkg => {
|
||||
startCompiler(pkg, logFactory(pkg.name)).promise.catch(error => {
|
||||
process.stderr.write(`${error}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
await startPackageWatcher(localPackagePath, async () => {
|
||||
const newDeps = await getPackageDeps(localPackagePath, PACKAGE_BLACKLIST);
|
||||
await watcher.update(newDeps);
|
||||
});
|
||||
|
||||
if (args?.length) {
|
||||
startChild(args);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
export type Logger = {
|
||||
out(msg: string): void;
|
||||
err(msg: string): void;
|
||||
};
|
||||
|
||||
export type ColorFunc = (msg: string) => string;
|
||||
|
||||
// Logger utility that prefixes logs and removes terminal clear commands
|
||||
export function createLogger(prefix: string = ''): Logger {
|
||||
const write = (stream: NodeJS.WriteStream, msg: string) => {
|
||||
const noClearMsg = msg.startsWith('\x1b\x63') ? msg.slice(2) : msg;
|
||||
const prefixedMsg = noClearMsg.trimRight().replace(/^/gm, prefix);
|
||||
stream.write(`${prefixedMsg}\n`, 'utf8');
|
||||
};
|
||||
|
||||
return {
|
||||
out(msg: string) {
|
||||
write(process.stdout, msg);
|
||||
},
|
||||
err(msg: string) {
|
||||
write(process.stderr, msg);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A factory for creating loggers that rotate between different coloring functions
|
||||
export function createLoggerFactory(colorFuncs: ColorFunc[]) {
|
||||
let colorIndex = 0;
|
||||
|
||||
return (name: string) => {
|
||||
const colorFunc = colorFuncs[colorIndex];
|
||||
|
||||
colorIndex = (colorIndex + 1) % colorFuncs.length;
|
||||
|
||||
const prefix = `${colorFunc(name)}: `;
|
||||
return createLogger(prefix);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import fs from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
|
||||
const LernaProject = require('@lerna/project');
|
||||
const PackageGraph = require('@lerna/package-graph');
|
||||
|
||||
export type Package = {
|
||||
name: string;
|
||||
location: string;
|
||||
scripts: { [name in string]: string };
|
||||
};
|
||||
|
||||
// Uses lerna to find all local deps of the root package, excluding itself or any package in the blacklist
|
||||
export async function findAllDeps(
|
||||
rootPackageName: string,
|
||||
blacklist: string[],
|
||||
): Promise<Package[]> {
|
||||
const project = new LernaProject(resolvePath('.'));
|
||||
const packages = await project.getPackages();
|
||||
const graph = new PackageGraph(packages);
|
||||
|
||||
const deps = new Map<string, any>();
|
||||
const searchNames = [rootPackageName];
|
||||
|
||||
while (searchNames.length) {
|
||||
const name = searchNames.pop()!;
|
||||
|
||||
if (deps.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const node = graph.get(name);
|
||||
if (!node) {
|
||||
throw new Error(`Package '${name}' not found`);
|
||||
}
|
||||
|
||||
searchNames.push(...node.localDependencies.keys());
|
||||
deps.set(name, node.pkg);
|
||||
}
|
||||
|
||||
deps.delete(rootPackageName);
|
||||
for (const name of blacklist) {
|
||||
deps.delete(name);
|
||||
}
|
||||
|
||||
return [...deps.values()];
|
||||
}
|
||||
|
||||
export async function getPackageDeps(packagePath: string, blacklist: string[]) {
|
||||
const packageData = await readFile(packagePath, 'utf8');
|
||||
const packageJson = JSON.parse(packageData);
|
||||
|
||||
return await findAllDeps(packageJson.name, blacklist);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import chalk from 'chalk';
|
||||
import chokidar from 'chokidar';
|
||||
import { Package } from './packages';
|
||||
import { createLogger } from './logger';
|
||||
|
||||
export type Watcher = {
|
||||
update(newPackages: Package[]): Promise<void>;
|
||||
};
|
||||
|
||||
/*
|
||||
* Watch for changes inside a collection of packages. When a change is detected, stop
|
||||
* watching and call the callback with the package the change occured in.
|
||||
*
|
||||
* The returned promise is resolved once all watchers are ready.
|
||||
*/
|
||||
export async function startWatcher(
|
||||
packages: Package[],
|
||||
paths: string[],
|
||||
callback: (pkg: Package) => void,
|
||||
): Promise<Watcher> {
|
||||
const watchedPackageLocations = new Set<string>();
|
||||
const logger = createLogger();
|
||||
|
||||
const watchPackage = async (pkg: Package) => {
|
||||
let signalled = false;
|
||||
watchedPackageLocations.add(pkg.location);
|
||||
|
||||
const watchLocations = paths.map(path => resolvePath(pkg.location, path));
|
||||
const watcher = chokidar
|
||||
.watch(watchLocations, {
|
||||
cwd: pkg.location,
|
||||
ignoreInitial: true,
|
||||
disableGlobbing: true,
|
||||
})
|
||||
.on('all', () => {
|
||||
if (!signalled) {
|
||||
signalled = true;
|
||||
callback(pkg);
|
||||
}
|
||||
watcher.close();
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
watcher.on('ready', resolve);
|
||||
watcher.on('error', reject);
|
||||
});
|
||||
};
|
||||
|
||||
const update = async (newPackages: Package[]) => {
|
||||
const promises = new Array<Promise<unknown>>();
|
||||
|
||||
for (const pkg of newPackages) {
|
||||
if (watchedPackageLocations.has(pkg.location)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.out(chalk.green(`Starting watch of new dependency ${pkg.name}`));
|
||||
promises.push(watchPackage(pkg));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
await Promise.all(packages.map(watchPackage));
|
||||
|
||||
return { update };
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a package.json for updates
|
||||
*/
|
||||
export function startPackageWatcher(packagePath: string, callback: () => void) {
|
||||
let changed = false;
|
||||
let working = false;
|
||||
|
||||
const notifyDeps = async () => {
|
||||
changed = true;
|
||||
if (working) {
|
||||
return;
|
||||
}
|
||||
working = true;
|
||||
changed = false;
|
||||
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
working = false;
|
||||
// Keep going if a change was emitted while working
|
||||
if (changed) {
|
||||
notifyDeps();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const watcher = chokidar
|
||||
.watch(packagePath, {
|
||||
ignoreInitial: true,
|
||||
disableGlobbing: true,
|
||||
})
|
||||
.on('all', () => {
|
||||
notifyDeps();
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
watcher.on('ready', resolve);
|
||||
watcher.on('error', reject);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
describe('dummy', () => {
|
||||
it('dummy', () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import program from 'commander';
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs';
|
||||
import createPluginCommand from './commands/createPlugin';
|
||||
import watch from './commands/watch-deps';
|
||||
import serve from './commands/serve';
|
||||
|
||||
process.on('unhandledRejection', err => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
const main = (argv: string[]) => {
|
||||
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
||||
|
||||
program.name('backstage-cli').version(packageJson.version ?? '0.0.0');
|
||||
|
||||
program
|
||||
.command('create-plugin')
|
||||
.description('Creates a new plugin in the current repository')
|
||||
.action(createPluginCommand);
|
||||
|
||||
program
|
||||
.command('serve')
|
||||
.description('Serves the dev/ folder of a package')
|
||||
.action(serve);
|
||||
|
||||
program
|
||||
.command('watch-deps')
|
||||
.description('Watch all dependencies while running another command')
|
||||
.action(watch);
|
||||
|
||||
program.on('command:*', () => {
|
||||
console.log();
|
||||
console.log(
|
||||
chalk.red(`Invalid command: ${chalk.cyan(program.args.join(' '))}`),
|
||||
);
|
||||
console.log(chalk.red('See --help for a list of available commands.'));
|
||||
console.log();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
if (!process.argv.slice(2).length) {
|
||||
program.outputHelp(chalk.yellow);
|
||||
}
|
||||
|
||||
program.parse(argv);
|
||||
};
|
||||
|
||||
main(process.argv);
|
||||
// main([process.argv[0], process.argv[1], '--version']);
|
||||
@@ -0,0 +1,6 @@
|
||||
# Title
|
||||
Welcome to your {{id}} plugin!
|
||||
|
||||
## Sub-section 1
|
||||
|
||||
## Sub-section 2
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
...require('@spotify/web-scripts/config/jest.config.js'),
|
||||
setupFilesAfterEnv: ['../jest.setup.ts'],
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@spotify-backstage/plugin-{{id}}",
|
||||
"version": "0.0.0",
|
||||
"main": "dist/cjs",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"build": "tsc --outDir dist/cjs --noEmit false --module CommonJS",
|
||||
"lint": "web-scripts lint",
|
||||
"test": "web-scripts test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@spotify-backstage/cli": "^1.2.0",
|
||||
"@spotify/web-scripts": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@material-ui/lab": "4.0.0-alpha.45"
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import ExampleComponent from './ExampleComponent';
|
||||
|
||||
describe('ExampleComponent', () => {
|
||||
it('should render', () => {
|
||||
const rendered = render(<ExampleComponent />);
|
||||
expect(rendered.getByText('Welcome to {{ id }}!')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Typography, Grid, makeStyles, Theme } from '@material-ui/core';
|
||||
import { InfoCard, Header, Page, theme } from '@spotify-backstage/core';
|
||||
import ExampleFetchComponent from '../ExampleFetchComponent';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
mainContentArea: {
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
pageBody: {
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
title: {
|
||||
padding: theme.spacing(1,0,2,0),
|
||||
},
|
||||
}));
|
||||
|
||||
const ExampleComponent: FC<{}> = () => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Page theme={theme.tool}>
|
||||
<div className={classes.mainContentArea}>
|
||||
<Header title="Welcome to {{ id }}!" subtitle="Optional subtitle"></Header>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
direction={'column'}
|
||||
className={classes.pageBody}
|
||||
>
|
||||
<Grid item>
|
||||
<Typography variant="h3">Plugin page title</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<InfoCard title="Information card" maxWidth>
|
||||
<Typography variant="body1">
|
||||
All content should be wrapped in a card like this.
|
||||
</Typography>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<InfoCard title="Example User List (fetching data from randomuser.me)">
|
||||
<ExampleFetchComponent />
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleComponent;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ExampleComponent';
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import ExampleFetchComponent from './ExampleFetchComponent';
|
||||
|
||||
describe('ExampleFetchComponent', () => {
|
||||
it('should render', () => {
|
||||
const rendered = render(<ExampleFetchComponent />);
|
||||
expect(rendered.getByTestId('progress')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
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 LinearProgress from '@material-ui/core/LinearProgress';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
table: {
|
||||
minWidth: 650,
|
||||
},
|
||||
});
|
||||
|
||||
type User = {
|
||||
gender: string; // "male"
|
||||
name: {
|
||||
title: string; //"Mr",
|
||||
first: string; // "Duane",
|
||||
last: string; //"Reed"
|
||||
};
|
||||
location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…}
|
||||
email: string; // "duane.reed@example.com"
|
||||
login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…}
|
||||
dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37}
|
||||
registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14}
|
||||
phone: string; //"07-2154-5651"
|
||||
cell: string; // "0405-592-879"
|
||||
id: {
|
||||
name: string; // "TFN",
|
||||
value: string; // "796260432"
|
||||
};
|
||||
picture: object; //{large: "https://randomuser.me/api/portraits/men/95.jpg",…}
|
||||
nat: string; // "AU"
|
||||
};
|
||||
|
||||
type DenseTableProps = {
|
||||
users: User[];
|
||||
};
|
||||
|
||||
export const DenseTable: FC<DenseTableProps> = ({ users }) => {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table className={classes.table} size="small" aria-label="a dense table">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Email</TableCell>
|
||||
<TableCell>Nationality</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map(user => (
|
||||
<TableRow key={user.email}>
|
||||
<TableCell>
|
||||
{user.name.first} {user.name.last}
|
||||
</TableCell>
|
||||
<TableCell>{user.email}</TableCell>
|
||||
<TableCell>{user.nat}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const ExampleFetchComponent: FC<{}> = () => {
|
||||
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
|
||||
const response = await fetch('https://randomuser.me/api/?results=20');
|
||||
const data = await response.json();
|
||||
return data.results;
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <LinearProgress data-testid="progress" />;
|
||||
} else if (error) {
|
||||
return <Alert severity="error">{error.message}</Alert>;
|
||||
} else {
|
||||
return <DenseTable users={value || []} />;
|
||||
}
|
||||
};
|
||||
|
||||
export default ExampleFetchComponent;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ExampleFetchComponent';
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './plugin';
|
||||
@@ -0,0 +1,7 @@
|
||||
import plugin from './plugin';
|
||||
|
||||
describe('{{ id }}', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createPlugin } from '@spotify-backstage/core';
|
||||
import ExampleComponent from './components/ExampleComponent';
|
||||
|
||||
export default createPlugin({
|
||||
id: '{{ id }}',
|
||||
register({ router }) {
|
||||
router.registerRoute('/{{ id }}', ExampleComponent);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Backstage is an open platform for building developer portals"
|
||||
/>
|
||||
<title>Backstage</title>
|
||||
</head>
|
||||
<body style="margin: 0">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@spotify/web-scripts/config/tsconfig.json",
|
||||
"include": ["src"],
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"*": ["src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user