From 5fbc348f344aaba16f55a3f5ce830c12b55d1181 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Thu, 19 Mar 2020 13:45:45 -0500 Subject: [PATCH 01/80] Updated purpleBlue in PageThemeProvider --- packages/core/src/layout/Page/PageThemeProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/layout/Page/PageThemeProvider.ts b/packages/core/src/layout/Page/PageThemeProvider.ts index 64c90b78a1..64f8a9f8a7 100644 --- a/packages/core/src/layout/Page/PageThemeProvider.ts +++ b/packages/core/src/layout/Page/PageThemeProvider.ts @@ -45,7 +45,7 @@ export const gradients: Record = { colors: ['#F13DA2', '#FF8A48'], }, purpleBlue: { - colors: ['#4100F4', '#AF2996'], + colors: ['#2D00AA', '#C769B5'], }, tealGreen: { colors: ['#19E68C', '#1D7F6E'], From 557880a36f17e81848a6f1d4dc0c119665b1baf2 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Tue, 31 Mar 2020 12:53:58 -0600 Subject: [PATCH 02/80] Added removePlugin --- package.json | 1 + .../remove-plugin/removePlugin.test.ts | 47 +++++ .../commands/remove-plugin/removePlugin.ts | 189 ++++++++++++++++++ packages/cli/src/index.ts | 6 + 4 files changed, 243 insertions(+) create mode 100644 packages/cli/src/commands/remove-plugin/removePlugin.test.ts create mode 100644 packages/cli/src/commands/remove-plugin/removePlugin.ts diff --git a/package.json b/package.json index 8a862e04b5..828b18c88d 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "lerna run build", "test": "cross-env CI=true lerna run test --since origin/master -- --coverage", "create-plugin": "backstage-cli create-plugin", + "remove-plugin": "backstage-cli remove-plugin", "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi", "lint": "cross-env CI=true lerna run lint --since origin/master --", "storybook": "yarn workspace storybook start" diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts new file mode 100644 index 0000000000..5253fdf301 --- /dev/null +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -0,0 +1,47 @@ +// Test Suite for removePlugin command. + +import fse from 'fs-extra' +import path from 'path' +import {// removeExportStatementFromPlugins, + removePluginDependencyFromApp, + //removePluginDirectory, +} from './removePlugin' + +const rootDir = fse.realpathSync(process.cwd().replace('/cli', '')); + +// test remove export statement +describe('removePlugin', () => { + describe('Remove Plugin Dependencies', () => { + // Set up test + // Copy contents of package file for test + const packageFile = path.join(rootDir, 'app', 'package.json'); + const testFilePath = path.join(rootDir, 'app', 'test.json'); + const testPluginName = 'yarn-test-package'; + const testPluginPackage = `@spotify-backstage/plugin-${testPluginName}`; + + let packageFileContents = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + packageFileContents.dependencies[testPluginPackage] = "0.1.0"; + + it('should remove plugin dependency from /packages/app/package.json', async () => { + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContents, null, 2)}\n`, + 'utf8'); + console.log(JSON.parse(fse.readFileSync(testFilePath, 'utf8'))); + try { + await removePluginDependencyFromApp(testFilePath, testPluginName); + expect(JSON.parse(fse.readFileSync(testFilePath, 'utf8')).hasOwnProperty(testPluginPackage)).toBe(false); + } finally { + fse.removeSync(testFilePath); + } + }); + }); +}); + + +// test remove plugin dependency from app + +// remove plugin from directory + +// remove symlink from lerna scope \ No newline at end of file diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts new file mode 100644 index 0000000000..86444ab640 --- /dev/null +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -0,0 +1,189 @@ +/* + * 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 fse 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 { execSync } from 'child_process'; +// import { resolve as resolvePath } from 'path'; +import { realpathSync, /*existsSync*/ } from 'fs'; +// import os from 'os'; +import ora from 'ora'; + +const MARKER_SUCCESS = chalk.green(` ✔︎`); +const MARKER_FAILURE = chalk.red(` ✘`); + +export const checkExists = async (rootDir: string, pluginName: string) => { + const destination = path.join(rootDir, 'plugins', pluginName); + const spinner = ora({ + prefixText: `Checking plugin exists.`, + spinner: 'arc', + color: 'green', + }).start(); + try { + let pathExist = await fse.pathExists(destination); + if (pathExist) { + spinner.succeed(); + console.log(chalk.green(` Plugin ID ${chalk.cyan(pluginName)} exists at: ${destination.replace(`${rootDir}`, '')} ${MARKER_SUCCESS}`)); + } else { + throw new Error(chalk.red(` Plugin ${chalk.cyan(pluginName)} does not exist!`)); + } + } catch (e) { + spinner.fail(); + throw new Error(chalk.red(` There was an error removing plugin ${chalk.cyan(pluginName)}: ${e.message}`)); + } +} + +export const removePluginDirectory = async (destination: string, pluginName: string) => { + console.log(` Removing plugin files ${chalk.cyan(destination)}.`); + try { + await fse.remove(destination); + console.log(chalk.green(` Plugin files removed successfully. ${MARKER_SUCCESS}`)); + } catch (e) { + throw Error(` Could not remove Plugin\t${pluginName}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`) + } +} + +export const removeSymLink = async (destination: string) => { + console.log(` Removing symbolic link if it exists at:\t${chalk.cyan(destination)}.`) + const symLinkExists = fse.pathExists(destination); + if (symLinkExists) { + try { + await fse.remove(destination); + console.log(chalk.green(` Symbolic link successfully removed. ${MARKER_SUCCESS}`)) + } catch (e) { + throw Error(` Could not remove symbolic link\t${destination}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`) + } + + } +} + +export const removeExportStatementFromPlugins = async (pluginsFile: string, pluginName: string) => { + const pluginNameCapitalized = pluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + + console.log(` Removing export statement from ${chalk.cyan(pluginsFile.replace(pluginsFile.split('/app', 1)[0], ''))}`); // remove long path + try { + let originalContent = await fse.readFile(pluginsFile, 'utf8'); + const contentAfterRemoval = originalContent + .split('\n') + .filter(Boolean) // get rid of empty lines + .filter(statement => { return !statement.includes(`${pluginNameCapitalized}`) }) // get rid of lines with pluginName + .sort() + .concat(['']) // newline at end of line + .join('\n'); + await fse.writeFile(pluginsFile, contentAfterRemoval, 'utf8'); + const finalContent = await fse.readFile(pluginsFile, 'utf8'); + if (finalContent === originalContent) + throw new Error(`File was not modified.`); + console.log(chalk.green(` Successfully removed export statement from /app/src/plugin.ts ${MARKER_SUCCESS}`)); + } catch (e) { + throw new Error(chalk.red(` There was an error removing export statement for plugin ${chalk.cyan(pluginNameCapitalized)} ${MARKER_FAILURE} ${e.message}`)); + } +}; + +export const removePluginDependencyFromApp = async ( + packageFile: string, + pluginName: string, +) => { + + const pluginPackage = `@spotify-backstage/plugin-${pluginName}`; + + console.log(` Removing plugin from app dependencies ${chalk.cyan(packageFile.replace(`${packageFile}/packages`, ''))}:`); + + try { + const packageFileContent = await fse.readFile(packageFile, 'utf-8'); + const packageFileContentJSON = JSON.parse(packageFileContent); + const dependencies = packageFileContentJSON.dependencies; + + if (!dependencies[pluginPackage]) { + throw new Error( + chalk.red(` Plugin ${chalk.cyan(pluginPackage)} does not exist in ${chalk.yellow(packageFile)}`), + ); + } + + delete dependencies[pluginPackage]; + await fse.writeFile( + packageFile, + `${JSON.stringify(packageFileContentJSON, null, 2)}\n`, + 'utf-8', + ); + + console.log(chalk.green(` Successfully removed plugin from app dependencies. ${MARKER_SUCCESS}`)); + } catch (e) { + throw new Error( + `${chalk.red(` Failed to remove plugin as dependency in app: ${chalk.cyan(packageFile)}:`)} ${e.message}`, + ); + } +} + +const capitalize = (str: string): string => + str.charAt(0).toUpperCase() + str.slice(1); + +const removePlugin = async () => { + const questions: Question[] = [ + { + type: 'input', + name: 'pluginName', + message: chalk.blue('Enter the ID of the plugin to be removed [required]'), + validate: (value: any) => { + if (!value) { + return chalk.red('Please enter an ID for the plugin'); + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return chalk.red( + 'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.' + ); + } + return true; + }, + }, + ]; + + const answers: Answers = await inquirer.prompt(questions); + + const rootDir = realpathSync(process.cwd()); + const pluginName: string = answers.pluginName; + const packageFile = path.join(rootDir, 'packages', 'app', 'package.json'); + const pluginsFile = path.join(rootDir, 'packages', 'app', 'src', 'plugins.ts'); + const pluginDirectory = path.join(rootDir, 'plugins', pluginName); + const pluginScopedDirectory = path.join(rootDir, `node_modules/@spotify-backstage/plugin-${pluginName}`); + + console.log(pluginScopedDirectory); + try { + + await checkExists(rootDir, pluginName); + await removeExportStatementFromPlugins(pluginsFile, pluginName); + await removePluginDependencyFromApp(packageFile, pluginName); + await removePluginDirectory(pluginDirectory, pluginName); + await removeSymLink(pluginScopedDirectory); + + console.log(chalk.green(`Successfully removed plugin ${chalk.cyan(pluginName)} from app.`)); + + } catch (e) { + // If error, restore files + console.log(e); + throw new Error( + chalk.red(`Failed to remove plugin: ${chalk.cyan(pluginName)}: ${e.message}`), + ); + } + +}; + +export default removePlugin; \ No newline at end of file diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 997c05c18c..44e30fa2c8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,7 @@ import program from 'commander'; import chalk from 'chalk'; import fs from 'fs'; import createPluginCommand from './commands/create-plugin/createPlugin'; +import removePluginCommand from './commands/remove-plugin/removePlugin'; import watch from './commands/watch-deps'; import buildCache from './commands/build-cache'; import lintCommand from './commands/lint'; @@ -48,6 +49,11 @@ const main = (argv: string[]) => { .description('Creates a new plugin in the current repository') .action(actionHandler(createPluginCommand)); + program + .command('remove-plugin') + .description('Removes plugin in the current repository') + .action(actionHandler(removePluginCommand)); + program .command('plugin:build') .option('--watch', 'Enable watch mode') From ecc2dbe6584736405955309f61cb725fee15a860 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Tue, 31 Mar 2020 14:19:36 -0600 Subject: [PATCH 03/80] remove-plugin removes codeowners statement --- .../commands/remove-plugin/removePlugin.ts | 390 +++++++++++------- 1 file changed, 249 insertions(+), 141 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 86444ab640..25553fa96e 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -21,169 +21,277 @@ import inquirer, { Answers, Question } from 'inquirer'; // import recursive from 'recursive-readdir'; // import { execSync } from 'child_process'; // import { resolve as resolvePath } from 'path'; -import { realpathSync, /*existsSync*/ } from 'fs'; +import { realpathSync /*existsSync*/ } from 'fs'; // import os from 'os'; import ora from 'ora'; const MARKER_SUCCESS = chalk.green(` ✔︎`); const MARKER_FAILURE = chalk.red(` ✘`); +const BACKSTAGE = '@backstage'; export const checkExists = async (rootDir: string, pluginName: string) => { - const destination = path.join(rootDir, 'plugins', pluginName); - const spinner = ora({ - prefixText: `Checking plugin exists.`, - spinner: 'arc', - color: 'green', - }).start(); - try { - let pathExist = await fse.pathExists(destination); - if (pathExist) { - spinner.succeed(); - console.log(chalk.green(` Plugin ID ${chalk.cyan(pluginName)} exists at: ${destination.replace(`${rootDir}`, '')} ${MARKER_SUCCESS}`)); - } else { - throw new Error(chalk.red(` Plugin ${chalk.cyan(pluginName)} does not exist!`)); - } - } catch (e) { - spinner.fail(); - throw new Error(chalk.red(` There was an error removing plugin ${chalk.cyan(pluginName)}: ${e.message}`)); + const destination = path.join(rootDir, 'plugins', pluginName); + const spinner = ora({ + prefixText: ` Checking plugin exists.`, + spinner: 'arc', + color: 'green', + }).start(); + try { + let pathExist = await fse.pathExists(destination); + if (pathExist) { + spinner.succeed(); + console.log( + chalk.green( + ` Plugin ID ${chalk.cyan( + pluginName, + )} exists at: ${destination.replace( + `${rootDir}`, + '', + )} ${MARKER_SUCCESS}`, + ), + ); + } else { + throw new Error( + chalk.red(` Plugin ${chalk.cyan(pluginName)} does not exist!`), + ); } -} + } catch (e) { + spinner.fail(); + throw new Error( + chalk.red( + ` There was an error removing plugin ${chalk.cyan(pluginName)}: ${ + e.message + }`, + ), + ); + } +}; -export const removePluginDirectory = async (destination: string, pluginName: string) => { - console.log(` Removing plugin files ${chalk.cyan(destination)}.`); - try { - await fse.remove(destination); - console.log(chalk.green(` Plugin files removed successfully. ${MARKER_SUCCESS}`)); - } catch (e) { - throw Error(` Could not remove Plugin\t${pluginName}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`) - } -} +export const removePluginDirectory = async ( + destination: string, + pluginName: string, +) => { + console.log(` Removing plugin files ${chalk.cyan(destination)}.`); + try { + await fse.remove(destination); + console.log( + chalk.green(` Plugin files removed successfully. ${MARKER_SUCCESS}`), + ); + } catch (e) { + throw Error( + ` Could not remove Plugin\t${pluginName}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`, + ); + } +}; export const removeSymLink = async (destination: string) => { - console.log(` Removing symbolic link if it exists at:\t${chalk.cyan(destination)}.`) - const symLinkExists = fse.pathExists(destination); - if (symLinkExists) { - try { - await fse.remove(destination); - console.log(chalk.green(` Symbolic link successfully removed. ${MARKER_SUCCESS}`)) - } catch (e) { - throw Error(` Could not remove symbolic link\t${destination}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`) - } - - } -} - -export const removeExportStatementFromPlugins = async (pluginsFile: string, pluginName: string) => { - const pluginNameCapitalized = pluginName - .split('-') - .map(name => capitalize(name)) - .join(''); - - console.log(` Removing export statement from ${chalk.cyan(pluginsFile.replace(pluginsFile.split('/app', 1)[0], ''))}`); // remove long path + console.log( + ` Removing symbolic link if it exists at:\t${chalk.cyan(destination)}.`, + ); + const symLinkExists = fse.pathExists(destination); + if (symLinkExists) { try { - let originalContent = await fse.readFile(pluginsFile, 'utf8'); - const contentAfterRemoval = originalContent - .split('\n') - .filter(Boolean) // get rid of empty lines - .filter(statement => { return !statement.includes(`${pluginNameCapitalized}`) }) // get rid of lines with pluginName - .sort() - .concat(['']) // newline at end of line - .join('\n'); - await fse.writeFile(pluginsFile, contentAfterRemoval, 'utf8'); - const finalContent = await fse.readFile(pluginsFile, 'utf8'); - if (finalContent === originalContent) - throw new Error(`File was not modified.`); - console.log(chalk.green(` Successfully removed export statement from /app/src/plugin.ts ${MARKER_SUCCESS}`)); + await fse.remove(destination); + console.log( + chalk.green(` Symbolic link successfully removed. ${MARKER_SUCCESS}`), + ); } catch (e) { - throw new Error(chalk.red(` There was an error removing export statement for plugin ${chalk.cyan(pluginNameCapitalized)} ${MARKER_FAILURE} ${e.message}`)); + throw Error( + ` Could not remove symbolic link\t${destination}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`, + ); } + } +}; + +export const removeStatementContainingID = async (file: string, ID: string) => { + const originalContent = await fse.readFile(file, 'utf8'); + const contentAfterRemoval = originalContent + .split('\n') + .filter(Boolean) // get rid of empty lines + .filter(statement => { + return !statement.includes(`${ID}`); + }) // get rid of lines with pluginName + .sort() + .concat(['']) // newline at end of line + .join('\n'); + await fse.writeFile(file, contentAfterRemoval, 'utf8'); + const finalContent = await fse.readFile(file, 'utf8'); + if (finalContent === originalContent) + throw new Error(`File was not modified.`); +}; + +export const removeExportStatementFromPlugins = async ( + pluginsFile: string, + pluginName: string, +) => { + const pluginNameCapitalized = pluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + console.log( + ` Removing export statement from ${chalk.cyan( + pluginsFile.replace(pluginsFile.split('/app', 1)[0], ''), + )}`, + ); // remove long path + try { + await removeStatementContainingID(pluginsFile, pluginNameCapitalized); + console.log( + chalk.green( + ` Successfully removed export statement from /app/src/plugin.ts ${MARKER_SUCCESS}`, + ), + ); + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing export statement for plugin ${chalk.cyan( + pluginNameCapitalized, + )} ${MARKER_FAILURE} ${e.message}`, + ), + ); + } +}; + +export const removePluginFromCodeOwners = async ( + codeOwnersFile: string, + pluginName: string, +) => { + console.log( + ` Removing teams and owners from ${chalk.cyan( + codeOwnersFile.replace(codeOwnersFile.split('/.git', 1)[0], ''), + )}`, + ); // remove long path + try { + await removeStatementContainingID(codeOwnersFile, pluginName); + console.log( + chalk.green( + ` Successfully removed codeowners statement from /.git/CODEOWNERS ${MARKER_SUCCESS}`, + ), + ); + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing code owners statement for plugin ${chalk.cyan( + pluginName, + )} ${MARKER_FAILURE} ${e.message}`, + ), + ); + } }; export const removePluginDependencyFromApp = async ( - packageFile: string, - pluginName: string, + packageFile: string, + pluginName: string, ) => { + const pluginPackage = `${BACKSTAGE}/plugin-${pluginName}`; - const pluginPackage = `@spotify-backstage/plugin-${pluginName}`; + console.log( + ` Removing plugin from app dependencies ${chalk.cyan( + packageFile.replace(`${packageFile}/packages`, ''), + )}:`, + ); - console.log(` Removing plugin from app dependencies ${chalk.cyan(packageFile.replace(`${packageFile}/packages`, ''))}:`); + try { + const packageFileContent = await fse.readFile(packageFile, 'utf-8'); + const packageFileContentJSON = JSON.parse(packageFileContent); + const dependencies = packageFileContentJSON.dependencies; - try { - const packageFileContent = await fse.readFile(packageFile, 'utf-8'); - const packageFileContentJSON = JSON.parse(packageFileContent); - const dependencies = packageFileContentJSON.dependencies; - - if (!dependencies[pluginPackage]) { - throw new Error( - chalk.red(` Plugin ${chalk.cyan(pluginPackage)} does not exist in ${chalk.yellow(packageFile)}`), - ); - } - - delete dependencies[pluginPackage]; - await fse.writeFile( - packageFile, - `${JSON.stringify(packageFileContentJSON, null, 2)}\n`, - 'utf-8', - ); - - console.log(chalk.green(` Successfully removed plugin from app dependencies. ${MARKER_SUCCESS}`)); - } catch (e) { - throw new Error( - `${chalk.red(` Failed to remove plugin as dependency in app: ${chalk.cyan(packageFile)}:`)} ${e.message}`, - ); - } -} - -const capitalize = (str: string): string => - str.charAt(0).toUpperCase() + str.slice(1); - -const removePlugin = async () => { - const questions: Question[] = [ - { - type: 'input', - name: 'pluginName', - message: chalk.blue('Enter the ID of the plugin to be removed [required]'), - validate: (value: any) => { - if (!value) { - return chalk.red('Please enter an ID for the plugin'); - } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { - return chalk.red( - 'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.' - ); - } - return true; - }, - }, - ]; - - const answers: Answers = await inquirer.prompt(questions); - - const rootDir = realpathSync(process.cwd()); - const pluginName: string = answers.pluginName; - const packageFile = path.join(rootDir, 'packages', 'app', 'package.json'); - const pluginsFile = path.join(rootDir, 'packages', 'app', 'src', 'plugins.ts'); - const pluginDirectory = path.join(rootDir, 'plugins', pluginName); - const pluginScopedDirectory = path.join(rootDir, `node_modules/@spotify-backstage/plugin-${pluginName}`); - - console.log(pluginScopedDirectory); - try { - - await checkExists(rootDir, pluginName); - await removeExportStatementFromPlugins(pluginsFile, pluginName); - await removePluginDependencyFromApp(packageFile, pluginName); - await removePluginDirectory(pluginDirectory, pluginName); - await removeSymLink(pluginScopedDirectory); - - console.log(chalk.green(`Successfully removed plugin ${chalk.cyan(pluginName)} from app.`)); - - } catch (e) { - // If error, restore files - console.log(e); - throw new Error( - chalk.red(`Failed to remove plugin: ${chalk.cyan(pluginName)}: ${e.message}`), - ); + if (!dependencies[pluginPackage]) { + throw new Error( + chalk.red( + ` Plugin ${chalk.cyan( + pluginPackage, + )} does not exist in ${chalk.yellow(packageFile)}`, + ), + ); } + delete dependencies[pluginPackage]; + await fse.writeFile( + packageFile, + `${JSON.stringify(packageFileContentJSON, null, 2)}\n`, + 'utf-8', + ); + + console.log( + chalk.green( + ` Successfully removed plugin from app dependencies. ${MARKER_SUCCESS}`, + ), + ); + } catch (e) { + throw new Error( + `${chalk.red( + ` Failed to remove plugin as dependency in app: ${chalk.cyan( + packageFile, + )}:`, + )} ${e.message}`, + ); + } }; -export default removePlugin; \ No newline at end of file +const capitalize = (str: string): string => + str.charAt(0).toUpperCase() + str.slice(1); + +const removePlugin = async () => { + const questions: Question[] = [ + { + type: 'input', + name: 'pluginName', + message: chalk.blue( + 'Enter the ID of the plugin to be removed [required]', + ), + validate: (value: any) => { + if (!value) { + return chalk.red('Please enter an ID for the plugin'); + } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { + return chalk.red( + 'Plugin IDs must be kehbab-cased and contain only letters, digits and dashes.', + ); + } + return true; + }, + }, + ]; + + const answers: Answers = await inquirer.prompt(questions); + + const rootDir = realpathSync(process.cwd()); + const codeOwnersFile = path.join(rootDir, '.github', 'CODEOWNERS'); + const pluginName: string = answers.pluginName; + const packageFile = path.join(rootDir, 'packages', 'app', 'package.json'); + const pluginsFile = path.join( + rootDir, + 'packages', + 'app', + 'src', + 'plugins.ts', + ); + const pluginDirectory = path.join(rootDir, 'plugins', pluginName); + const pluginScopedDirectory = path.join( + rootDir, + `node_modules/${BACKSTAGE}/plugin-${pluginName}`, + ); + console.log(pluginScopedDirectory); + try { + await checkExists(rootDir, pluginName); + await removeExportStatementFromPlugins(pluginsFile, pluginName); + await removePluginDependencyFromApp(packageFile, pluginName); + await removePluginDirectory(pluginDirectory, pluginName); + await removeSymLink(pluginScopedDirectory); + await removePluginFromCodeOwners(codeOwnersFile, pluginName); + console.log( + chalk.green( + `Successfully removed plugin ${chalk.cyan(pluginName)} from app.`, + ), + ); + } catch (e) { + // If error, restore files + console.log(e); + throw new Error( + chalk.red( + `Failed to remove plugin: ${chalk.cyan(pluginName)}: ${e.message}`, + ), + ); + } +}; + +export default removePlugin; From 0a70a0a26baaadf6b9c36151d3639a166d9be261 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Tue, 31 Mar 2020 14:27:54 -0600 Subject: [PATCH 04/80] Removed unnecessary comments --- packages/cli/src/commands/remove-plugin/removePlugin.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 25553fa96e..b1fa469be2 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -15,15 +15,11 @@ */ import fse 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 { execSync } from 'child_process'; -// import { resolve as resolvePath } from 'path'; -import { realpathSync /*existsSync*/ } from 'fs'; -// import os from 'os'; +import { realpathSync } from 'fs'; import ora from 'ora'; +// import os from 'os'; const MARKER_SUCCESS = chalk.green(` ✔︎`); const MARKER_FAILURE = chalk.red(` ✘`); From 7217b56825aef89088c8d66f65e084b4e53ebf3f Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Tue, 31 Mar 2020 15:13:31 -0600 Subject: [PATCH 05/80] Fixed @bakcstage name in test --- .../remove-plugin/removePlugin.test.ts | 68 ++++++++++--------- 1 file changed, 37 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 5253fdf301..51a910c8a5 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -1,47 +1,53 @@ // Test Suite for removePlugin command. -import fse from 'fs-extra' -import path from 'path' -import {// removeExportStatementFromPlugins, - removePluginDependencyFromApp, - //removePluginDirectory, -} from './removePlugin' +import fse from 'fs-extra'; +import path from 'path'; +import { + // removeExportStatementFromPlugins, + removePluginDependencyFromApp, + //removePluginDirectory, +} from './removePlugin'; const rootDir = fse.realpathSync(process.cwd().replace('/cli', '')); +const BACKSTAGE = `@backstage`; // test remove export statement describe('removePlugin', () => { - describe('Remove Plugin Dependencies', () => { - // Set up test - // Copy contents of package file for test - const packageFile = path.join(rootDir, 'app', 'package.json'); - const testFilePath = path.join(rootDir, 'app', 'test.json'); - const testPluginName = 'yarn-test-package'; - const testPluginPackage = `@spotify-backstage/plugin-${testPluginName}`; + describe('Remove Plugin Dependencies', () => { + // Set up test + // Copy contents of package file for test + const packageFile = path.join(rootDir, 'app', 'package.json'); + const testFilePath = path.join(rootDir, 'app', 'test.json'); + const testPluginName = 'yarn-test-package'; + const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; - let packageFileContents = JSON.parse(fse.readFileSync(packageFile, 'utf8')); - packageFileContents.dependencies[testPluginPackage] = "0.1.0"; + let packageFileContents = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + packageFileContents.dependencies[testPluginPackage] = '0.1.0'; - it('should remove plugin dependency from /packages/app/package.json', async () => { - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContents, null, 2)}\n`, - 'utf8'); - console.log(JSON.parse(fse.readFileSync(testFilePath, 'utf8'))); - try { - await removePluginDependencyFromApp(testFilePath, testPluginName); - expect(JSON.parse(fse.readFileSync(testFilePath, 'utf8')).hasOwnProperty(testPluginPackage)).toBe(false); - } finally { - fse.removeSync(testFilePath); - } - }); + it('should remove plugin dependency from /packages/app/package.json', async () => { + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContents, null, 2)}\n`, + 'utf8', + ); + console.log(JSON.parse(fse.readFileSync(testFilePath, 'utf8'))); + try { + await removePluginDependencyFromApp(testFilePath, testPluginName); + expect( + JSON.parse(fse.readFileSync(testFilePath, 'utf8')).hasOwnProperty( + testPluginPackage, + ), + ).toBe(false); + } finally { + fse.removeSync(testFilePath); + } }); + }); }); - // test remove plugin dependency from app // remove plugin from directory -// remove symlink from lerna scope \ No newline at end of file +// remove symlink from lerna scope From f7967c6d1784bfa6bc11b519362377b951034577 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Tue, 31 Mar 2020 15:24:46 -0600 Subject: [PATCH 06/80] Fixed yarn lint errors --- .../remove-plugin/removePlugin.test.ts | 30 ++++++++++++------- .../commands/remove-plugin/removePlugin.ts | 8 ++--- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 51a910c8a5..c801aaaa02 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -1,12 +1,22 @@ -// Test Suite for removePlugin command. +/* + * 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 fse from 'fs-extra'; import path from 'path'; -import { - // removeExportStatementFromPlugins, - removePluginDependencyFromApp, - //removePluginDirectory, -} from './removePlugin'; +import { removePluginDependencyFromApp } from './removePlugin'; const rootDir = fse.realpathSync(process.cwd().replace('/cli', '')); const BACKSTAGE = `@backstage`; @@ -21,7 +31,9 @@ describe('removePlugin', () => { const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; - let packageFileContents = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + const packageFileContents = JSON.parse( + fse.readFileSync(packageFile, 'utf8'), + ); packageFileContents.dependencies[testPluginPackage] = '0.1.0'; it('should remove plugin dependency from /packages/app/package.json', async () => { @@ -45,9 +57,7 @@ describe('removePlugin', () => { }); }); }); - +// Still to implement // test remove plugin dependency from app - // remove plugin from directory - // remove symlink from lerna scope diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index b1fa469be2..51f10fbc7a 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -33,7 +33,7 @@ export const checkExists = async (rootDir: string, pluginName: string) => { color: 'green', }).start(); try { - let pathExist = await fse.pathExists(destination); + const pathExist = await fse.pathExists(destination); if (pathExist) { spinner.succeed(); console.log( @@ -116,6 +116,9 @@ export const removeStatementContainingID = async (file: string, ID: string) => { throw new Error(`File was not modified.`); }; +const capitalize = (str: string): string => + str.charAt(0).toUpperCase() + str.slice(1); + export const removeExportStatementFromPlugins = async ( pluginsFile: string, pluginName: string, @@ -224,9 +227,6 @@ export const removePluginDependencyFromApp = async ( } }; -const capitalize = (str: string): string => - str.charAt(0).toUpperCase() + str.slice(1); - const removePlugin = async () => { const questions: Question[] = [ { From e67bfd134936d62b9fad53df342d1d0ccc9d5145 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Wed, 8 Apr 2020 13:13:26 -0500 Subject: [PATCH 07/80] Merge branch 'master' of https://github.com/spotify/backstage into remove_plugin_wip --- docs/design.md | 10 ++++++++++ docs/designheader.png | Bin 0 -> 124655 bytes lerna.json | 2 +- packages/app/package.json | 12 ++++++------ packages/cli/package.json | 2 +- .../cli/src/commands/create-app/createApp.ts | 8 ++++---- .../default-app/plugins/welcome/tsconfig.json | 5 ++++- .../cli/templates/default-plugin/tsconfig.json | 5 ++++- packages/core/package.json | 8 ++++---- .../src/api/apis/definitions/featureFlags.ts | 2 +- packages/core/src/api/app/AppBuilder.tsx | 8 ++++---- .../core/src/api/app/FeatureFlags.test.tsx | 2 +- packages/core/src/api/app/FeatureFlags.tsx | 4 ++-- .../core/src/api/app/LoginPage/LoginPage.tsx | 10 +++++----- packages/core/src/api/app/types.ts | 2 +- packages/core/src/api/plugin/Plugin.tsx | 4 ++-- packages/core/src/api/plugin/types.ts | 2 +- .../src/api/widgetView/WidgetViewBuilder.tsx | 6 +++--- .../DefaultWidgetView/DefaultWidgetView.tsx | 2 +- packages/core/src/components/ProgressCard.tsx | 4 ++-- packages/core/src/icons/icons.tsx | 2 +- packages/core/src/layout/Header/Header.tsx | 2 +- packages/core/src/layout/Header/Waves.test.tsx | 2 +- packages/core/src/layout/Header/Waves.tsx | 2 +- .../src/layout/HeaderLabel/OwnerHeaderLabel.js | 2 +- packages/core/src/layout/InfoCard/InfoCard.tsx | 2 +- packages/core/tsconfig.json | 1 + packages/storybook/.storybook/main.js | 3 +++ packages/storybook/package.json | 2 +- packages/test-utils/package.json | 6 +++--- packages/test-utils/tsconfig.json | 5 ++++- packages/theme/package.json | 4 ++-- packages/theme/tsconfig.json | 5 ++++- plugins/home-page/package.json | 8 ++++---- .../src/components/HomePage/HomePage.tsx | 2 +- plugins/home-page/src/plugin.ts | 2 +- plugins/home-page/tsconfig.json | 5 ++++- plugins/welcome/package.json | 8 ++++---- .../src/components/WelcomePage/WelcomePage.tsx | 2 +- plugins/welcome/src/plugin.ts | 2 +- plugins/welcome/tsconfig.json | 5 ++++- 41 files changed, 101 insertions(+), 69 deletions(-) create mode 100644 docs/designheader.png diff --git a/docs/design.md b/docs/design.md index e1a2af84b1..a75227b0cd 100644 --- a/docs/design.md +++ b/docs/design.md @@ -19,11 +19,21 @@ There are a lot of exciting things coming up and we want to keep you in the loop ## 🛠 Our Practice The chart below details how we work. ***Stay tuned***: We are currently in the process of securing a Figma workspace for Backstage Open Source, and we plan on referencing Figma documents to share specs and prototypes with the community. +### Creating a New Design Component | Step 1 | Step 2 | Step 3 | Step 4 | Step 5 | Step 6 | |:---|:---|:---|:---|:---|:---| | Platform design team submits an issue to **spotify/Backstage GitHub** with a potential component. | Backstage community offers feedback or approval on **spotify/Backstage GitHub**. | Platform design team adjusts accordingly (as they see fit) and update the Figma DLS document. | Designed component is added to **spotify/Backstage GitHub** as an issue. | External or internal Backstage open source contributors build the component. | External or internal contributors add the component to the **Backstage Storybook**. 🎉 | +### Building for Backstage +| Step 1 | Step 2 | Step 3 | Step 4 | +|:---|:---|:---|:---| +| External or internal contributors use Backstage and come up with an idea of an entity to build for Backstage. |External or internal contributors refer to the Backstage Open Source design system documentation in the Figma DLS document. | External or internal contributors leverage the components and tokens from the Backstage Storybook. | External or internal contributors build their Backstage entity. | + +| Step 5 | Step 6 | Step 7 | Step 8 | +|:---|:---|:---|:---| +| External or internal contributors make a pull request for their entity on spotify/Backstage GitHub for review. | Platform designers and devs review the entity and submit feedback or approval on spotify/Backstage GitHub. | External or internal contributors make the changes, pull request is approved and the entity is merged. It’s live on Backstage! 🎉 | If the entity happens to be or include a UX component, it’s added to Backstage Storybook as well. | + The following diagram shows the relationship between the Backstage Design System and our foundation, which comprises of [Material UI](https://material-ui.com/) that is shaped by user experience and user interface decisions made by our Backstage Design Team. Also note, we encourage you to take the core experience we’ve crafted and add custom theming to better represent your organization! diff --git a/docs/designheader.png b/docs/designheader.png new file mode 100644 index 0000000000000000000000000000000000000000..e9ace5c2e7074fbcbc716571b75e1a217daf528e GIT binary patch literal 124655 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

uc_d`U20&n=&=JUdnimE=7xQ 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

+xwm zIc^S4~PfSdyQi~5d`s%-%{6RdSBgshA)ac_CW zTQPTD^Bt)(IP_49vmDC{dxBPD(UEc)1FLeBHdz<4XBwkLZoOLi{8Oa&;8Dw` zv@|20lL$O#%pI4!+jhI`Tb2wWxbRrdUQbuxG%uFl&V0`Gl4Qci9+Sf-80>z_R^PnX z#^cvJ*L;257fTQ<`lN!Zq$cW|kKaN`#SkeZes_fAP>0;EjCs5kSl<%j;K-T3GhzD7WtND9#7Sy(5T=O|Ufl1K%d~u(3OPA(;XN98L!^VPJTGP59 z(NK`^+jVgy=7aeD7|R^j{~A!xj$9uCqVn8w{l>8g563qa26gk0n)YJCbj3b0r^24zcyK8@aGS-FkN!O(K^ybc-!6xaNO(rfe*fF5J1 zQ*>C`eOt~?vaVYBFtF|Slax#O{}(>7>^ucA6{>8Q;td_}5DbLx_Y;yQYNOw*4MK=r zo3#hxx4<8ZpSu74!mAy^B-j~T#V`|aJ%CewwMbP3sA96eC3=7-f1<9O41D;`^94@> zV22+O;IYD!Xnm}nkZLASq2;=LDPHs1i@iHgOrR+la@9ogAk4x-!m;C))nSv{`{nKW z0NjP}y7kxo%!bE{L*;F0M$@@O>YvGHxa2+g%>`sg5ny)>SmJ>a!CWmrc89ZCWWv`Q z@hOr23^W=YUL~hjeUjw@{6Qe3 z4^{+FTvyrj8&s4T6$ zY7I~?qydD(H%qP%cByT(ZVzuPC`Ad_K3SypwE#hhzOAIclqX^nyq0LeGoKXtD~*~jDu@RGdd4)UegZNyVrht9X_zD?i@S; zn9GIl9zJx`rR1T3w~TswPi=r-G}m0}Mnsn?sXb%=o!}Z9XDOYyb{1PIVPUb!0QS-GgO!8Uro(; zCw2}@Anpr*9EI=L9y-ojuHIGBC81qiwn(#K6XwWO{37WNTBc`0$vI$5ny*~lq2;BP}yGZq)kxO^S_Z=bo&)*|u za=&=Hlr8tH+Q*P%X`E$ut|sK+^0i*uteO1sx;HrXhX2eZK|(H-i?=IPMd6}q-=SBQ z0NQEk&Y}6{+^z3mrLUFp9Xqoy0L~m<>-Y4Su!G6V`5oJ@H7`X)UM76|h&p8(2+R@C z0($$md`~tGJsGX?3@5{Ox#29x1Ynj2S^JWz?(&zp3`#ehky5A~hnXM~A2xn;9^)68 zq3|aele|wM-T~?tCtySgva?)u9WhT7ALTs4(}rSy6IbqPjo52~V1ClR^xn128y`@C zI-M8+G+03P^Kc4v$3?KW-hL?yyeO2I?%Hg(0dQdQ{MW3CxNlqrp_M56p2XtH*9Q>> zIlo{UrqBOuRclG0^T@7(oWZP-rZ3_EK%Qy4)lt?UXN6=6pX>poM?eW~e;bBLraI%yOQ%j(Bmehv*hTDAC< z7QG_W78hFKyyX0`QRGQ9Ap+OUlO|p_)uHaQYk$W;6p^pPRlqOY9{Uh?RUkfxTZa*O z0KfRD5jaom9b}>ncT&TO_;vQ?CtjOZ4wu#d0|(hulK4U=fTiq@xam!>}d#G zjJy_GLFLsWx1RBo@aZl^`{!~FfpPDm5_SA2^(;`ixLsJg=hD)Ig)zO)@7J{#RNR^b z&LrdD7m=NZ3C;nVFyB0-dQ}ZV5ePK?<`=yh9wAe+(vVShL&MD=Y6-+i?|vT>s!@BJw)Wf^Gl4b1d~ov%%DXDmz{;d#45`AkS3e~-fGM|P8H zClI#3ST+WNRDXPukS&kw|M*QZF2W+u0cW*REsWHBmJUDZTTZR~p8z(9W%p)y zn#2~Fj{nPHxx811GamRnN8_;k&3E4lD)$??AB8z9aHU^`%nq7u`vriS8uU4TJQ9wh z93J{a_^_L)IvKow;TO`k+~O368cnWl3{gwZ`8=y_Hl<=&CUoM{Fm;ME{hL`7Yac)X z>0d2C0PN)7%ehA|ZZ8m?(a>2&uWDd0pvCG_Q=UAb`iPS&TIt+KRfttJ&TBx z0m;?p3)vvWJk|_UxZ;T}$^nBXU~SuRp%*R~0i~iscf%v!cY^`ixWQf)%QL+HM1e>{ z!A?ksDV!c zl>E=}IGc2ESb>qDcF&FKr>+RQWL_ovyS4*ufIFaa{UW+tq-x+;lG`G#d=O0J(2a`R z6|2@91h&`zy!APq5Ox{IfBJ%L5C7?_qfr@eoTW34US)YH&KjQc(&%jJe@N=b2d}kx zMO4i*cs)w;LdQaR)fM+=%>@1fEurK5L9^=Gr#!d9R`TBij^1BVrrQ^C{$G$cbH!xQ zpHu@rLqXqTQ1IG)>5MiIt3BSE3Y(!ofMQUuNkA=E-aK@s)_CDIWdg=-YYxDESNC6( zROkx=A|%&tUPs3@OoKDw=v>N(DKwaL1T2yp@@7Zk2Re^k-|8*c4S{pc54)VekuaA9 zv?ti!#W-M}R9LEN zoXx)R9m_%~qndVcbZ7uz!!j`7+&K=_0u%ZoFLUN{XT3FS$O9@$QpRr}84kxUv)bwX zCtxm$j~{w?jEB@#cooTKxa#j72UUQ~kpB{mSekjXmSplOkw=j5^ge2kg_bv?^@IQ9 zPB<#r4{OYeLJqeE$qcrB4**FHoBXX)+aN%63%i4m0m$j-{aJ5sHHaH?<0(mt)Nb!` z4rAdfmz4liECKcQ#H>2Ke&=qQgSC|`cTe3AH5A0&e^AAj3%jnlS;+5`H>YC92bP8V zDHI2L3jDcR@ND_^dmC%yAe5#;;~jMDz$5|(kRuMXBYs(W0`ET!>QZ~1KhWRT$f}>a z{+{Qh!A%SVdy5CsulZniCw-YEtwsmcKsY>28+l5aJpREHqke38Q)%kU$-^d?XU}Y< zcHREY((e^}TanW6uE^w-4@e3Zg0B&$Rjgem47f^rts+~X*|qmZFaLirwJ2Ht6XJYb z6njvO3wK!p?JLMFmEI3Mk#kEmVrnXmZyq=!dUL=>*nH2Qyo_UHe?ZvMHaLteuB5w@ zPaLlE+~2qPDE$scpKHFm$X7DR3c*j+vccXAHf5!9@F?P3ha&7DoFloYk~_@RZ0XK} zRkH?dWN$w7IW|?nSc(bD%5+6gA7*VGKNZD(hY0A5^<5)Bp}|t6ipS0PKbeLm?|Iz& zmvsCsyDg=IQ7K_;T`=I=?5y-`N?!3DQtUoKuGx;v2M@109-EWWAoP=oe z&gUO@^Kh~aUbUX9=`olD-WJ)C3RpGJAzT+bQVl$kKi~CQC@BN+Ir|-fmilw+R=upH zUS$9L_j}&y0vSGgJJmJ+`VfxMJCpTc7Q9N=cQb)GSy0d7daXRHXZI@>$z$?LQRwx9 z>IpCrGUq<^t+JrqnzJO$gk?l+k|$k<>sl{oTxCvCCqVLW!AzUq=1LTvnr~1_48xYq z%1uv-G6CcFwEHV7?><3TiWEwN9Ms$hZuvp!bF+4jYToYWuF{9XcCF~j@`=;9F2_so zPZwQ$_)mIjFX4Ud@l*ZBHg65u2^&|RtuMI^oEqT;4S0cL=s3@2D93|U@W&`z>jQ4z znr~wtd%>IvMa>kYZt+I@ne)NKj&ES+HOx(CoT|zJw?zIgr-E)dr`F_3NCi$Z72cOVG4Ro1>kwN3uO?m!b$j8G7B; zm(FTHOx?|_H zB)D$mLI795v(ecZ4`Aa0FK@DXSM1lvP^!0|a zfFZ5!!?#w!4}6!N+gHw3LUsi?g2a|>?f}QGpJ`pC$K|T?nuGtsk#j<+!0-CHsT4K` z6#AP9=k6FY3lgd)x`=z32E!mYXZ&qFmV8GDf_Wf;?Jba{PXaRELhoQofC}wY5cQ8j z9tNEc0C0Ry?AWQGwOwQ5IT9S+w%rBzd+@{-wF3d=f!j0_@Xn2S2smYbhINkK?8XH)UD=z`)V>HXvdG_|4rf6G&=w4La;K99d|+e zhP}MErU6UbL{V*#t3Xb7{=l;PaSqei5<$e4vjiCc;gw9TU_tu624sT-&~cx)K*m39 zFG@FOJ$4~A4$mN0?ADI;m#m<2muC8-z+xrI#67Blpn(n)pB{%4iGy;`C8(<>X_H3x zA@>ISJyNoSDE1T;vOJsFBcXABbPK(P6=gj4{fQd8H|9>NZmSMj37syHz^nVzPPe*L zVQ^bEsj4Fo}ss)A+w*n#^8Gzi*abJ$1_a;9E__7PlyWImQs zbF;1i4Cc1wJ12|Q)ab{~PO>EaE}iW)0HX^<76V}Aq7zss zP(hIIL)Ou43fRtS*>plw&MDUq&dXt^pI%k2vO{)(*064e^8sx`%czAtB%a21()om! zts|g<2MU=vS0#FsK79~qbb^O43aKJub}BjmQ?4)_*ykVA57@0mHEx2rVMe5_h~+l9 z!rK~o$?R6fcB*4vJRB;giDRbU7;AZLGX4_JO@Y+Ps_a}EIdQbXNBGk4-6D< zAFJ_2C_$7K)x)%sC^Xjr?bpo|5Q&4rtJ(Ma&7?464f5ycILmY0I7k6B)SpQNeVvBq z$IUWuph@@?WXMv3;f-hVrmEq?0gbSn6g1HU*9S0-AdwHHpR&DjSath>#&y0lZXxA)9F}PkO|7!8e;ln`&01@x&)3R|4ez0jl{K9%b%?|w>H-S=YmIykf~Z!=)TO~8gbh4^CtkEmYT-V6mTYi zi%t~=s{YDWK&4i0d&CrH!k`6JJy~(u34rG=2?GxXwF}DYo0}9toRRd^^34rogP`zW zpf~vR@&fO3=5853|0=7~XFf8GYAT&pi7PelNG-yMAuP1=Md^Qj>f-zFpDQk!n9Dxm zzdX%a1u!;q0NVboIMYLPVx^?$%Qsb^?On-a(A&UK$VGba`C-$zNSk7}(Q#4Ppn&GMu(n*7zsM?1#Y1z;AM{ z(wF3zsy_SvasY@krvW}Cr%M{>204E~znmfb=F<&f04c>%PzS^oM|Et-onMLNn+IXO z86?fpFaj)owz?Mqs$ZC8xlZ-xj85{>(CPFWFO~yhwWs{3YI=jcL0g*C7A7qwM{jH+ zlQ%w{4nBoCB}mP6)=b0wjEE~|UVZGjehJz#cGYumZPG}X@aRhnO0Yn%hvSyhOMUc#Ca zvtSzs+uW5~K|*4UzJwYiL|(4vj%^sb%1`gJjLh$LzQTXT%SKslFfiaLC;$w7cJJ%4 zs%Z9nA@@@g^_paE)bMyuj$Zf#7+kEKIc)Mco4ZgtKg-0W{$YvmIl**c z6|eo4@S8lx%t2F!P~=aR4`2`so8|=d=D6vmKn_&y1lgZv4~Fc81GcPn9K{wy`hWoe z4d*L(Poz#cY%LXGmk#3S+Ck+{)yRakw<&ll+L@q)t<*8kY2r>P6;k!mbOL-cY?{8x zrDs0l`HiBDxhHir_O}sUD02abL`gY{eKO9F3-UtkGB>6ILiq!B!(9*Bv$$;jst=|xB z%sY5`ry-sp0WAukGFDwhY92*DOf<=`lbquSe^r(7R;whHJq$4L_{)G^S|+Tb*p#Nq zQ$8{F5H94?aB=^{dNd2`0W&fRZnpi8C1pX6ZyuC6~cr2*xL2}2bitVu${g~@HY?(D(8t`=Tm%8qgqF7+jH~r{)LS< zT0aV;^OF|LvB}l@=PtH?&%Ol#Q-Ja%9F$7}2wBO?QXaQ{0ER91ffzc8;XZ!s05rFN zd%Gvk^=Z}@z*(#(MO%_z_N;nB9YkDNo$sjgs6ZzpXHRsq3=!2ljGb@ur-N@}Dx$iD z-%$1+dJJe`Dm##jukV3P9~Zy=2OChW73QD_K5Yor{Zb(A;`NW0EHyx4W#>y9^IXwk zH$I*xt_70$G$#p0`lb2Vh+Hf#?LihA1O+VLC4A!sP9)>gm8 zzWfKtoH>8!)CZIW3RO9SY}AaR-`@Z_A#j2|a0BU3jqLkpeV55m`iKjok71L4$$#hw*KTJ zrRf1=3#)VSgobeL9sS)FlUK^{*Rr(Rr~k#W$AN2_nb+`Nn!P76YLpbl|Lr&qBeM|C zZ$Y{Jj3Q7eR-PsrWvTsuEq@!*3D!z&Kh$`Sq-KSme)nVXUK#AKXe;TxP|x3pQ_iGz zF^;!fNpiy=Eh$tRxisj9M(9XnGO!wM%i8bgKLfp7eDTr#-}~kRG`1$b5O9pVor5d` zQnVeoedxw(zBN8r_))_;k+-Vfo!0pK4;YQ<)vpJFbn|>u8|V)UxBn*zx2ZlLefRY` zyKz!1=%Oq<4A?{X@;mRjZUdtHD2E#mZ-DuOLe<2({J_JSB?&C^E8slSK=r?gaXHha z3+QFTe}j{q)FrPk%eXAPNcOx=K0q5-{cZrXQpV^3lZ(B`0Jq%}>}SxxM42d+5OjBp zqOfpqVkdH0It%1Bbwmnt&l)#emBDWQ8$X@VP%;73pY)%+u{0{>3D0jdMbs7V<2sa= zT?Y%lDG6_WncT<_hZXv!IjkYC)ne6OPPuS*6PmA{C?69`Dx*IETOwSUQ=bRqhzu3? ze__7QIU(-m{|vMz>D|!!cmedt9UMgtQtvaA8WU!_Mb8ItKyvL8~&DG`Tb(%o*P%&5-4|8gK*;4Yc7!<&fIpD zB$_VSYnND^c^qA=0@8PC&STZ^=m2*}S~Uha&ks9+9uXx3>!v@7%%@^;26UrIAXlIW z8e*qW>dPnNbCrrELps^-^d2gIX4gmP3Sc*~C|;C+^3uLbsC|{+j;8@io9FXQ_k&d; z}Yy+L|TPCov1h&N!dyqUY1<{riw!>py zM|mJ{=3UARwe8%M%wG1{Tn)-0RR{4e7~?YJ zeDTJ+G`qGmcjcRduw&B}&l7k;T9q4>eK(eS7w5i$y8}48jgva-%w=1CEemot4ev$~FvQ#5ig6@}BHqVn4r|F0AEtMlZlkwes6yYy+hxdgo|% zKQtINia(sxH9W@!b_z>%*|n9LO&2^5Bk@!9qaZ=!xY6f3jdufp(E2V_AuGVSw)1cf zyoJB-J7-1b9Mg+y5Ko28|K>k4FY9&h_2xf-xCs{r3bXGny1W}A1nQ<_&p`r(p`N@5 zJw*aQ0j1ht#2&>eRe$T$v51)==~14V#ZeEX}##_7Y0{_%Qf* zfMHXme}ONFd%PLOzJMeakK6oK5ijGv ztWOQaQ%#~+w@+(p_zlL%_fjhS(1sj+meV8@=!qU>lesolZK*Qq zspg-&dJ*x%pvVm?t5D>UjzfOF&$0)(6rDzySzJA}wg z(Q4Fljny2dz{%DHl7sn|v|oYlQIy0t0mnw#`>8_G>fWx`z>R8&H`U?hiMWk)H*p+U zL-CczpD(!gwS$Xp{uBGUijJr9ZqJQQ*H##*kyPkZ)_NI&B$FNMTNuxCJc8#HO|En|PKI_^D>Q8gFqQ zyMWrqjn`jc;xw4dc+2T*qrEia1f+%u@-`Ti>!xU>Lw4v>ES^9SI%EUoXQ*-~F2M1!Cj6CLV+uB&qUXJCh ziM1Yq&Nw$4=RwZaAX;9KC3aE;tDX5*w?yQR+nf_gJ=%xGk)_oCtbAHN9&Zg`GvUg5HP9{-Pj)UZcwO@d$MqrL77Tw7Ah_ zN7%8*0#ujNWw5Q=P00QiPV<^7FwB^ z?iULpO*BB2A#UM6ArDB5XD@RW@NH{@X#YtdHf((D(S~=1M7P} zazj+@g0`rnkq9HFVP&iPCbNDVOH;wog)w+Dj0A2Q!(Q9`%$av9Zy!n9nuuE?;^=N^ z1y#*%hjY97lg_@BH!D-53PB~7gUdw-wUHXK_L1}=mvRE`uadch!`sa?$~LjphK40d zF^6%Wyl$%CD#=wL zHDOX zxj^Q(f|y5UH4LNIv?E&2vZ%`62hKF1c^H<7xiP*SRYJOLieG8uaEkbnknQ4U2}Qnm((xZ2GxK&Xy_qLsOh{>6U;qmtJ@=12O}&?{v8K(}Y>DG0#Wn(P5LVK0A&e-K%GUIexHxlpA*hHSB7Kn&RsAm~XWJkg;uh{q(-yn0G)S8~^n zS-|29*gyj2B_Pz#h@43Va(WV!1HBp%^@va2-zI%M4FL7`Wu>32`U*|44}L`Aeoa9_ zy0*-o{IUm+XSJU`@a7A?qf6=i=8Y%l<$as}^`yPrYx~K}ykqDk=GNZ|$IgDPLpODA9 z>q7Q>j^1>}j06TwN_Sa+TcHvv+Kt5hk80Io+X>~ERRm^iV<9K$>={xc-2Ny+&*@L( zQH$!?=W!Pzt77H1z|kwp8)&HU%KbFrItiCxhHe7FBk`b-B_}V_C-n0BAt2MY-hr`J ztFQ3=PAkDkZ{4-w7-S(?=tumtoZ>4g7Dw<7g>R3m9&w&|IWxPM{5HHbz=2$!?(^@z z`iaPL%hviZpznp(IIw)$8iz>?<7MV)SeSoAve^4zH(JXV%*q`mDc}a9X3h<(dZ}m4 ztNs$Hwq3*`<-(}p^{&jMGArIm1(dY;99E)Hq4ryGQW1_SZ{Pdf02lpuu1YBoh?|S7 z0rvAt-%8=F`yl2rD#)$yWuwq#*A9`*llHySZPP9SL6~&| z@QDCH7WciXv}&z>n~c%8#~Am<-eqxq$MM!`f7VyEQ!3KFifN_So`FqHt+1A{#LAil z0UZcQKu@06(mVan$A8sE?H-nT6Qj}B_K=d3(3X%!^?y>tZ>qS&pd~kC!Lc2NKJb{i< z>6*?Aw@T|=jvKRn0TT$Eau$nNdRqmSym6eFv_-jcx);boA7CiR4ysb8VnUgtAf9zS zcltI;-Sn^`+P!O52NVL$L_Rr2%OJEx&;YivC?ou!FX*P0BM+L@v2Exd==O&v$tE~j zC2Uv?Q$lg;Ool&oKr~<}itVEt;BI~*o?WA~C10)YEp~Cy$bT;0^}o$+2&DRSj6_q44yE&s7HI;r1GigNuJTdQpc>yMIVv(3V_?x%_dZk2q zDA>yo4?4~*U}GWFX13~=h>xg71SEdkJKdxG1pg6C@BRVT%n<^^Ng)2+-Z>mJjxCil zE4%y7=f8KIe$tn#F&|aT26OBD5uRMG(IRSvZ4+m5-UR^2o`d)pieA-OoOF07aBKA^ zw?h1=NVb5?I+=u};qmASiY;XyRX`oAOKzP4OkE;1Dq|L7fX z90wE!x0`Xyri$8fe>{gq_kWejlU`NtVEr2ufV2_Uf6|bLX3CnEQ8MkH&%BB>IG*4r z;B)~=0dua7#fzc#g66j0*>)wfd{Q;&>cRs^s8$0!)hcw{Y&k0hMSwI|!hZ!3mn!hq zptal;Yf15+vF~zv5)V8BXOgI_Q8tUSc=$d}S0Vrcvb>QiRH~Jd^?np!$CG43<~*X? z?me+K?G+z9WpRB<{b45JI{9^((`JiNU=MB0^DhYp^qyNyq`c80FDuagC~COOR!YM^ zwe<>xSkQf>hff$k7=Vbn=B@CKm2x}u%)H2Vz%*_@v`cvPgoJD+a{GzLYy5d)9Cqlv zs4E+r1^Bs%Kgh}o*1L{}HN%x9DIt^JkEft@uhoHx5crmiKc(5PPVIHfn!}oeqK}@H zzWJKZ^Swo4Bs5=Dux1GE)5oW3^&Q&k;( z_^0+{fakCMAHe1rKkuVzD#~-AX8I>lnpcDx#D9z!M4bvUWo&CO;X4%v!K^mWqFmQ` z{kQ5mC8EeAGWF9UztiAF;rAvV3;EuM-+DLMZ)PJkV%{7drX+_2(u~hx&DlPA25Yk| zf~6pbxEAun8dA{i^y|JM{z5;(2rXmpCGbX~ce>0Hra1N8e$W@ zgZ&->ea|!46dZR1gNycA87)yu1p$~$uvm&Uw=M7ms`F&qm^1lk8py~2z9nIrQT&YF zP$nPLyyc6K0XX>OBA{W{nt8#BH@&%9eZ9V+r-lv$TA|uc-6kljuKxh|fh&p=ReB)f zxA|V!{Ne}U1_2@(N$$uTOPK^GYF$4jJ*ij4oAX$#Nd?R=Nir8K7eA+a$b%tdaQV*b z_j`>5WtA?rmI{iNC6q>H4QhZ99RRTqHz zaV!kza)OW!IS&B*y}*F{f7*NRuco4|TNo=?Xo8|t6%|pM(rc8eQUZb?NKsLGhtLUK z1VmI+qy|t(0BO>sM!Hgk(4_Z3=!6a--#&mo&pYmS$GCsM%P&B3aHASmlFwme@bi@ z2X+T&v~VN^WY+6XmN|WBICLce<X4Uf3pmxNd0btFt=5) zQ&DG$^JKN`c>LuxKH=iog5EU3SskKj+1WKv37D&-o(uX!@BA|DT1Fkin}2QmcGvz^3wE_qZ! zq*h^%sv9#N+FR9CZ~c#LIf&s~cIuB^XurI<*GGB6fXA|Uij7RA>Z`<9=ff+oiwS!Z}cE`i- ze+5kT=dsOLqhcrXh3RSY;sukF4cEKYcnkY(uCIVxbAx%gcSVU=!z zE0S&yq)2;J-S}>OQ=9u}xpr=Ii?q6g`?#r*A4YXSC~#sta9UWkbL4Fh(_^6jMt ze}oq@u1j|HrE$5}NPsHQJ6G{xbmx_!cKQz2G%AI!@2wwcJ{1Clsi3&SOHbOwL$SrP}q2nDi0n z-%RJKP=MM9&`nHKK5nkRL^<-=*Lpo|jj6tGXVgTq4(FbdAuQ<)GJ8L{8Ps*=R#UVF z#fJchJqgMq8DFg9;Lkp*>;3#y(|39DrI(R8!>Q=_YbW6hcMK-= zZBVYIKn3#c0ISM+=Lb+Jv;`oXIvbk}SfgEhwssuo-8J8H&7DT~r%Xm~BtI67%Ys(> zH`~GTir@im!gR>CE-vvrTlSL6oy0QP>Q+qt_bsp#$02xd@$L-`;?vTb_-W)l)Y#TG zP=sL0VON~s;HAqXYDwBw5&iTP)muJb+qn**z+dMx7Hz-64J^*oM+g2S zfm#LlD%lPsMrk#iq1d_!7U`I^&XkV$5SzH%N+$?tur67DOdp=&ji-rq8YQ1b9 zg2KG6CfzxpalE~#obfWy8CwbSD+l&Ysm7=7l#%L*mWYGZ+vkX;O(=1$rfFmq1`tF| z1+K`VE}I!V!V3}Js72K-uqcZqTUPjc7Ibc$h(IiXglP46&!t}#J#HWl{YKe!trTWUf{O58_Tp5~a^2 zPbs|N4uE80i*`BgF^oxYMVTPxP!FOD63QV?4C6zD`UVnukF5z9)Gn~5?NWy(BGr{D z*>(ZGw=K>fc*H^IEhVD&L20;!s8>e()G( zIIaVx>xlqqRE_y`JbXt5kh9Rz99OP+z(A<6`g{U4F(>FgYKHrn)z1URek*~H>0A@> z@$LjF2Fdja{`Xx6&^8yRj($FCZysG)&4^J7q1Sx>X}+$3A1k#UHK@Ee@8BQIEv663 z)+bz*$pY!t&ld!1f*`F|6+;=6UP;542g>HGEZ?%majw(nT1}7Xd@;T&YtzU-U3OT# zxV`_f!aEpI5XN~aTVB`O7Fui;&zd5{FHCPkb$7B?8?gLbGhJVaxtX?|8%@kcLr!iM zvqy*0&fIH-BJbdECc(e@DSl6m&4;M;qc6i5g5O-h>P&PUv8ync~c`dn_ z8m<3MlsmF{W$nFq-yTEw3eet6Fy7581f=6OE^Zao18+wr#x0BQH+FMQdr{$57V>%EW>AGAo`(cM#9nET8~CB(*dHY_FE z&K)086mn)Nlksim05vDNAMcUDaMbG!!`zm{e3cdVCAbo0+}A9AM-qrBH$WSc&RBL7 zqu-BiH_K3od<2Jd7S7z5;ZSuftG`J@JD7Eq2@)F~^t&UYXs z%k)#`@v6Ghu}f_4`aRkx=eUdB0_)OaZ{LoE_U-eu%v*-H-fDURfBKP6vgRwX%f4ld zEZnkR8XPLi7Py58_f1>w-YZ1&HEk7z-@Qd)@mBT;(Use@IhLCaWbAG@y)W06#dyhN zzYT0Cdy&u7I&(rY_6?tfvNigo*05IiV(aRK_T;VALG6-0P-x?8!@xwWi2VP7*Rcr4&|HvUipux63E#wmwV=q$V- z>0nCN8)#N4W1~-e^=kzm4~3O4^sq2fn74VGM%lClzj>g56DT)=9o?7MIEWg)KM9@X_Dij`=A2)~g)sALdC3!8cevH+*G<+Hv#?Dm@3 z230#Gx{mXN(O|&&VzSy9sJ?X_lpL+iRtJTuN*Z;A*;Uqo;$%{`kkN+qumSY2kGH{8 z)w?(x0Rk+~)Xj_>*M-{XIs1)$YFZBT4KYo@t}O3tGBDaG{-~cpx3Dq*W)pVBetz6{PQ>3wO&D*$^uz` z_L1>0)mY`o)JuD>kTA0d#>y+6M;iG@okLy~FhJcmP?_QZ6=Kn$C2l3fpQhycfswC8eN5lrNn((x0*)O3QzO6#t`dPsh^-sXJAMC3jza5($Oi zeuxRjNiv?T74JkBe*d%Y}*)ZpLH=WQ(nOe&q95fbxPK)3kVr z&qkUa6fezPo%1CQPAF@$tTA$ReU9G*Ne+@>DZg!-Mj9`BzRHV;Y%Ihe;vlW#h4$QTQq0}5|KN7k|o}q!fuq(w8R$sF$ z>svBnT}Iws1SMIFkU_u&sq<4m!Cou_xD3L#O`3z`-f|Fdodw;oEq!*(7LGN2cgySI zqSa0>RUOMevaDxVcuK~r2){a`8t&QfE1uGUJm}3i+q3a`H@iZe3G7nG$28%_H+pM> zs~AGRiKIH5&w0slAglBx2TOfVtx0ti!(fydI?m@dfAt!{?*!i+K*?75@iw^WCu=uI z{J;SpOcNHe*ye_h>*|SUGUEef2|2YYRVF;e&bl>o!P@I$X_eAEnbQ_@%SMRd5_Gil z%R_u=XRL33?Kh{K{17#*UD`KG*68&<|0s@e_oQzJ2^a}MvF;Lue8rO&hXo8ZWDK%d z7c53oCa7ZVT$AEnze#X@QRD;)3!fgmj^3!~ds!f%)-w77keDJBm6SBGAwJOzi)JC~ ze8*AEaa3Ujj!nYH>Kiu}EKFjE=A4c)j{K+>%m_mkt?=!I=Jnp%$Kp+N#FLBXFU?Xo z@@@*`D9%&_WX-kO_Q$brOt_rNEdfMkLr+C$W>X+Q?){|icC`&UzByx^HL&WFD$huZ zbMesF*fR?j^b^*o?#VMOva!XrZ$rv&d4?xhfAc*xp&jsXIg7S=rOODlsT)gksshH< zsvfyMSL5>guCOD&$c9G(#q$YI*UMQ#ctIJN_#P8by(Ds{Sc&eo_)C{CEm8vAnH>;ua4KvUBICo(tLkiQyUDd=u#?FkJyd1i`5J< z6eEr=7A!*7t*OyG53|qPua9^{zzuSz-c->K#!Q~5Ub}OLX2P&fN3B^>%>G{I?x2Pa zp)?mUax&KN^*om(-okS+-#!t8MFwMOqr_1*^tXiZILX$d3jN^>8l%4VCj#6#Wx7~w zz9+87juJ&|Wh3Mz>9`W3nI`Vk$c22S1&KtIwq4B}$r++Znbz4kY`?yz$XmICJt)+OOq%o`ryMIPxX>cnzKhv~j|-CqoKPv><+sh=@oH zYuwN>HK7x>_<)>D3~ou^9%`nFCm6T#Lsj>ZP|QTHIWHPTq`;G}ryX76dvis-?E60i zlXhed7OBB@tyX4`9X}*cKE1BXRSV4lvJlAgN=o8t>3rW3wcW z@T#;3t2;qD>rziLRi)cEX|3yoZAg=%+E+pS2@s1nr<3PUS50AZkPc@M?;u(nOS~Dw zD!Wo<#^fkNY0`{#7k|l2{Jkh4B68)GGsVUQ1Mwfz6>-by-^rh0rx3rqTCQ`$e*W}7 z7T))HuvpT%Y>}8cTd;#g!3Rmbk4{13e@hdn6|p95SI>!JH=1gW9*56lMuw%$IzY=i zbRBFrQW!Gw(L5aeWLXhOD8s4$sz=A$bZ1{U&=KkZhb;^NrnCsJ{&xZDgThfoXGUMG ztEJ*sZWNe`&HO0ccMkJX?zk%9K*7~$oyUjRGZIl@N+tAa{4}H=P zsF*N4Az&WNxz$Vk#E3%UKc%!rKA+!_`KzvtSBLa`*Ss)01-49o^#Q5j;Qpov(X?6| z{+xj~k5)(RpwKFpgc1;!o>n-ETSJ-|OI%JI>+*Q^1Ed&fY!S`^t}~FAHnas0Rty;GNW7nl(m|bz$(Oz z&jq$-9-s;e-APh?tvas}VSRAo*3++NtzWng=x;bpf6wY~d0*plaVMHMmrv8*2`fk+lug|r+b%~Xq1aZ$cMQbMt~=d(R3lBlCfA+I5o^6&2f3RAY|XIC`;Elg_R^wt5;J8Wv#R22rXtd*x%Co5k@| zqgq!t^&$ zDJ+z!5-JkP@}!gXghgn# z-6eG+>D%oU7clpw4`>}|=Uf5uuq`iI{G9+Fi^;n0yk^9)8>=m}@6=niB2`IcxbOq* zjj?d=U~v>xeDkC+sq^mv=`9b3XF^&VPm=a^ukU-ug&#Jv`7L?c{hd#608qGt)-7l+sSW%L*(1VlQ$=mtT%=zVGliF?PIGy z(e3gJPJC*AJ|kPRc79OGsQ2MEyB*P}LzoKgqNd0?omTcW*++QK5y3FZkl*a|@?q*0 zH2RU=Na5CIr9s#ZIL*)qBCE9?M+cvfTv5xlmp!P%@R;cpbXh7@VmY!R)tqi7+Vd+F zYVNNY`P=Rw29#I6_viy$JnY95u);!7=3HDULhuI{vXuFXE|F@CwiPU5)>Vu-b0`eT z4r^=$4VjY;s~=IvS3b}&_yFX0;>5DM@0>%M<|_nfUVO=H9TqM1y`5OWL?RGdVA!+x zDPxWzByEDpglBYJEZjDV=D|BZ%ZZTW{b5rZ zU$XkMa8AuTckbSvTasV|^anw1jJ3MVu#jvDy+}VuCeT!yIt35Ju8)M`KuCpWz3UQX zRXR`}WRZeY&)XHD1gSP4KX7V=LEbt=qUVxf6h#^ZLI35BVLQoRocg6!>>CE_$1(D} zucKLgwGeIs^3vfsWFW%WPW%{LE*H>phTm|;P)5c7)Gcc9$tTTdnO62XmuqSXB6ys~ zgy+x?nYE&pXGY3^J`;Xt(6QE0sso#INUy^CRecvud!{iE|t_uC$fex!d- zA0Xsha*%eBRV`7V5cb3PjR)DhIfwyPAR&sF8YIpkmJPDh;cF|D26>d5)lDXb-^Hcg z+X=+(T%W%~#&cz=RGm;$B@N|ldB542u7gFflUDopQh-|a#)Fi`Dil_OWfkcMif@a| z^Ss9$F2y3-AB`()i@p8cOf3LOB&gjs=Zp!DzPqJa)_0|bVb-=}R%A`#(6NN$3C+n_ zp5xce0bw@y5QAa2QRL{eFF>;Ip@B`dloH+GAI!K0uIZS7=5iLXc8B$jBOLqf_Q?c@ zRq-Gmn6yRBK?iI7l|~L+vVPV~X=o60kkI45lNf$Kyw4?AM%jS2DM2(E$xLu8S^JV9 zIuL6^4xipTSy88MS)Vwd6^&u#YjQ|%va8m6gAY!Mm5Dd+s;2qooA+E$Q%j)CXU zumc-em~M6WGz+}cmNW6)MCcD?t9RjD3DnV&f}&??(i84E9!nt1Hz$ix&o@iXtFXrI z8u4$k$t4U3@q>+vj_NVfoKE#`UwXI#4>itvXQQU&U@E`pPiP}+=>vPV6qrsO(wmc? z&B6AZO05QpypzUc7>O0FI!)8-o7|2KuHU2$y?bKXN?@M$?sfBD%~a@wDdB}XD|lQB za1(beaxWE%eBR8GIf(;W*B%T=ah!0=C536K$XPjsVnzCJj2n53Cw3@pCsO@Xsa>IJ zd>Mc2gm4N>23l5h$$v(+qD0vg9x=}6-ww)TmB`P*VC84Sa+FyV7UaBL=e)K@Ic#Um z9|m3P`2}PkPa6vwzJn_^<%?sQ%07rv;eBu*J1@uZX1D6Glq*kJ!jCd4)>D0qnP3)Wzn-A{V}0~xjbwx zZNVKu8j^!p;02$S+C5>8h5>-E^xcvlR`XcN2c`u`3CZesVJr27p&RZkF52)LwboY%Bq@{jWuiIX8eaJ;(?an#Gd5zlf6SC0EJcbk@}ITTIfEN?3$T&t{h>S_$h_ zIK&N&lzg@W zN;M)kI_HK2@~BVRn4Mnx1g3S-F=m=rakXpV>?3(9zJq9ux+7}lX-7_+VRWRh_pX}` z=86|1m2aw=mrRZX5>+>`SOV1F)X01Tb?IBv9Z|$TEwQu$W+Mj< zp|<+)_gUmw$^oul{Wzrj?p*p9B|4zswWMh?wa4S5l=o>qmrs2a#Hq)=Vy0PRcVCn| z%M~|3UK_j~X~I8m$Y{XzEQ^-$=o6m4y7Z=?-#p{D zEb?xT;sHM`|x-@Ug zPp#dPKDsgtoAC50&;@ZbNEgS#AJ};y^BODFO4F(nylC=Dl34ZT+ie!dk6ZlX3~DPz zVyDnxNZMismQGBKX_BAgR~nuA?sBCJ;|8=!UwAT&+YUljY-I`@w)%Nl3y=5S-jM^g z%c2J~SUw+tNR&?z9m^@ApEr^j(OG?j92L6iHu}VhDx5ky=cVm1`=+u?Aa767AKBEz zUo3A7GYq~ZQu!c1S~``M-xm;G?o}Lin57v{m;!;84%^3%$0);V#IE-&ECqKZn$H%B zbNG-(mT|5xpRsrX@hWAI@^dFoN2W$PTs&sLD9XHG@i~(2$+A%r|L8D})&-DS7oc@u z7q9a%2S)bq6FJ!&T=8Q^$gZ&7BySO&P_iJ?RPMu1;Z1=#>L}5GM|nZcKWEkQZlmLz z^;-Ep_zUDtAz~OEN*HI=OKVM%whc=T-+I(|5?yPV*|8|m%N_DZ-}XE*znD$f9mGy> zqrp-s>LtDf(c;Y^U41DnCVV|sjxrQM6R#!buJ+(XK%EJNMV|v#W5fqu-7sOdt$mo= zSH^ZHi^#1G^cW;LoE~z^Q73%yt&}1b)!dyFb4$}xneSm%OdcOemxkC@Kg z+gGn$FE`!0|8M2Lk8leve}2IcwWV(n6C51u1RRn}X}K01X%`pz&g_E`6bZ-wJ*BjO zxOh~L*|TStcmW8d5eE+cUo|r{w_OuKf!A3_Ax0e7}{J|619SCmg`cK#g z3PHKvm>v`09RFV~`u6PZV&s6?=oA{rbnkxpzrRor>I+at0})&VDwG$*Ba1=41DhlDN`LZ^PEEc^uJ5dpesN2G;(7cEdkRoHSA(z|EkB{1Nd0{n{(3nATZMItadw& zWl{X~t5;t{PRZ=GD8b@ix5C51!_fty4C*?N{q6|_gjIl@rfQ7<>ec(x4fF2bXlc^l zf)wj5N~qw9FW7u9ra^x3~x5VsI2`#xzuirl}6L zvnX`)=DQw3k7d^1NBg(T67awwdUwYh$ZysFGqw~wK{7OyB95Efd;Ns?Pq*3tV=R~i z>3#-W6VE=uUz)w*+Ygvp+^YYU5vE5&PEJne-^%DIv^CNL1J8W!+}$sE&wjlVvK^4( zdQ=`_v|VGQVzge#biX%4H}?t&P&U-|zEEA5>+b~n(EaVX^2lZTZ|DehQ>G{5yN}*m zjId<>H)GW)|Bccq2xWI_xX>;}{ei>>7+1?5vOF(0feD=754j?Hw{Hj4T=)LMRD(?| zl!0yP%!PKzx$m419G0&eV8{8&qaV?A`9A1lrD7oFxofpjP_&O@5)%`z@Beq}$Q3Rw z9AJ{4>PODuk$jM0X%3hy=Frq@0y*TdO$DfxPcjVi5bi{ilm`tE#Hf+@Jjk z7a_G-sB;=^o<6;3;MA(1K!>of$c}UzZ3m3%NPzM{D=^0>ma)`fyncVG3rD-F?yo`2 z?UWk$QRxbr%;_%-EaGze} zm8N@#h}6_EF|Q#2k22yV15Du zeH+jjj`;}8C9&+**4AYY?3#!9uXmSg%rW%xvxS-(qh#Ay*J2RGT?Vu@iLz;f`FnM2 zF8WEmKwPdZ2qZFt$m-wKi*P)+8-J#tP!~rhu$P0aF%QJh^MGvjh_KM&vv#Vu+Lf>& zt5|2|tkPl#lI0rM>w;T{RQ@f&{FgX^tYSi1^>D+Jc8K2V+QenrNq?sa{Y`Pw2|#R9 zeU2@>hJmkW)nbvG1%+k2`zMFozfEnW2=rqcKx>}b$WtM#taz*k%gdISKzYb_54ae5L0aGWlHpnh0WZS_YQ49a}D^iIxtv3%T!jw55iU zAM}vc!wrI>tG(K95#g5!_O(Ux zDz;O740Xjc`Stk{b3rJX4XsT)(+;;r8Kxt&#oGm~jlDdpk#5NVy_?kXMZ4=t&*#>) za>+2KO%*vJ{641zWB%q>7ZkchOEACq+=xWA>s)({DWbLmj_D9~OV!3EYo?>r(=ESY zwXtZ;bnhI^bhs9$g;QqIk8>@{f#42Js@~4<}Vg@m)>^l6e_WEVhbe7f7 z99~~{)dSLBej-K7ZOS;nfsC6BlgdO!h%e~PW_8Sa<+O}Rv|kQS|E!H&o%|eXd0YBb zdd!otnbxOpTx;BPb^`*_r0h6kAs+``Pb@Vg_05&ll?<)o8(Jrr{nrhdlwg6y?P>@?Y47{>sx8NEw-sleot|Y3`~czH(3rhZ#+aV z>}R16w%(;f-WtJU=rz)9H*zgI2q7OV5h>bToLz+P9F;C2NZpLg+nF6j^P4|rU7Rge zB2RTh>!x#fFC?ncO(1lVl!pg?A3yk0*m$Qs_>%A9_zZxHT+94ZV^_|o{j znB5dh-hO(>`;7JdJXNh}Bd0UOX6#Jc2!0YPLmcDFFFq=0iSXR9hdrFk9GKq@g7Id` z&0*PXGRpr=_zJ*eiOVco?UA>wHu*8QUIn{~)J~sf$lnx&eKRT6#xBgXv=gG=*LB## ztx6VCmKRn_KvTstcbw5J%U(p5524h)IleGKO2cqWN5js-VDzCZ-GNb;<;A+wU6tR9 zWd!j*usf(7$LQ~-JQ^9@9@|9xj>Dqka?}h9MyW`<4&j>g>5xu(x;DE`P#Ijt+*X&` zB)y~6G^CuN15EXK^ZV)38krpx<-YEv`I_l1++peD9Kp;T$^2pm!L{{sI$fNV=y`HP z_!r}FSh*{CTQE+Kga>y-8%7*eS81OJPNieZ5{-9LOw!9SmkNT*=?96D36cyQA@j)i zPxWgGjLd1L;UBM3>Q(B_i}P!gIjlR8w}{0(*U}b0GWjd$_5pd6xM++vjqA82eg5W2 z@WtbLaan~w;*Ay(F~QOJ?-e|jcCO7T&!gw3fSxQZTE_=TW5vM-8JTsu^PBlo3+7@I0Trw|w9hCW=#}W!Q}>k0 zGq4IiJLKJA!k);s7FdwRz7_A~yoq;Mcx_RG%Pygj)Jr|w!N8e~AegIN zmw&bEB;oD+F;WU)ydbqt=YD#+Xs?U6iHCx)$!oSeWzcG*_?t3RxhC7I&oRT@iK9lV zV3N4=OKtTv)VAxTxJS*Wx0XiM+HI7J^a2PUu)n3B;bq(nu+u7v`&ErznyrH?;d zGu>OT-KI$CN%8$wM$LTQ1JL@3r?7G_q5Pj+iI!4$JuSYLvL0Of ztwRqpDR260{^**U)c+cp*{K;3vA63?56`-sR-(io<$X;X|GkSLJ^c)71Ma=l*4oqp zBU%+tpDnob4L!BLp&dNQ?G+!cav@BAID`7NUo$4&fq_C-Ubba#fe0nyLeFtGQW zFDLU$5$~T}KbK#8hSr;ynYRWi7sHqsF2?D3%}-=qN&8^AlBdS9Lh7AY(YkK2R94v6 zDXejJKGl$22MMl%X$UL<#3qmWU`S{&(%=^ zK_P<=Z1dJ&>Atl@7~XL&b@ay2cZ4#(&8phbBYl}x_y0~aF22AdLE-`M4>x+AjBTw#+!6jhpe8F*#mCS#=0&uO3;(fc%6f| zT1$T;b0>OQJ^!o@ERWQSn-BFmbff&UgCJfsX@0Z4x$acQwJe2dM0f8wokv-j13P8U z`8zB|cjt49Ev94T9{3N!Jt@B%{dIq7GhJME( zm}a^q-5|*n`E5Gc2podlM(JzdDb=dKZw|MK#Sq544i*hE=7XFmLJfYvAbs{wmR$Sz zr!|^Zky%h@W_AAZSpnjUpdiYn8)pF`&HI|>wIUB+Z0%R}jZcnUiG~|jXI)<3DaGf|e6^6BYv8dk!&e01-L3Av%Ja%15d*@|4!9+bh=5~h7i%(rIH^Pcy z4`FA%HMA+gHd_z-Kee#92H5C4VPR8p4;1!30BVtBW}-I2OwNj%3Q?n+Ei_oHShK?Q z`cGOQ1c6lmoz8pzyw8$bT%ZDz%bxA2ms%JG1zz5e7z=V$rv>fOQ%^@975V4K7J_O+ zd;X<4 zR4f8oz<$Wn!?^#l@vuM;W6qj?j|G8RT6?ZX0{Q#Va z1LfwpJ@>xj$E|N1pW z2ttBN-H4~O#;4FIpwWW&j)#u_eQ(JB&R>si(h_7nz=2%UT~Hyg7iV9`7MaimN>Y?N zRUBdZlI2aB-uJtXc7RCoaO(aIe&hG!#%;iPVpx`qEhs^fHb>fV0Q1`S$XV$7Xml;uK9gmQ|<3O zJE|u`Ev%O3StmI#ETm{eNEc*{q;N0wL%JbO<}DzZYEi zcfluf0Gk;&|9P%(UKn{saTZQ5RR$b-96$pO?>Beo_dl1aG+GRFgF+Z6l4C(sm~?V* zxCxE$6jUS?TCn>bH%-)E|LRA@%LsKy$SMycouHFMR07oGWM-W_J@ps%z3tcke6b&i zAvkqnDy9;%^76NNfC>mX_F5Mv$JmzZPVQek@%rD5-#!F-8T5)kf6aW+5tK6sW0eTY zh5+n9wY}An@NYArn79}Ff*G9@54gKH^yj0#?NtoExNIwrzgtr&S z6glt_Lb)I~i(y4sEMgBUi z9_h*U`W*8;nAcLOJaATlkC&`1Dz_m4?D6~QF%<|i2-_*P$lnO-0XF6h15SyR3IqnS zI_qNXJkiz?N*S5Dne<#v7S-(e#Uj802A3u5nWC%ILck(r-7`-YibYQ{d6)zLbzCMw z&wxL_SUgGbB)?b(0;Ah*?v=7xQ)WjPDg5$TGJg{|Mk2KxO^?Hk{@#tW4&79)YrEE` zlhBoNfKh=2fRU6*tETB~H?yrmTmg&iJ+13Dv1`b&wx^XM@*01(O}joxWmSa03}Oti zL#j0-x~}2$E<^OsuAw-N^;v+Yd3$Y=y2vTH2=iUWOW+hOVooHQ)6zG6x%OXoU>J)gW@g_oMc_<=$Iy zQvh9Z039rkLe!wXs&bVMuheXr#`h&_cYTqfWM8}8M{94So^e(Oi>R?x!;+h8`O`au zU+R%qyw@plUMU`;-opJJ4c5UH3K_2vZBp39_Oa$>*7f{gi>87zz|}OKn)kjMXFn$y zidvK2vf3=cmKtVcRy3{|cr%G^OnJ78*3D7o@GD`au%gOV~fKtoOYslX8DX zb;?}DtTr6IN26d_;Cowq;brUS?nf!wI(&p>>`dRG3z`1A!`3jfn64Cm2!#ZfU6lQ? zF;u%A0jEsSGubS1jGfdS0qgJt^= z8nrp=V>VM2%V`W8RV@(XPfr>QFk%$jIcrahdnB&MF>4e_eFUH@cImrBR%BX7haly^ z$=oIv0L?TwdK6(9>uH_Q%x)a2KZScq>fyS*S4NBK*#z^jY}X1^Gvxrj@ad0BeqEOP z_@ZC_%S~3tK6?XNhU0TV?v2|!Otislz~?tyL#8B=(?Cyly5^$qlJL1Mz}MA_iHgnS|L^& z){c;Uxl!&e&%qr!4WQf6w@j9Za;_vpvzRm{l-qKItj2|uM&GUrJqf0UtU7pP#I$8brmYZyc~3LurDm6cdCNWmif}im0%gob|9<}AfpNJW7y&l zCosa1>pP(yv6#-P!M;JIdXI+qDSZ~O0K>@(iJ5Em0E!1-TkGlXp#@W`&}lNAy;kEj z1op2ohYY@IDV%6n`n38^-&&Rq?7?=jUX3eXnGhDjfU|l>VHJ0qUz?@@?+_4oh5nhMFQ6%zU5m%h+u1UaP zK9gQJz0i5-xUy9L`70;LTm6H|Yc@L;N$w0}YaTX4qa_XoBg^I_l1q#>vueC#^Bpo| zv@LBQi>BZG` z0GJ${nYL(c2v-aPrbT$9#Y%vlK@6s0LqZCi4KaYTH`_ije|nUF>Bu>=*u72*z{D8o^0$XsbBV49T5R`YAh7I^{eRdeX6a()kp%V?yJ#=o3?V~P06ppg*;ki+eu*{KT2 zLM|||{b`xHE?BH^S|F?)GLW;D;o9kzTA3Yisde?uW+u_p4&keni*X(!0mH*jd1b+- zpV?9Ou`|7c;4UR&kdETDSaHGTUDhc3G}w?Y(V0HKy==M?WuZP0&XNDLH%#;xQ^$E3 zqJbobvK&{u84MYgL> z`bDnz_s*&_DucyS*ptsP;{O{6+4q|sV^g~>{SP0C;_Cn5&ox*|dZYJA0)MZa5naJj YWEar~{rMFq3B7n%K}|kW_Oajp2cor%zyJUM literal 0 HcmV?d00001 diff --git a/lerna.json b/lerna.json index 7a9ee6a4cb..2cc439d190 100644 --- a/lerna.json +++ b/lerna.json @@ -5,5 +5,5 @@ ], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.2" + "version": "0.1.1-alpha.3" } diff --git a/packages/app/package.json b/packages/app/package.json index e24ac9688c..0d7d29e480 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,13 +1,13 @@ { "name": "example-app", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "private": true, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.2", - "@backstage/core": "^0.1.1-alpha.2", - "@backstage/theme": "^0.1.1-alpha.2", - "@backstage/plugin-home-page": "^0.1.1-alpha.2", - "@backstage/plugin-welcome": "^0.1.1-alpha.2", + "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/core": "^0.1.1-alpha.3", + "@backstage/plugin-home-page": "^0.1.1-alpha.3", + "@backstage/plugin-welcome": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.45", diff --git a/packages/cli/package.json b/packages/cli/package.json index 40b5d78040..1764d19bf2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "private": false, "publishConfig": { "access": "public" diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index 826f9866d2..5863d9e705 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -57,11 +57,11 @@ async function cleanUp(tempDir: string) { }); } -async function buildApp(pluginFolder: string) { +async function buildApp(appFolder: string) { const commands = ['yarn install', 'yarn build']; for (const command of commands) { await Task.forItem('executing', command, async () => { - process.chdir(pluginFolder); + process.chdir(appFolder); await exec(command).catch(error => { process.stdout.write(error.stderr); @@ -80,7 +80,7 @@ export async function moveApp( await Task.forItem('moving', id, async () => { await fs.move(tempDir, destination).catch(error => { throw new Error( - `Failed to move plugin from ${tempDir} to ${destination}: ${error.message}`, + `Failed to move app from ${tempDir} to ${destination}: ${error.message}`, ); }); }); @@ -97,7 +97,7 @@ export default async () => { return chalk.red('Please enter a name for the app'); } else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(value)) { return chalk.red( - 'Plugin name must be kebab-cased and contain only letters, digits, and dashes.', + 'App name must be kebab-cased and contain only letters, digits, and dashes.', ); } return true; diff --git a/packages/cli/templates/default-app/plugins/welcome/tsconfig.json b/packages/cli/templates/default-app/plugins/welcome/tsconfig.json index 596e2cf729..7b73db2f0f 100644 --- a/packages/cli/templates/default-app/plugins/welcome/tsconfig.json +++ b/packages/cli/templates/default-app/plugins/welcome/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src"] + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } } diff --git a/packages/cli/templates/default-plugin/tsconfig.json b/packages/cli/templates/default-plugin/tsconfig.json index 596e2cf729..7b73db2f0f 100644 --- a/packages/cli/templates/default-plugin/tsconfig.json +++ b/packages/cli/templates/default-plugin/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src"] + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } } diff --git a/packages/core/package.json b/packages/core/package.json index 85054eba14..cc30222cf4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "private": false, "publishConfig": { "access": "public" @@ -41,9 +41,9 @@ "recompose": "0.30.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.2", - "@backstage/test-utils": "^0.1.1-alpha.2", - "@backstage/theme": "^0.1.1-alpha.2", + "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/test-utils": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", diff --git a/packages/core/src/api/apis/definitions/featureFlags.ts b/packages/core/src/api/apis/definitions/featureFlags.ts index 926fe091d5..5d6a134f84 100644 --- a/packages/core/src/api/apis/definitions/featureFlags.ts +++ b/packages/core/src/api/apis/definitions/featureFlags.ts @@ -19,7 +19,7 @@ import { UserFlags, FeatureFlagsRegistry, FeatureFlagsRegistryItem, -} from '../../app/FeatureFlags'; +} from 'api/app/FeatureFlags'; /** * The feature flags API is used to toggle functionality to users across plugins and Backstage. diff --git a/packages/core/src/api/app/AppBuilder.tsx b/packages/core/src/api/app/AppBuilder.tsx index 23d0e17672..ccc1303ccb 100644 --- a/packages/core/src/api/app/AppBuilder.tsx +++ b/packages/core/src/api/app/AppBuilder.tsx @@ -18,16 +18,16 @@ import React, { ComponentType } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; import { App } from './types'; -import BackstagePlugin from '../plugin/Plugin'; +import BackstagePlugin from 'api/plugin/Plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; -import { featureFlagsApiRef } from '../apis/definitions/featureFlags'; +import { featureFlagsApiRef } from 'api/apis/definitions/featureFlags'; import { IconComponent, SystemIcons, SystemIconKey, defaultSystemIcons, -} from '../../icons'; -import { ApiHolder, ApiProvider } from '../apis'; +} from 'icons'; +import { ApiHolder, ApiProvider } from 'api/apis'; import LoginPage from './LoginPage'; class AppImpl implements App { diff --git a/packages/core/src/api/app/FeatureFlags.test.tsx b/packages/core/src/api/app/FeatureFlags.test.tsx index b5bbb4d68e..cc09ebc61d 100644 --- a/packages/core/src/api/app/FeatureFlags.test.tsx +++ b/packages/core/src/api/app/FeatureFlags.test.tsx @@ -15,7 +15,7 @@ */ import { FeatureFlags as FeatureFlagsImpl } from './FeatureFlags'; -import { FeatureFlagState } from '../apis/definitions/featureFlags'; +import { FeatureFlagState } from 'api/apis/definitions/featureFlags'; describe('FeatureFlags', () => { beforeEach(() => { diff --git a/packages/core/src/api/app/FeatureFlags.tsx b/packages/core/src/api/app/FeatureFlags.tsx index 8e5a06b209..fcbefedb3e 100644 --- a/packages/core/src/api/app/FeatureFlags.tsx +++ b/packages/core/src/api/app/FeatureFlags.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import { FeatureFlagName } from '../plugin/types'; +import { FeatureFlagName } from 'api/plugin/types'; import { FeatureFlagState, FeatureFlagsApi, -} from '../apis/definitions/featureFlags'; +} from 'api/apis/definitions/featureFlags'; /** * Helper method for validating compatibility and flag name. diff --git a/packages/core/src/api/app/LoginPage/LoginPage.tsx b/packages/core/src/api/app/LoginPage/LoginPage.tsx index 7352600f63..9176c4c522 100644 --- a/packages/core/src/api/app/LoginPage/LoginPage.tsx +++ b/packages/core/src/api/app/LoginPage/LoginPage.tsx @@ -16,10 +16,10 @@ import React, { FC, useState } from 'react'; import { GitHub as GitHubIcon } from '@material-ui/icons'; -import Page from '../../../layout/Page'; -import Header from '../../../layout/Header'; -import Content from '../../../layout/Content/Content'; -import ContentHeader from '../../../layout/ContentHeader/ContentHeader'; +import Page from 'layout/Page'; +import Header from 'layout/Header'; +import Content from 'layout/Content/Content'; +import ContentHeader from 'layout/ContentHeader/ContentHeader'; import { Grid, Typography, @@ -29,7 +29,7 @@ import { ListItem, Link, } from '@material-ui/core'; -import InfoCard from '../../../layout/InfoCard/InfoCard'; +import InfoCard from 'layout/InfoCard/InfoCard'; enum AuthType { GitHub, diff --git a/packages/core/src/api/app/types.ts b/packages/core/src/api/app/types.ts index 4a6efe66fb..cc54cef606 100644 --- a/packages/core/src/api/app/types.ts +++ b/packages/core/src/api/app/types.ts @@ -15,7 +15,7 @@ */ import { ComponentType } from 'react'; -import { IconComponent, SystemIconKey } from '../../icons'; +import { IconComponent, SystemIconKey } from 'icons'; export type App = { getSystemIcon(key: SystemIconKey): IconComponent; diff --git a/packages/core/src/api/plugin/Plugin.tsx b/packages/core/src/api/plugin/Plugin.tsx index aade5d0092..47f8331e10 100644 --- a/packages/core/src/api/plugin/Plugin.tsx +++ b/packages/core/src/api/plugin/Plugin.tsx @@ -21,8 +21,8 @@ import { RouteOptions, FeatureFlagName, } from './types'; -import { validateBrowserCompat, validateFlagName } from '../app/FeatureFlags'; -import { Widget } from '../widgetView/types'; +import { validateBrowserCompat, validateFlagName } from 'api/app/FeatureFlags'; +import { Widget } from 'api/widgetView/types'; export type PluginConfig = { id: string; diff --git a/packages/core/src/api/plugin/types.ts b/packages/core/src/api/plugin/types.ts index b006e06d9a..3192dfd095 100644 --- a/packages/core/src/api/plugin/types.ts +++ b/packages/core/src/api/plugin/types.ts @@ -15,7 +15,7 @@ */ import { ComponentType } from 'react'; -import { Widget } from '../widgetView/types'; +import { Widget } from 'api/widgetView/types'; export type RouteOptions = { // Whether the route path must match exactly, defaults to true. diff --git a/packages/core/src/api/widgetView/WidgetViewBuilder.tsx b/packages/core/src/api/widgetView/WidgetViewBuilder.tsx index fb26eadbe3..0bf873df86 100644 --- a/packages/core/src/api/widgetView/WidgetViewBuilder.tsx +++ b/packages/core/src/api/widgetView/WidgetViewBuilder.tsx @@ -15,10 +15,10 @@ */ import React, { ComponentType } from 'react'; -import { AppComponentBuilder } from '../app/types'; +import { AppComponentBuilder } from 'api/app/types'; import { Widget } from './types'; -import BackstagePlugin from '../plugin/Plugin'; -import DefaultWidgetView from '../../components/DefaultWidgetView'; +import BackstagePlugin from 'api/plugin/Plugin'; +import DefaultWidgetView from 'components/DefaultWidgetView'; type WidgetViewRegistration = | { diff --git a/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx b/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx index 26246d5341..990ecb0e92 100644 --- a/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx +++ b/packages/core/src/components/DefaultWidgetView/DefaultWidgetView.tsx @@ -16,7 +16,7 @@ import React, { FC } from 'react'; import { Grid, Paper, makeStyles, Theme } from '@material-ui/core'; -import { WidgetViewProps } from '../../api/widgetView/types'; +import { WidgetViewProps } from 'api/widgetView/types'; const useStyles = makeStyles(theme => ({ root: { diff --git a/packages/core/src/components/ProgressCard.tsx b/packages/core/src/components/ProgressCard.tsx index ef84c40499..1964f382e9 100644 --- a/packages/core/src/components/ProgressCard.tsx +++ b/packages/core/src/components/ProgressCard.tsx @@ -17,8 +17,8 @@ import React, { FC } from 'react'; import { makeStyles } from '@material-ui/core'; -import InfoCard from '../layout/InfoCard'; -import { Props as BottomLinkProps } from '../layout/InfoCard/BottomLink'; +import InfoCard from 'layout/InfoCard'; +import { Props as BottomLinkProps } from 'layout/InfoCard/BottomLink'; import CircleProgress from './CircleProgress'; type Props = { diff --git a/packages/core/src/icons/icons.tsx b/packages/core/src/icons/icons.tsx index 454a10c264..a8debf56b4 100644 --- a/packages/core/src/icons/icons.tsx +++ b/packages/core/src/icons/icons.tsx @@ -18,7 +18,7 @@ import { SvgIconProps } from '@material-ui/core'; import PeopleIcon from '@material-ui/icons/People'; import PersonIcon from '@material-ui/icons/Person'; import React, { FC } from 'react'; -import { useApp } from '../api/app/AppContext'; +import { useApp } from 'api/app/AppContext'; import { IconComponent, SystemIconKey, SystemIcons } from './types'; export const defaultSystemIcons: SystemIcons = { diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index 6af4019d13..5ed929d9a0 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -19,7 +19,7 @@ import Helmet from 'react-helmet'; import { Typography, Tooltip, makeStyles } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; -import { Theme } from '../Page/Page'; +import { Theme } from 'layout/Page/Page'; // import { Link } from 'shared/components'; import Waves from './Waves'; diff --git a/packages/core/src/layout/Header/Waves.test.tsx b/packages/core/src/layout/Header/Waves.test.tsx index 2c7d463052..02ea8da634 100644 --- a/packages/core/src/layout/Header/Waves.test.tsx +++ b/packages/core/src/layout/Header/Waves.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import { render } from '@testing-library/react'; -import { pageTheme } from '../Page/PageThemeProvider'; +import { pageTheme } from 'layout/Page/PageThemeProvider'; import Waves from './Waves'; describe('', () => { diff --git a/packages/core/src/layout/Header/Waves.tsx b/packages/core/src/layout/Header/Waves.tsx index f37ecf7b12..001f3d61c4 100644 --- a/packages/core/src/layout/Header/Waves.tsx +++ b/packages/core/src/layout/Header/Waves.tsx @@ -16,7 +16,7 @@ import React, { FC } from 'react'; import { makeStyles } from '@material-ui/core'; -import { PageTheme } from '../Page'; +import { PageTheme } from 'layout/Page'; const useStyles = makeStyles({ wave: { diff --git a/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js b/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js index d13f0bd07a..abe0ee8b33 100644 --- a/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js +++ b/packages/core/src/layout/HeaderLabel/OwnerHeaderLabel.js @@ -18,7 +18,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import { Tooltip, Link, withStyles } from '@material-ui/core'; -import { StatusError } from '../../components/Status'; +import { StatusError } from 'components/Status'; import HeaderLabel from './HeaderLabel'; const style = theme => ({ diff --git a/packages/core/src/layout/InfoCard/InfoCard.tsx b/packages/core/src/layout/InfoCard/InfoCard.tsx index 10f9f60da0..caa5f16513 100644 --- a/packages/core/src/layout/InfoCard/InfoCard.tsx +++ b/packages/core/src/layout/InfoCard/InfoCard.tsx @@ -24,7 +24,7 @@ import { withStyles, makeStyles, } from '@material-ui/core'; -import ErrorBoundary from '../ErrorBoundary/ErrorBoundary'; +import ErrorBoundary from 'layout/ErrorBoundary/ErrorBoundary'; import BottomLink, { Props as BottomLinkProps } from './BottomLink'; const useStyles = makeStyles(theme => ({ diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index ad43a0b986..5c5c42c25f 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -2,6 +2,7 @@ "extends": "../../tsconfig.json", "include": ["src"], "compilerOptions": { + "baseUrl": "src", "noImplicitAny": false } } diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index afed6723d8..55d731c8e6 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -1,3 +1,5 @@ +const path = require('path'); + module.exports = { stories: [ '../../core/src/layout/**/*.stories.tsx', @@ -5,6 +7,7 @@ module.exports = { ], addons: ['@storybook/addon-actions', '@storybook/addon-links'], webpackFinal: async config => { + config.resolve.modules.push(path.resolve(__dirname, '../../core/src')); config.module.rules.push( { test: /\.(ts|tsx)$/, diff --git a/packages/storybook/package.json b/packages/storybook/package.json index fc15da6207..4dbf2990fa 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "description": "Storybook build for core package", "private": true, "scripts": { diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index ce2861353f..98b2dcd749 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "private": false, "publishConfig": { "access": "public" @@ -24,8 +24,8 @@ "test": "backstage-cli test" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.2", - "@backstage/theme": "^0.1.1-alpha.2", + "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json index 596e2cf729..7b73db2f0f 100644 --- a/packages/test-utils/tsconfig.json +++ b/packages/test-utils/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src"] + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } } diff --git a/packages/theme/package.json b/packages/theme/package.json index 450f8f6f86..b08014e9d6 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "private": false, "publishConfig": { "access": "public" @@ -23,7 +23,7 @@ "lint": "backstage-cli lint" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.2", + "@backstage/cli": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1" }, "peerDependencies": { diff --git a/packages/theme/tsconfig.json b/packages/theme/tsconfig.json index 596e2cf729..7b73db2f0f 100644 --- a/packages/theme/tsconfig.json +++ b/packages/theme/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src"] + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } } diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json index 73e208b737..e5d90de209 100644 --- a/plugins/home-page/package.json +++ b/plugins/home-page/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-home-page", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "license": "Apache-2.0", @@ -11,9 +11,9 @@ "test": "backstage-cli test" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.2", - "@backstage/core": "^0.1.1-alpha.2", - "@backstage/theme": "^0.1.1-alpha.2", + "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/core": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", "@testing-library/jest-dom": "^4.2.4", diff --git a/plugins/home-page/src/components/HomePage/HomePage.tsx b/plugins/home-page/src/components/HomePage/HomePage.tsx index c3f0250d71..e9e71c3b0b 100644 --- a/plugins/home-page/src/components/HomePage/HomePage.tsx +++ b/plugins/home-page/src/components/HomePage/HomePage.tsx @@ -16,7 +16,7 @@ import React, { FC } from 'react'; import { Typography, Link, Grid } from '@material-ui/core'; -import HomePageTimer from '../HomepageTimer'; +import HomePageTimer from 'components/HomepageTimer'; import { Content, InfoCard, Header, Page, pageTheme } from '@backstage/core'; import SquadTechHealth from './SquadTechHealth'; import Table from '@material-ui/core/Table'; diff --git a/plugins/home-page/src/plugin.ts b/plugins/home-page/src/plugin.ts index 17eb89eb35..c3b4d30fe3 100644 --- a/plugins/home-page/src/plugin.ts +++ b/plugins/home-page/src/plugin.ts @@ -15,7 +15,7 @@ */ import { createPlugin } from '@backstage/core'; -import HomePage from './components/HomePage'; +import HomePage from 'components/HomePage'; export default createPlugin({ id: 'home-page', diff --git a/plugins/home-page/tsconfig.json b/plugins/home-page/tsconfig.json index 596e2cf729..7b73db2f0f 100644 --- a/plugins/home-page/tsconfig.json +++ b/plugins/home-page/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src"] + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } } diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index f4f33d6265..0d31b0d635 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.1.1-alpha.2", + "version": "0.1.1-alpha.3", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "private": true, @@ -11,9 +11,9 @@ "test": "backstage-cli test" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.2", - "@backstage/core": "^0.1.1-alpha.2", - "@backstage/theme": "^0.1.1-alpha.2", + "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/core": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index eea582ab55..307369f5fd 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -24,7 +24,7 @@ import { ListItemText, Link, } from '@material-ui/core'; -import Timer from '../Timer'; +import Timer from 'components/Timer'; import { Content, InfoCard, diff --git a/plugins/welcome/src/plugin.ts b/plugins/welcome/src/plugin.ts index e7755862ff..740c6d2da3 100644 --- a/plugins/welcome/src/plugin.ts +++ b/plugins/welcome/src/plugin.ts @@ -15,7 +15,7 @@ */ import { createPlugin } from '@backstage/core'; -import WelcomePage from './components/WelcomePage'; +import WelcomePage from 'components/WelcomePage'; export default createPlugin({ id: 'welcome', diff --git a/plugins/welcome/tsconfig.json b/plugins/welcome/tsconfig.json index 596e2cf729..7b73db2f0f 100644 --- a/plugins/welcome/tsconfig.json +++ b/plugins/welcome/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../../tsconfig.json", - "include": ["src"] + "include": ["src"], + "compilerOptions": { + "baseUrl": "src" + } } From 083f104946483f982387457038029d4ff4fe15b0 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Wed, 8 Apr 2020 23:30:26 -0500 Subject: [PATCH 08/80] removePlugin.ts now uses Task helper --- .../commands/remove-plugin/removePlugin.ts | 330 ++++++++---------- 1 file changed, 147 insertions(+), 183 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.ts b/packages/cli/src/commands/remove-plugin/removePlugin.ts index 51f10fbc7a..76b8606973 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.ts @@ -17,89 +17,70 @@ import fse from 'fs-extra'; import path from 'path'; import chalk from 'chalk'; import inquirer, { Answers, Question } from 'inquirer'; -import { realpathSync } from 'fs'; -import ora from 'ora'; +import { getCodeownersFilePath } from '../create-plugin/lib/codeowners'; +import { paths } from 'helpers/paths'; +import { Task } from 'helpers/tasks'; // import os from 'os'; -const MARKER_SUCCESS = chalk.green(` ✔︎`); -const MARKER_FAILURE = chalk.red(` ✘`); const BACKSTAGE = '@backstage'; export const checkExists = async (rootDir: string, pluginName: string) => { - const destination = path.join(rootDir, 'plugins', pluginName); - const spinner = ora({ - prefixText: ` Checking plugin exists.`, - spinner: 'arc', - color: 'green', - }).start(); - try { - const pathExist = await fse.pathExists(destination); - if (pathExist) { - spinner.succeed(); - console.log( - chalk.green( - ` Plugin ID ${chalk.cyan( - pluginName, - )} exists at: ${destination.replace( - `${rootDir}`, - '', - )} ${MARKER_SUCCESS}`, + await Task.forItem('checking', pluginName, async () => { + try { + const destination = path.join(rootDir, 'plugins', pluginName); + const pathExist = await fse.pathExists(destination); + + if (!pathExist) { + throw new Error( + chalk.red(` Plugin ${chalk.cyan(pluginName)} does not exist!`), + ); + } + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing plugin ${chalk.cyan(pluginName)}: ${ + e.message + }`, ), ); - } else { - throw new Error( - chalk.red(` Plugin ${chalk.cyan(pluginName)} does not exist!`), - ); } - } catch (e) { - spinner.fail(); - throw new Error( - chalk.red( - ` There was an error removing plugin ${chalk.cyan(pluginName)}: ${ - e.message - }`, - ), - ); - } + }); }; -export const removePluginDirectory = async ( - destination: string, - pluginName: string, -) => { - console.log(` Removing plugin files ${chalk.cyan(destination)}.`); - try { - await fse.remove(destination); - console.log( - chalk.green(` Plugin files removed successfully. ${MARKER_SUCCESS}`), - ); - } catch (e) { - throw Error( - ` Could not remove Plugin\t${pluginName}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`, - ); - } +export const removePluginDirectory = async (destination: string) => { + await Task.forItem('removing', 'plugin files', async () => { + try { + await fse.remove(destination); + } catch (e) { + throw Error( + chalk.red( + ` There was a problem removing the plugin directory: ${e.message}`, + ), + ); + } + }); }; export const removeSymLink = async (destination: string) => { - console.log( - ` Removing symbolic link if it exists at:\t${chalk.cyan(destination)}.`, - ); - const symLinkExists = fse.pathExists(destination); - if (symLinkExists) { - try { - await fse.remove(destination); - console.log( - chalk.green(` Symbolic link successfully removed. ${MARKER_SUCCESS}`), - ); - } catch (e) { - throw Error( - ` Could not remove symbolic link\t${destination}. ${MARKER_FAILURE} \n Please try again. Error: ${e.message}`, - ); + await Task.forItem('removing', 'symbolic link', async () => { + const symLinkExists = fse.pathExists(destination); + if (symLinkExists) { + try { + await fse.remove(destination); + } catch (e) { + throw Error( + chalk.red( + ` Could not remove symbolic link\t${chalk.cyan(destination)}: ${ + e.message + }`, + ), + ); + } } - } + }); }; -export const removeStatementContainingID = async (file: string, ID: string) => { +const removeAllStatementsContainingID = async (file: string, ID: string) => { const originalContent = await fse.readFile(file, 'utf8'); const contentAfterRemoval = originalContent .split('\n') @@ -107,7 +88,6 @@ export const removeStatementContainingID = async (file: string, ID: string) => { .filter(statement => { return !statement.includes(`${ID}`); }) // get rid of lines with pluginName - .sort() .concat(['']) // newline at end of line .join('\n'); await fse.writeFile(file, contentAfterRemoval, 'utf8'); @@ -119,7 +99,7 @@ export const removeStatementContainingID = async (file: string, ID: string) => { const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); -export const removeExportStatementFromPlugins = async ( +export const removeReferencesFromPluginsFile = async ( pluginsFile: string, pluginName: string, ) => { @@ -127,107 +107,82 @@ export const removeExportStatementFromPlugins = async ( .split('-') .map(name => capitalize(name)) .join(''); - console.log( - ` Removing export statement from ${chalk.cyan( - pluginsFile.replace(pluginsFile.split('/app', 1)[0], ''), - )}`, - ); // remove long path - try { - await removeStatementContainingID(pluginsFile, pluginNameCapitalized); - console.log( - chalk.green( - ` Successfully removed export statement from /app/src/plugin.ts ${MARKER_SUCCESS}`, - ), - ); - } catch (e) { - throw new Error( - chalk.red( - ` There was an error removing export statement for plugin ${chalk.cyan( - pluginNameCapitalized, - )} ${MARKER_FAILURE} ${e.message}`, - ), - ); - } + + await Task.forItem('removing', 'export references', async () => { + try { + await removeAllStatementsContainingID(pluginsFile, pluginNameCapitalized); + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing export statement for plugin ${chalk.cyan( + pluginNameCapitalized, + )}: ${e.message}`, + ), + ); + } + }); }; export const removePluginFromCodeOwners = async ( codeOwnersFile: string, pluginName: string, ) => { - console.log( - ` Removing teams and owners from ${chalk.cyan( - codeOwnersFile.replace(codeOwnersFile.split('/.git', 1)[0], ''), - )}`, - ); // remove long path - try { - await removeStatementContainingID(codeOwnersFile, pluginName); - console.log( - chalk.green( - ` Successfully removed codeowners statement from /.git/CODEOWNERS ${MARKER_SUCCESS}`, - ), - ); - } catch (e) { - throw new Error( - chalk.red( - ` There was an error removing code owners statement for plugin ${chalk.cyan( - pluginName, - )} ${MARKER_FAILURE} ${e.message}`, - ), - ); - } + await Task.forItem('removing', 'codeowners references', async () => { + try { + await removeAllStatementsContainingID(codeOwnersFile, pluginName); + } catch (e) { + throw new Error( + chalk.red( + ` There was an error removing code owners statement for plugin ${chalk.cyan( + pluginName, + )}: ${e.message}`, + ), + ); + } + }); }; -export const removePluginDependencyFromApp = async ( - packageFile: string, +export const removeReferencesFromAppPackage = async ( + appPackageFile: string, pluginName: string, ) => { const pluginPackage = `${BACKSTAGE}/plugin-${pluginName}`; - console.log( - ` Removing plugin from app dependencies ${chalk.cyan( - packageFile.replace(`${packageFile}/packages`, ''), - )}:`, - ); + await Task.forItem('removing', 'plugin app dependency', async () => { + try { + const appPackageFileContent = await fse.readFile(appPackageFile, 'utf-8'); + const appPackageFileContentJSON = JSON.parse(appPackageFileContent); + const dependencies = appPackageFileContentJSON.dependencies; - try { - const packageFileContent = await fse.readFile(packageFile, 'utf-8'); - const packageFileContentJSON = JSON.parse(packageFileContent); - const dependencies = packageFileContentJSON.dependencies; + if (!dependencies[pluginPackage]) { + throw new Error( + chalk.red( + ` Plugin ${chalk.cyan( + pluginPackage, + )} does not exist in ${chalk.cyan(appPackageFile)}`, + ), + ); + } - if (!dependencies[pluginPackage]) { + delete dependencies[pluginPackage]; + await fse.writeFile( + appPackageFile, + `${JSON.stringify(appPackageFileContentJSON, null, 2)}\n`, + 'utf-8', + ); + } catch (e) { throw new Error( chalk.red( - ` Plugin ${chalk.cyan( - pluginPackage, - )} does not exist in ${chalk.yellow(packageFile)}`, + ` Failed to remove plugin as dependency in app: ${chalk.cyan( + appPackageFile, + )}: ${e.message}`, ), ); } - - delete dependencies[pluginPackage]; - await fse.writeFile( - packageFile, - `${JSON.stringify(packageFileContentJSON, null, 2)}\n`, - 'utf-8', - ); - - console.log( - chalk.green( - ` Successfully removed plugin from app dependencies. ${MARKER_SUCCESS}`, - ), - ); - } catch (e) { - throw new Error( - `${chalk.red( - ` Failed to remove plugin as dependency in app: ${chalk.cyan( - packageFile, - )}:`, - )} ${e.message}`, - ); - } + }); }; -const removePlugin = async () => { +export default async () => { const questions: Question[] = [ { type: 'input', @@ -249,45 +204,54 @@ const removePlugin = async () => { ]; const answers: Answers = await inquirer.prompt(questions); - - const rootDir = realpathSync(process.cwd()); - const codeOwnersFile = path.join(rootDir, '.github', 'CODEOWNERS'); const pluginName: string = answers.pluginName; - const packageFile = path.join(rootDir, 'packages', 'app', 'package.json'); - const pluginsFile = path.join( - rootDir, - 'packages', - 'app', - 'src', - 'plugins.ts', - ); - const pluginDirectory = path.join(rootDir, 'plugins', pluginName); - const pluginScopedDirectory = path.join( - rootDir, - `node_modules/${BACKSTAGE}/plugin-${pluginName}`, + const appPackage = paths.resolveTargetRoot('packages/app'); + const pluginDir = paths.resolveTargetRoot('plugins', answers.pluginName); + const codeOwnersFile = await getCodeownersFilePath(paths.targetRoot); + const appPackageFile = path.join(appPackage, 'package.json'); + const appPluginsFile = path.join(appPackage, 'src', 'plugins.ts'); + const pluginScopedDirectory = paths.resolveTargetRoot( + 'node_modules', + BACKSTAGE, + `plugin-${pluginName}`, ); + + Task.log(); + Task.log('Removing the plugin...'); + console.log(pluginScopedDirectory); try { - await checkExists(rootDir, pluginName); - await removeExportStatementFromPlugins(pluginsFile, pluginName); - await removePluginDependencyFromApp(packageFile, pluginName); - await removePluginDirectory(pluginDirectory, pluginName); + Task.section('Checking the plugin exists.'); + await checkExists(paths.targetRoot, pluginName); + + Task.section('Removing plugin files.'); + await removePluginDirectory(pluginDir); + + Task.section('Removing symbolic link from @backstage.'); await removeSymLink(pluginScopedDirectory); - await removePluginFromCodeOwners(codeOwnersFile, pluginName); - console.log( - chalk.green( - `Successfully removed plugin ${chalk.cyan(pluginName)} from app.`, - ), - ); - } catch (e) { - // If error, restore files - console.log(e); - throw new Error( - chalk.red( - `Failed to remove plugin: ${chalk.cyan(pluginName)}: ${e.message}`, - ), + + if (await fse.pathExists(appPackage)) { + Task.section('Removing references from plugins.ts.'); + await removeReferencesFromPluginsFile(appPluginsFile, pluginName); + + Task.section('Removing plugin dependency from app.'); + await removeReferencesFromAppPackage(appPackageFile, pluginName); + } + + if (codeOwnersFile) { + Task.section('Removing codeowners reference.'); + await removePluginFromCodeOwners(codeOwnersFile, pluginName); + } + + Task.log(); + Task.log( + `🥇 Successfully removed ${chalk.cyan( + `@backstage/plugin-${answers.id}`, + )}`, ); + Task.log(); + } catch (error) { + Task.error(error.message); + Task.log('It seems that something went wrong when removing the plugin 🤔'); } }; - -export default removePlugin; From 169ecbe2f0cb55828bfd709aa5a430b2fd872990 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Wed, 8 Apr 2020 23:31:13 -0500 Subject: [PATCH 09/80] Added unit tests for remove-plugin --- .../remove-plugin/removePlugin.test.ts | 179 ++++++++++++++---- 1 file changed, 146 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index c801aaaa02..5ed8e82d13 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -16,48 +16,161 @@ import fse from 'fs-extra'; import path from 'path'; -import { removePluginDependencyFromApp } from './removePlugin'; +import { paths } from '../../helpers/paths'; +import { addExportStatement, capitalize } from '../create-plugin/createPlugin'; +import { addCodeownersEntry } from '../create-plugin/lib/codeowners'; +import { + removeReferencesFromAppPackage, + removeReferencesFromPluginsFile, + removePluginDirectory, + removeSymLink, + removePluginFromCodeOwners, +} from './removePlugin'; -const rootDir = fse.realpathSync(process.cwd().replace('/cli', '')); const BACKSTAGE = `@backstage`; +const testPluginName = 'yarn-test-package'; +const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; -// test remove export statement describe('removePlugin', () => { describe('Remove Plugin Dependencies', () => { - // Set up test - // Copy contents of package file for test - const packageFile = path.join(rootDir, 'app', 'package.json'); - const testFilePath = path.join(rootDir, 'app', 'test.json'); - const testPluginName = 'yarn-test-package'; - const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; - - const packageFileContents = JSON.parse( - fse.readFileSync(packageFile, 'utf8'), - ); - packageFileContents.dependencies[testPluginPackage] = '0.1.0'; - - it('should remove plugin dependency from /packages/app/package.json', async () => { - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContents, null, 2)}\n`, - 'utf8', - ); - console.log(JSON.parse(fse.readFileSync(testFilePath, 'utf8'))); + const appPath = paths.resolveTargetRoot('packages', 'app'); + const githubDir = paths.resolveTargetRoot('.github'); + it('removes plugin references from /packages/app/package.json', async () => { + // Set up test + const packageFilePath = path.join(appPath, 'package.json'); + const testFilePath = path.join(appPath, 'test.json'); + createTestPackageFile(testFilePath, packageFilePath); try { - await removePluginDependencyFromApp(testFilePath, testPluginName); - expect( - JSON.parse(fse.readFileSync(testFilePath, 'utf8')).hasOwnProperty( - testPluginPackage, - ), - ).toBe(false); + await removeReferencesFromAppPackage(testFilePath, testPluginName); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const packageFileContent = removeEmptyLines( + fse.readFileSync(packageFilePath, 'utf8'), + ); + expect(testFileContent === packageFileContent).toBe(true); } finally { fse.removeSync(testFilePath); } }); + it('removes plugin exports from /packages/app/src/packacge.json', async () => { + const testFilePath = path.join(appPath, 'src', 'test.ts'); + const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); + createTestPluginFile(testFilePath, pluginsFilePaths); + try { + await removeReferencesFromPluginsFile(testFilePath, testPluginName); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const pluginsFileContent = removeEmptyLines( + fse.readFileSync(pluginsFilePaths, 'utf8'), + ); + expect(testFileContent === pluginsFileContent).toBe(true); + } finally { + fse.removeSync(testFilePath); + } + }); + it('removes codeOwners references', async () => { + const testFilePath = path.join(githubDir, 'test'); + const codeownersPath = path.join(githubDir, 'CODEOWNERS'); + try { + fse.copySync(codeownersPath, testFilePath); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const codeOwnersFileContent = removeEmptyLines( + fse.readFileSync(codeownersPath, 'utf8'), + ); + await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [ + '@thisIsAtestTeam', + 'test@gmail.com', + ]); + await removePluginFromCodeOwners(testFilePath, testPluginName); + expect(testFileContent === codeOwnersFileContent).toBeTruthy(); + } finally { + if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath); + } + }); + }); + describe('Remove files', () => { + const testDirPath = path.join( + paths.resolveTargetRoot(), + 'plugins', + testPluginName, + ); + describe('Removes Plugin Directory', () => { + it('removes plugin directory from /plugins', async () => { + try { + mkTestDir(testDirPath); + expect(fse.existsSync(testDirPath)).toBeTruthy(); + await removePluginDirectory(testDirPath); + expect(fse.existsSync(testDirPath)).toBeFalsy(); + } finally { + if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); + } + }); + }); + describe('Removes System Link', () => { + it('removes system link from @backstage', async () => { + const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage'); + const testSymLinkPath = path.join( + scopedDir, + `plugin-${testPluginName}`, + ); + try { + mkTestDir(testDirPath); + fse.ensureSymlinkSync(testSymLinkPath, testDirPath); + + await removeSymLink(testSymLinkPath); + expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); + } finally { + if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); + if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath); + } + }); + }); }); }); -// Still to implement -// test remove plugin dependency from app -// remove plugin from directory -// remove symlink from lerna scope + +const removeEmptyLines = (file: string): string => + file + .split('\n') + .filter(Boolean) + .join('\n'); + +const createTestPackageFile = async ( + testFilePath: string, + packageFile: string, +) => { + // Copy contents of package file for test + const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + + packageFileContent.dependencies[testPluginPackage] = '0.1.0'; + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContent, null, 2)}\n`, + 'utf8', + ); + return; +}; +const createTestPluginFile = async ( + testFilePath: string, + pluginsFilePath: string, +) => { + // Copy contents of package file for test + fse.copyFileSync(pluginsFilePath, testFilePath); + const pluginNameCapitalized = testPluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + const exportStatement = `export {${pluginNameCapitalized}}`; + addExportStatement(testFilePath, importStatement, exportStatement); +}; + +function mkTestDir(testDirPath: string) { + fse.mkdirSync(testDirPath); + for (let i = 0; i < 50; i++) + fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); +} From 08a0f6fe8a1ce1c1f8b80d9079824ce0563caf96 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Wed, 8 Apr 2020 23:32:41 -0500 Subject: [PATCH 10/80] Added unit tests for remove-plugin --- .../commands/create-plugin/createPlugin.ts | 4 +- .../remove-plugin/removePlugin.test.ts | 179 ++++++++++++++---- 2 files changed, 148 insertions(+), 35 deletions(-) diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 7efb29d270..40c99b1571 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -65,10 +65,10 @@ const sortObjectByKeys = (obj: { [name in string]: string }) => { }, {} as { [name in string]: string }); }; -const capitalize = (str: string): string => +export const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); -const addExportStatement = async ( +export const addExportStatement = async ( file: string, importStatement: string, exportStatement: string, diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index c801aaaa02..5ed8e82d13 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -16,48 +16,161 @@ import fse from 'fs-extra'; import path from 'path'; -import { removePluginDependencyFromApp } from './removePlugin'; +import { paths } from '../../helpers/paths'; +import { addExportStatement, capitalize } from '../create-plugin/createPlugin'; +import { addCodeownersEntry } from '../create-plugin/lib/codeowners'; +import { + removeReferencesFromAppPackage, + removeReferencesFromPluginsFile, + removePluginDirectory, + removeSymLink, + removePluginFromCodeOwners, +} from './removePlugin'; -const rootDir = fse.realpathSync(process.cwd().replace('/cli', '')); const BACKSTAGE = `@backstage`; +const testPluginName = 'yarn-test-package'; +const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; -// test remove export statement describe('removePlugin', () => { describe('Remove Plugin Dependencies', () => { - // Set up test - // Copy contents of package file for test - const packageFile = path.join(rootDir, 'app', 'package.json'); - const testFilePath = path.join(rootDir, 'app', 'test.json'); - const testPluginName = 'yarn-test-package'; - const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; - - const packageFileContents = JSON.parse( - fse.readFileSync(packageFile, 'utf8'), - ); - packageFileContents.dependencies[testPluginPackage] = '0.1.0'; - - it('should remove plugin dependency from /packages/app/package.json', async () => { - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContents, null, 2)}\n`, - 'utf8', - ); - console.log(JSON.parse(fse.readFileSync(testFilePath, 'utf8'))); + const appPath = paths.resolveTargetRoot('packages', 'app'); + const githubDir = paths.resolveTargetRoot('.github'); + it('removes plugin references from /packages/app/package.json', async () => { + // Set up test + const packageFilePath = path.join(appPath, 'package.json'); + const testFilePath = path.join(appPath, 'test.json'); + createTestPackageFile(testFilePath, packageFilePath); try { - await removePluginDependencyFromApp(testFilePath, testPluginName); - expect( - JSON.parse(fse.readFileSync(testFilePath, 'utf8')).hasOwnProperty( - testPluginPackage, - ), - ).toBe(false); + await removeReferencesFromAppPackage(testFilePath, testPluginName); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const packageFileContent = removeEmptyLines( + fse.readFileSync(packageFilePath, 'utf8'), + ); + expect(testFileContent === packageFileContent).toBe(true); } finally { fse.removeSync(testFilePath); } }); + it('removes plugin exports from /packages/app/src/packacge.json', async () => { + const testFilePath = path.join(appPath, 'src', 'test.ts'); + const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); + createTestPluginFile(testFilePath, pluginsFilePaths); + try { + await removeReferencesFromPluginsFile(testFilePath, testPluginName); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const pluginsFileContent = removeEmptyLines( + fse.readFileSync(pluginsFilePaths, 'utf8'), + ); + expect(testFileContent === pluginsFileContent).toBe(true); + } finally { + fse.removeSync(testFilePath); + } + }); + it('removes codeOwners references', async () => { + const testFilePath = path.join(githubDir, 'test'); + const codeownersPath = path.join(githubDir, 'CODEOWNERS'); + try { + fse.copySync(codeownersPath, testFilePath); + const testFileContent = removeEmptyLines( + fse.readFileSync(testFilePath, 'utf8'), + ); + const codeOwnersFileContent = removeEmptyLines( + fse.readFileSync(codeownersPath, 'utf8'), + ); + await addCodeownersEntry(testFilePath!, `/plugins/${testPluginName}`, [ + '@thisIsAtestTeam', + 'test@gmail.com', + ]); + await removePluginFromCodeOwners(testFilePath, testPluginName); + expect(testFileContent === codeOwnersFileContent).toBeTruthy(); + } finally { + if (fse.existsSync(testFilePath)) fse.removeSync(testFilePath); + } + }); + }); + describe('Remove files', () => { + const testDirPath = path.join( + paths.resolveTargetRoot(), + 'plugins', + testPluginName, + ); + describe('Removes Plugin Directory', () => { + it('removes plugin directory from /plugins', async () => { + try { + mkTestDir(testDirPath); + expect(fse.existsSync(testDirPath)).toBeTruthy(); + await removePluginDirectory(testDirPath); + expect(fse.existsSync(testDirPath)).toBeFalsy(); + } finally { + if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); + } + }); + }); + describe('Removes System Link', () => { + it('removes system link from @backstage', async () => { + const scopedDir = paths.resolveTargetRoot('node_modules', '@backstage'); + const testSymLinkPath = path.join( + scopedDir, + `plugin-${testPluginName}`, + ); + try { + mkTestDir(testDirPath); + fse.ensureSymlinkSync(testSymLinkPath, testDirPath); + + await removeSymLink(testSymLinkPath); + expect(fse.existsSync(testSymLinkPath)).toBeFalsy(); + } finally { + if (fse.existsSync(testDirPath)) fse.removeSync(testDirPath); + if (fse.existsSync(testSymLinkPath)) fse.removeSync(testSymLinkPath); + } + }); + }); }); }); -// Still to implement -// test remove plugin dependency from app -// remove plugin from directory -// remove symlink from lerna scope + +const removeEmptyLines = (file: string): string => + file + .split('\n') + .filter(Boolean) + .join('\n'); + +const createTestPackageFile = async ( + testFilePath: string, + packageFile: string, +) => { + // Copy contents of package file for test + const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + + packageFileContent.dependencies[testPluginPackage] = '0.1.0'; + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContent, null, 2)}\n`, + 'utf8', + ); + return; +}; +const createTestPluginFile = async ( + testFilePath: string, + pluginsFilePath: string, +) => { + // Copy contents of package file for test + fse.copyFileSync(pluginsFilePath, testFilePath); + const pluginNameCapitalized = testPluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + const exportStatement = `export {${pluginNameCapitalized}}`; + addExportStatement(testFilePath, importStatement, exportStatement); +}; + +function mkTestDir(testDirPath: string) { + fse.mkdirSync(testDirPath); + for (let i = 0; i < 50; i++) + fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); +} From 6899f96db5df8b0c186aafa1f0fa62712c2de49e Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Wed, 8 Apr 2020 23:41:16 -0500 Subject: [PATCH 11/80] Ficed some lint issues --- .../remove-plugin/removePlugin.test.ts | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 5ed8e82d13..aac3caece9 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -31,6 +31,49 @@ const BACKSTAGE = `@backstage`; const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; +const removeEmptyLines = (file: string): string => + file + .split('\n') + .filter(Boolean) + .join('\n'); + +const createTestPackageFile = async ( + testFilePath: string, + packageFile: string, +) => { + // Copy contents of package file for test + const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + + packageFileContent.dependencies[testPluginPackage] = '0.1.0'; + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContent, null, 2)}\n`, + 'utf8', + ); + return; +}; +const createTestPluginFile = async ( + testFilePath: string, + pluginsFilePath: string, +) => { + // Copy contents of package file for test + fse.copyFileSync(pluginsFilePath, testFilePath); + const pluginNameCapitalized = testPluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + const exportStatement = `export {${pluginNameCapitalized}}`; + addExportStatement(testFilePath, importStatement, exportStatement); +}; + +function mkTestDir(testDirPath: string) { + fse.mkdirSync(testDirPath); + for (let i = 0; i < 50; i++) + fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); +} + describe('removePlugin', () => { describe('Remove Plugin Dependencies', () => { const appPath = paths.resolveTargetRoot('packages', 'app'); @@ -131,46 +174,3 @@ describe('removePlugin', () => { }); }); }); - -const removeEmptyLines = (file: string): string => - file - .split('\n') - .filter(Boolean) - .join('\n'); - -const createTestPackageFile = async ( - testFilePath: string, - packageFile: string, -) => { - // Copy contents of package file for test - const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); - - packageFileContent.dependencies[testPluginPackage] = '0.1.0'; - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContent, null, 2)}\n`, - 'utf8', - ); - return; -}; -const createTestPluginFile = async ( - testFilePath: string, - pluginsFilePath: string, -) => { - // Copy contents of package file for test - fse.copyFileSync(pluginsFilePath, testFilePath); - const pluginNameCapitalized = testPluginName - .split('-') - .map(name => capitalize(name)) - .join(''); - const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; - const exportStatement = `export {${pluginNameCapitalized}}`; - addExportStatement(testFilePath, importStatement, exportStatement); -}; - -function mkTestDir(testDirPath: string) { - fse.mkdirSync(testDirPath); - for (let i = 0; i < 50; i++) - fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); -} From 429b7f08cb604aad22bad8296874ec6d6c8f33d1 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Wed, 8 Apr 2020 23:43:31 -0500 Subject: [PATCH 12/80] Fixed some lint issues --- .../remove-plugin/removePlugin.test.ts | 86 +++++++++---------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index 5ed8e82d13..aac3caece9 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -31,6 +31,49 @@ const BACKSTAGE = `@backstage`; const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; +const removeEmptyLines = (file: string): string => + file + .split('\n') + .filter(Boolean) + .join('\n'); + +const createTestPackageFile = async ( + testFilePath: string, + packageFile: string, +) => { + // Copy contents of package file for test + const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); + + packageFileContent.dependencies[testPluginPackage] = '0.1.0'; + fse.createFileSync(testFilePath); + fse.writeFileSync( + testFilePath, + `${JSON.stringify(packageFileContent, null, 2)}\n`, + 'utf8', + ); + return; +}; +const createTestPluginFile = async ( + testFilePath: string, + pluginsFilePath: string, +) => { + // Copy contents of package file for test + fse.copyFileSync(pluginsFilePath, testFilePath); + const pluginNameCapitalized = testPluginName + .split('-') + .map(name => capitalize(name)) + .join(''); + const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + const exportStatement = `export {${pluginNameCapitalized}}`; + addExportStatement(testFilePath, importStatement, exportStatement); +}; + +function mkTestDir(testDirPath: string) { + fse.mkdirSync(testDirPath); + for (let i = 0; i < 50; i++) + fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); +} + describe('removePlugin', () => { describe('Remove Plugin Dependencies', () => { const appPath = paths.resolveTargetRoot('packages', 'app'); @@ -131,46 +174,3 @@ describe('removePlugin', () => { }); }); }); - -const removeEmptyLines = (file: string): string => - file - .split('\n') - .filter(Boolean) - .join('\n'); - -const createTestPackageFile = async ( - testFilePath: string, - packageFile: string, -) => { - // Copy contents of package file for test - const packageFileContent = JSON.parse(fse.readFileSync(packageFile, 'utf8')); - - packageFileContent.dependencies[testPluginPackage] = '0.1.0'; - fse.createFileSync(testFilePath); - fse.writeFileSync( - testFilePath, - `${JSON.stringify(packageFileContent, null, 2)}\n`, - 'utf8', - ); - return; -}; -const createTestPluginFile = async ( - testFilePath: string, - pluginsFilePath: string, -) => { - // Copy contents of package file for test - fse.copyFileSync(pluginsFilePath, testFilePath); - const pluginNameCapitalized = testPluginName - .split('-') - .map(name => capitalize(name)) - .join(''); - const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; - const exportStatement = `export {${pluginNameCapitalized}}`; - addExportStatement(testFilePath, importStatement, exportStatement); -}; - -function mkTestDir(testDirPath: string) { - fse.mkdirSync(testDirPath); - for (let i = 0; i < 50; i++) - fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); -} From dc6dcbefaca7fd202038ea1d1720f6b38f808d18 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Wed, 8 Apr 2020 14:30:28 -0500 Subject: [PATCH 13/80] Add clean script --- package.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/package.json b/package.json index 0387a4e7ce..2348780603 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,12 @@ "start": "yarn workspace example-app start", "bundle": "yarn build && yarn workspace example-app bundle", "build": "lerna run build", + "clean": "yarn run clean:cli && yarn run clean:app && yarn run clean:core && yarn run clean:storybook && yarn clean:root", + "clean:root": "rimraf node_modules/", + "clean:cli":"rimraf packages/cli/dist/ packages/cli/node_modules/", + "clean:app":"rimraf packages/app/node_modules/", + "clean:core": "rimraf packages/core/dist packages/core/node_modules", + "clean:storybook":"rimraf packages/storybook/node_modules", "test": "yarn build && lerna run test --since origin/master -- --coverage", "test:all": "yarn build && lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", @@ -32,6 +38,7 @@ "lerna": "^3.20.2", "lint-staged": "^10.1.0", "prettier": "^1.19.1", + "rimraf": "^3.0.2", "typescript": "^3.7.5", "zombie": "^6.1.4" }, From 5a2c94640d7d2f41a6fd2f5eed8fbe0f12d4b8ff Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Wed, 8 Apr 2020 14:51:12 -0500 Subject: [PATCH 14/80] Add yarn lock file --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index acf9de9c97..469ca10af5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17292,7 +17292,7 @@ rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3, rimra dependencies: glob "^7.1.3" -rimraf@^3.0.0: +rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== From 9770623d6bdeef6e4bf8509e2006d8a21984032e Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Thu, 9 Apr 2020 15:22:27 -0500 Subject: [PATCH 15/80] Create clean command --- packages/cli/src/commands/clean/clean.ts | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 packages/cli/src/commands/clean/clean.ts diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts new file mode 100644 index 0000000000..f1759aeb8b --- /dev/null +++ b/packages/cli/src/commands/clean/clean.ts @@ -0,0 +1,28 @@ +/* + * 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 { getDefaultCacheOptions } from 'commands/build-cache/options'; + +const cacheOptions = getDefaultCacheOptions(); + +export default async function clean( + outputPath: string = cacheOptions.output, + cachePath: string = cacheOptions.cacheDir, +) { + fs.remove(outputPath); + fs.remove(cachePath); +} From ff5d455c23e99a890ead1563e2c05846cbd0c0a3 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Thu, 9 Apr 2020 15:23:01 -0500 Subject: [PATCH 16/80] Add clean command to index --- packages/cli/src/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ca39c1ac3b..d21ff90c34 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -90,6 +90,11 @@ const main = (argv: string[]) => { ) .action(actionHandler(() => require('commands/build-cache'))); + program + .command('clean') + .description('Delete cache directories') + .action(actionHandler(() => require('commands/clean/clean'))); + program.on('command:*', () => { console.log(); console.log( From 81c18f22f76a73832124e937947816e8edec97db Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Thu, 9 Apr 2020 15:23:33 -0500 Subject: [PATCH 17/80] Add clean script to package file --- packages/cli/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/package.json b/packages/cli/package.json index 1764d19bf2..40c8b9b266 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,7 @@ "build": "backstage-cli build-cache -- tsc", "lint": "backstage-cli lint", "test": "backstage-cli test", + "clean": "backstage-cli clean", "start": "nodemon ." }, "devDependencies": { From 4c6f1ab100e767a218bbb014c8a485441c480b24 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Thu, 9 Apr 2020 15:28:20 -0500 Subject: [PATCH 18/80] Delete default formal arguments --- packages/cli/src/commands/clean/clean.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts index f1759aeb8b..dcbb8654cd 100644 --- a/packages/cli/src/commands/clean/clean.ts +++ b/packages/cli/src/commands/clean/clean.ts @@ -19,10 +19,7 @@ import { getDefaultCacheOptions } from 'commands/build-cache/options'; const cacheOptions = getDefaultCacheOptions(); -export default async function clean( - outputPath: string = cacheOptions.output, - cachePath: string = cacheOptions.cacheDir, -) { - fs.remove(outputPath); - fs.remove(cachePath); +export default async function clean() { + await fs.remove(cacheOptions.output); + await fs.remove(cacheOptions.cacheDir); } From 2c4328f5e15612c40ceaa7bb75de42235bb6de1a Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:09:44 -0500 Subject: [PATCH 19/80] Add package path --- packages/cli/src/commands/clean/clean.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/clean/clean.ts b/packages/cli/src/commands/clean/clean.ts index dcbb8654cd..f18250d210 100644 --- a/packages/cli/src/commands/clean/clean.ts +++ b/packages/cli/src/commands/clean/clean.ts @@ -15,11 +15,19 @@ */ import fs from 'fs-extra'; +import { resolve as resolvePath, relative as relativePath } from 'path'; import { getDefaultCacheOptions } from 'commands/build-cache/options'; - -const cacheOptions = getDefaultCacheOptions(); +import { paths } from 'helpers/paths'; export default async function clean() { + const cacheOptions = getDefaultCacheOptions(); + const packagePath = getPackagePath(cacheOptions.cacheDir); await fs.remove(cacheOptions.output); - await fs.remove(cacheOptions.cacheDir); + await fs.remove(packagePath); +} + +function getPackagePath(cacheDir: string) { + const relativePackagePath = relativePath(paths.targetRoot, paths.targetDir); + const packagePath = resolvePath(cacheDir, relativePackagePath); + return packagePath; } From f5b8433aa2d789e62d7ecb2a0670a1beb17bbdde Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:19:47 -0500 Subject: [PATCH 20/80] Add clean script to template app --- packages/cli/templates/default-app/package.json.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs index def7b3178b..0ee61c6302 100644 --- a/packages/cli/templates/default-app/package.json.hbs +++ b/packages/cli/templates/default-app/package.json.hbs @@ -6,6 +6,7 @@ "build": "lerna run build", "test": "cross-env CI=true lerna run test -- --coverage", "create-plugin": "backstage-cli create-plugin", + "clean": "backstage-cli clean", "lint": "lerna run lint" }, "workspaces": { From 0bd526a4a35c11fb1d0879498a7172c28d944234 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:20:47 -0500 Subject: [PATCH 21/80] Add clean script to template plugin --- packages/cli/templates/default-plugin/package.json.hbs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 49d8d20d71..32479b879b 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -8,7 +8,8 @@ "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", - "test": "backstage-cli test" + "test": "backstage-cli test", + "clean": "backstage-cli clean" }, "devDependencies": { "@backstage/cli": "^{{version}}", From f1c9e804356814972f6372966bb19ed25dd6cb6f Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:22:53 -0500 Subject: [PATCH 22/80] Add clean script packages/app --- packages/app/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/package.json b/packages/app/package.json index 232a00416d..d9e91a122a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -30,6 +30,7 @@ "scripts": { "start": "backstage-cli app:serve", "bundle": "backstage-cli app:build", + "clean": "backstage-cli clean", "test": "backstage-cli test", "test:e2e": "start-server-and-test start http://localhost:3000 cy:dev", "test:e2e:ci": "start-server-and-test start http://localhost:3000 cy:run", From 4d0c5b084d4e08a82ed38cb32c280daf980d79fa Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:23:34 -0500 Subject: [PATCH 23/80] Add clean script packages/core --- packages/core/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 3dc8150f83..014fc0ce33 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,7 +21,8 @@ "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", - "test": "backstage-cli test" + "test": "backstage-cli test", + "clean": "backstage-cli clean" }, "dependencies": { "@material-ui/core": "^4.9.1", From 2467e4088358dea647d93edcec9560f1f64b0228 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:24:16 -0500 Subject: [PATCH 24/80] Add clean script packages/test-utils --- packages/test-utils/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 55b7436aae..68d7088918 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -21,7 +21,8 @@ "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", - "test": "backstage-cli test" + "test": "backstage-cli test", + "clean": "backstage-cli clean" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.3", From 8015b55a0fde1c64881d500fffb7141498b0e5f8 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:24:40 -0500 Subject: [PATCH 25/80] Add clean script packages/theme --- packages/theme/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/theme/package.json b/packages/theme/package.json index b08014e9d6..09c9ce2089 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -20,7 +20,8 @@ "types": "dist/index.d.ts", "scripts": { "build": "backstage-cli plugin:build", - "lint": "backstage-cli lint" + "lint": "backstage-cli lint", + "clean": "backstage-cli clean" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.3", From 0b7b6c4bedc864a2080ce0959b230e89bba64bc9 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:28:57 -0500 Subject: [PATCH 26/80] Add clean script to root package --- package.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/package.json b/package.json index 2348780603..7ec98d692f 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,7 @@ "start": "yarn workspace example-app start", "bundle": "yarn build && yarn workspace example-app bundle", "build": "lerna run build", - "clean": "yarn run clean:cli && yarn run clean:app && yarn run clean:core && yarn run clean:storybook && yarn clean:root", - "clean:root": "rimraf node_modules/", - "clean:cli":"rimraf packages/cli/dist/ packages/cli/node_modules/", - "clean:app":"rimraf packages/app/node_modules/", - "clean:core": "rimraf packages/core/dist packages/core/node_modules", - "clean:storybook":"rimraf packages/storybook/node_modules", + "clean": "lerna run clean", "test": "yarn build && lerna run test --since origin/master -- --coverage", "test:all": "yarn build && lerna run test -- --coverage", "lint": "lerna run lint --since origin/master --", From c171718840fff36e42b1f5560a4a89e1c442868a Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:32:03 -0500 Subject: [PATCH 27/80] Delete rimraf library --- package.json | 1 - yarn.lock | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 7ec98d692f..10a5daa4c1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "lerna": "^3.20.2", "lint-staged": "^10.1.0", "prettier": "^1.19.1", - "rimraf": "^3.0.2", "typescript": "^3.7.5", "zombie": "^6.1.4" }, diff --git a/yarn.lock b/yarn.lock index 469ca10af5..acf9de9c97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17292,7 +17292,7 @@ rimraf@^2.2.8, rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3, rimra dependencies: glob "^7.1.3" -rimraf@^3.0.0, rimraf@^3.0.2: +rimraf@^3.0.0: version "3.0.2" resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== From b03d937e7dc771661f3b08e8fc647c7ca932b8be Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:53:53 -0500 Subject: [PATCH 28/80] Add clean script --- plugins/home-page/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json index 7f78f1bf63..d87851babe 100644 --- a/plugins/home-page/package.json +++ b/plugins/home-page/package.json @@ -8,7 +8,8 @@ "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", - "test": "backstage-cli test" + "test": "backstage-cli test", + "clean": "backstage-cli clean" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.3", From 97604a3832c1cd14938721a966843c043a40a699 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:54:11 -0500 Subject: [PATCH 29/80] Add clean script to plugins/lighthouse --- plugins/lighthouse/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 721f20f802..e53ff85546 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -9,7 +9,8 @@ "build:watch": "backstage-cli plugin:build --watch", "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", - "test": "backstage-cli test" + "test": "backstage-cli test", + "clean": "backstage-cli clean" }, "dependencies": { "react-markdown": "^4.3.1", From c94a42c8120aa01ea35a77a73be39685ba6b94f1 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Sun, 12 Apr 2020 14:57:28 -0500 Subject: [PATCH 30/80] Add clean script to plugins/welcome --- plugins/welcome/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 3ee95e8898..beab06746e 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -8,7 +8,8 @@ "scripts": { "build": "backstage-cli plugin:build", "lint": "backstage-cli lint", - "test": "backstage-cli test" + "test": "backstage-cli test", + "clean": "backstage-cli clean" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.3", From 1820d3e67bf8146c9c15d06ce9680c1c79f9c350 Mon Sep 17 00:00:00 2001 From: Leo Mendez Date: Wed, 15 Apr 2020 10:15:22 -0500 Subject: [PATCH 31/80] Change backstage cli clean to lerna run clean command --- packages/cli/templates/default-app/package.json.hbs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs index 0ee61c6302..03371f887c 100644 --- a/packages/cli/templates/default-app/package.json.hbs +++ b/packages/cli/templates/default-app/package.json.hbs @@ -6,7 +6,7 @@ "build": "lerna run build", "test": "cross-env CI=true lerna run test -- --coverage", "create-plugin": "backstage-cli create-plugin", - "clean": "backstage-cli clean", + "clean": "lerna run clean", "lint": "lerna run lint" }, "workspaces": { From c4457a7614639b2eeaa429107d50b0bd49bd190b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Wed, 15 Apr 2020 20:27:06 +0200 Subject: [PATCH 32/80] v0.1.1-alpha.4 --- lerna.json | 2 +- packages/app/package.json | 14 +++++++------- packages/cli/package.json | 2 +- packages/core/package.json | 8 ++++---- packages/storybook/package.json | 2 +- packages/test-utils-core/package.json | 2 +- packages/test-utils/package.json | 8 ++++---- packages/theme/package.json | 4 ++-- plugins/home-page/package.json | 8 ++++---- plugins/lighthouse/package.json | 10 +++++----- plugins/welcome/package.json | 8 ++++---- 11 files changed, 34 insertions(+), 34 deletions(-) diff --git a/lerna.json b/lerna.json index 2cc439d190..1d77db92fb 100644 --- a/lerna.json +++ b/lerna.json @@ -5,5 +5,5 @@ ], "npmClient": "yarn", "useWorkspaces": true, - "version": "0.1.1-alpha.3" + "version": "0.1.1-alpha.4" } diff --git a/packages/app/package.json b/packages/app/package.json index 232a00416d..409abb6e46 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,14 +1,14 @@ { "name": "example-app", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "private": true, "dependencies": { - "@backstage/cli": "^0.1.1-alpha.3", - "@backstage/core": "^0.1.1-alpha.3", - "@backstage/theme": "^0.1.1-alpha.3", - "@backstage/plugin-home-page": "^0.1.1-alpha.3", - "@backstage/plugin-lighthouse": "^0.1.1-alpha.3", - "@backstage/plugin-welcome": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/core": "^0.1.1-alpha.4", + "@backstage/plugin-home-page": "^0.1.1-alpha.4", + "@backstage/plugin-lighthouse": "^0.1.1-alpha.4", + "@backstage/plugin-welcome": "^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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 1764d19bf2..1808b1239e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/cli", "description": "CLI for developing Backstage plugins and apps", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "private": false, "publishConfig": { "access": "public" diff --git a/packages/core/package.json b/packages/core/package.json index ad3afd84a3..94195942f1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/core", "description": "Core API used by Backstage plugins and apps", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "private": false, "publishConfig": { "access": "public" @@ -41,9 +41,9 @@ "recompose": "0.30.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.3", - "@backstage/test-utils-core": "^0.1.1-alpha.3", - "@backstage/theme": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/test-utils-core": "^0.1.1-alpha.4", + "@backstage/theme": "^0.1.1-alpha.4", "@storybook/addon-storysource": "^5.3.18", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index f884da5f07..8b54b2b8e1 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -1,6 +1,6 @@ { "name": "storybook", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "description": "Storybook build for core package", "private": true, "scripts": { diff --git a/packages/test-utils-core/package.json b/packages/test-utils-core/package.json index 79d36a3525..569622ed1c 100644 --- a/packages/test-utils-core/package.json +++ b/packages/test-utils-core/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils-core", "description": "Utilities to test Backstage core", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "private": false, "publishConfig": { "access": "public" diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index ab52ca94a1..5b328f2a58 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/test-utils", "description": "Utilities to test Backstage plugins and apps.", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "private": false, "publishConfig": { "access": "public" @@ -24,8 +24,8 @@ "test": "backstage-cli test" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.3", - "@backstage/theme": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/theme": "^0.1.1-alpha.4", "@material-ui/core": "^4.9.1", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", @@ -38,8 +38,8 @@ "react-router-dom": "^5.1.2" }, "peerDependencies": { - "@backstage/theme": "^0.1.1-alpha.3", "@backstage/test-utils-core": "^0.1.1-alpha.3", + "@backstage/theme": "^0.1.1-alpha.3", "@material-ui/core": "^4.9.1", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", diff --git a/packages/theme/package.json b/packages/theme/package.json index b08014e9d6..2f8a213478 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "name": "@backstage/theme", "description": "material-ui theme for use with Backstage.", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "private": false, "publishConfig": { "access": "public" @@ -23,7 +23,7 @@ "lint": "backstage-cli lint" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", "@material-ui/core": "^4.9.1" }, "peerDependencies": { diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json index 7f78f1bf63..a25e0cdc58 100644 --- a/plugins/home-page/package.json +++ b/plugins/home-page/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-home-page", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "license": "Apache-2.0", @@ -11,9 +11,9 @@ "test": "backstage-cli test" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.3", - "@backstage/core": "^0.1.1-alpha.3", - "@backstage/theme": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", + "@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", "@testing-library/jest-dom": "^4.2.4", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 721f20f802..a342c45b82 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-lighthouse", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "license": "Apache-2.0", @@ -16,10 +16,10 @@ "react-sparklines": "^1.7.0" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.3", - "@backstage/core": "^0.1.1-alpha.3", - "@backstage/test-utils": "^0.1.1-alpha.3", - "@backstage/theme": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/core": "^0.1.1-alpha.4", + "@backstage/test-utils": "^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", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index 3ee95e8898..e0186f1cce 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/plugin-welcome", - "version": "0.1.1-alpha.3", + "version": "0.1.1-alpha.4", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", "private": true, @@ -11,9 +11,9 @@ "test": "backstage-cli test" }, "devDependencies": { - "@backstage/cli": "^0.1.1-alpha.3", - "@backstage/core": "^0.1.1-alpha.3", - "@backstage/theme": "^0.1.1-alpha.3", + "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/core": "^0.1.1-alpha.4", + "@backstage/theme": "^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", From ceb716c3b4c7df5b9cfb11845c75d0ea1377e1a7 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Tue, 14 Apr 2020 08:55:07 +0200 Subject: [PATCH 33/80] Add create-app to e2e and move common functionality into seperate modules --- scripts/cli-e2e-test.js | 158 ++++++------------------------------ scripts/createTestApp.js | 41 ++++++++++ scripts/createTestPlugin.js | 44 ++++++++++ scripts/helpers.js | 137 +++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 135 deletions(-) create mode 100644 scripts/createTestApp.js create mode 100644 scripts/createTestPlugin.js create mode 100644 scripts/helpers.js diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 48058550d9..e852f73ed0 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -15,11 +15,18 @@ */ const { resolve: resolvePath } = require('path'); -const childProcess = require('child_process'); -const { spawn } = childProcess; const Browser = require('zombie'); -const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/; +const { + spawnPiped, + handleError, + waitForPageWithText, + waitForExit, + print, +} = require('./helpers'); + +const createTestApp = require('./createTestApp'); +const createTestPlugin = require('./createTestPlugin'); Browser.localhost('localhost', 3000); @@ -29,154 +36,35 @@ async function main() { const projectDir = resolvePath(__dirname, '..'); process.chdir(projectDir); - const start = spawnPiped(['yarn', 'start']); + await createTestApp(); + + const appDir = resolvePath(projectDir, 'test-app'); + process.chdir(appDir); + + print('Starting the app'); + const startApp = spawnPiped(['yarn', 'start']); try { const browser = new Browser(); + await createTestPlugin(); await waitForPageWithText(browser, '/', 'Welcome to Backstage'); - print('Backstage loaded correctly, creating plugin'); - - const createPlugin = spawnPiped(['yarn', 'create-plugin']); - - let stdout = ''; - createPlugin.stdout.on('data', data => { - stdout = stdout + data.toString('utf8'); - }); - - await waitFor(() => stdout.includes('Enter an ID for the plugin')); - createPlugin.stdin.write('test-plugin\n'); - - await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); - createPlugin.stdin.write('@someuser\n'); - - print('Waiting for plugin create script to be done'); - await waitForExit(createPlugin); - - print('Plugin create script is done, waiting for plugin page to load'); await waitForPageWithText( browser, '/test-plugin', 'Welcome to test-plugin!', ); - print('Test plugin loaded correctly, exiting'); + print('Both App and Plugin loaded correctly'); } finally { - start.kill(); + startApp.kill(); } - await waitForExit(start); + await waitForExit(startApp); + + print('All tests done'); process.exit(0); } -function waitFor(fn) { - return new Promise(resolve => { - const handle = setInterval(() => { - if (fn()) { - clearInterval(handle); - resolve(); - return; - } - }, 100); - }); -} - -function print(msg) { - return process.stdout.write(`${msg}\n`); -} - -async function waitForExit(child) { - if (child.exitCode !== null) { - throw new Error(`Child already exited with code ${child.exitCode}`); - } - await new Promise((resolve, reject) => - child.once('exit', code => { - if (code) { - reject(new Error(`Child exited with code ${code}`)); - } else { - resolve(); - } - }), - ); -} - -function spawnPiped(cmd, options) { - function pipeWithPrefix(stream, prefix = '') { - return data => { - const prefixedMsg = data - .toString('utf8') - .trimRight() - .replace(/^/gm, prefix); - stream.write(`${prefixedMsg}\n`, 'utf8'); - }; - } - - const child = spawn(cmd[0], cmd.slice(1), { - stdio: 'pipe', - shell: true, - ...options, - }); - child.on('error', handleError); - child.on('exit', code => { - if (code) { - print(`Child '${cmd.join(' ')}' exited with code ${code}`); - process.exit(code); - } - }); - child.stdout.on( - 'data', - pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `), - ); - child.stderr.on( - 'data', - pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `), - ); - - return child; -} - -async function waitForPageWithText( - browser, - path, - text, - { intervalMs = 1000, maxAttempts = 120 } = {}, -) { - let attempts = 0; - for (;;) { - try { - await new Promise(resolve => setTimeout(resolve, intervalMs)); - await browser.visit(path); - break; - } catch (error) { - if (error.message.match(EXPECTED_LOAD_ERRORS)) { - attempts++; - if (attempts > maxAttempts) { - throw new Error( - `Failed to load page '${path}', max number of attempts reached`, - ); - } - } else { - throw error; - } - } - } - - const escapedText = text.replace(/"/g, '\\"'); - browser.assert.evaluate( - `Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`, - true, - `expected to find text ${text}`, - ); -} - -function handleError(err) { - process.stdout.write(`${err.name}: ${err.stack || err.message}\n`); - if (typeof err.code === 'number') { - process.exit(err.code); - } else { - process.exit(1); - } -} - process.on('unhandledRejection', handleError); main(process.argv.slice(2)).catch(handleError); diff --git a/scripts/createTestApp.js b/scripts/createTestApp.js new file mode 100644 index 0000000000..89fa023436 --- /dev/null +++ b/scripts/createTestApp.js @@ -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. + */ + +const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); + +async function createTestApp() { + print('Creating a Backstage App'); + const createApp = spawnPiped(['yarn', 'create-app']); + + try { + let stdout = ''; + createApp.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); + + await waitFor(() => stdout.includes('Enter a name for the app')); + createApp.stdin.write('test-app\n'); + + print('Waiting for app create script to be done'); + await waitForExit(createApp); + + print('Test app created'); + } finally { + createApp.kill(); + } +} + +module.exports = createTestApp; diff --git a/scripts/createTestPlugin.js b/scripts/createTestPlugin.js new file mode 100644 index 0000000000..c204067ae3 --- /dev/null +++ b/scripts/createTestPlugin.js @@ -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. + */ + +const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); + +async function createTestPlugin() { + print('Creating a Backstage Plugin'); + const createPlugin = spawnPiped(['yarn', 'create-plugin']); + + try { + let stdout = ''; + createPlugin.stdout.on('data', data => { + stdout = stdout + data.toString('utf8'); + }); + + await waitFor(() => stdout.includes('Enter an ID for the plugin')); + createPlugin.stdin.write('test-plugin\n'); + + await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); + createPlugin.stdin.write('@someuser\n'); + + print('Waiting for plugin create script to be done'); + await waitForExit(createPlugin); + + print('Test plugin created'); + } finally { + createPlugin.kill(); + } +} + +module.exports = createTestPlugin; diff --git a/scripts/helpers.js b/scripts/helpers.js new file mode 100644 index 0000000000..7ad1bedfd5 --- /dev/null +++ b/scripts/helpers.js @@ -0,0 +1,137 @@ +/* + * 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. + */ + +const childProcess = require('child_process'); +const { spawn } = childProcess; + +const EXPECTED_LOAD_ERRORS = /ECONNREFUSED|ECONNRESET|did not get to load all resources/; + +function spawnPiped(cmd, options) { + function pipeWithPrefix(stream, prefix = '') { + return data => { + const prefixedMsg = data + .toString('utf8') + .trimRight() + .replace(/^/gm, prefix); + stream.write(`${prefixedMsg}\n`, 'utf8'); + }; + } + + const child = spawn(cmd[0], cmd.slice(1), { + stdio: 'pipe', + shell: true, + ...options, + }); + child.on('error', handleError); + child.on('exit', code => { + if (code) { + print(`Child '${cmd.join(' ')}' exited with code ${code}`); + process.exit(code); + } + }); + child.stdout.on( + 'data', + pipeWithPrefix(process.stdout, `[${cmd.join(' ')}].out: `), + ); + child.stderr.on( + 'data', + pipeWithPrefix(process.stderr, `[${cmd.join(' ')}].err: `), + ); + + return child; +} + +function handleError(err) { + process.stdout.write(`${err.name}: ${err.stack || err.message}\n`); + if (typeof err.code === 'number') { + process.exit(err.code); + } else { + process.exit(1); + } +} + +function waitFor(fn) { + return new Promise(resolve => { + const handle = setInterval(() => { + if (fn()) { + clearInterval(handle); + resolve(); + return; + } + }, 100); + }); +} + +async function waitForExit(child) { + if (child.exitCode !== null) { + throw new Error(`Child already exited with code ${child.exitCode}`); + } + await new Promise((resolve, reject) => + child.once('exit', code => { + if (code) { + reject(new Error(`Child exited with code ${code}`)); + } else { + print('Child finished'); + resolve(); + } + }), + ); +} + +async function waitForPageWithText( + browser, + path, + text, + { intervalMs = 1000, maxAttempts = 240 } = {}, +) { + let attempts = 0; + for (;;) { + try { + await new Promise(resolve => setTimeout(resolve, intervalMs)); + await browser.visit(path); + break; + } catch (error) { + if (error.message.match(EXPECTED_LOAD_ERRORS)) { + attempts++; + if (attempts > maxAttempts) { + throw new Error( + `Failed to load page '${path}', max number of attempts reached`, + ); + } + } else { + throw error; + } + } + } + + const escapedText = text.replace(/"/g, '\\"'); + browser.assert.evaluate( + `Array.from(document.querySelectorAll("*")).some(el => el.textContent === "${escapedText}")`, + true, + `expected to find text ${text}`, + ); +} + +function print(msg) { + return process.stdout.write(`${msg}\n`); +} + +module.exports.spawnPiped = spawnPiped; +module.exports.handleError = handleError; +module.exports.waitFor = waitFor; +module.exports.waitForExit = waitForExit; +module.exports.waitForPageWithText = waitForPageWithText; +module.exports.print = print; From ca2ccb314524e36cbd00b98a2b2fbd0e949354b6 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Tue, 14 Apr 2020 09:14:26 +0200 Subject: [PATCH 34/80] Modify cli workflow --- .github/workflows/cli.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index d571703294..44a18f10da 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -6,6 +6,7 @@ on: - '.github/workflows/cli.yml' - 'packages/cli/**' - 'packages/core/**' + - 'scripts/**' jobs: build: @@ -40,17 +41,18 @@ jobs: - name: yarn install run: yarn install --frozen-lockfile - run: yarn build - # This creates a new plugin and pollutes the workspace, so it should be run last. - - name: verify app serve and plugin creation on Windows + # This creates a new app and plugin which pollutes the workspace, so it should be run last. + - name: verify app and plugin creation on Windows if: runner.os == 'Windows' run: node scripts/cli-e2e-test.js - - name: verify app serve and plugin creation on Linux + - name: verify app and plugin creation on Linux if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 node scripts/cli-e2e-test.js - - name: yarn lint, test after plugin creation - working-directory: plugins/test-plugin + # This should lint and test both an app and a plugin + - name: yarn lint, test after creation + working-directory: test-app run: | yarn lint yarn test From ce53096b5c99f4e843c4d584666cbed61fa19da9 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Tue, 14 Apr 2020 09:20:46 +0200 Subject: [PATCH 35/80] Add create-app yarn command to package.json --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0387a4e7ce..64f5765312 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lint:all": "lerna run lint --", "docker-build": "yarn bundle && docker build . -t spotify/backstage", "create-plugin": "backstage-cli create-plugin", + "create-app": "backstage-cli create-app", "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi", "lerna": "lerna", "storybook": "yarn workspace storybook start" From fbc2048350c3a199e503ec3316b1fce8663821e3 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Tue, 14 Apr 2020 12:54:13 +0200 Subject: [PATCH 36/80] Add copyright notice to default-app template files --- .../default-app/packages/app/src/App.test.tsx | 16 ++++++++++++++++ .../default-app/packages/app/src/App.tsx | 16 ++++++++++++++++ .../default-app/packages/app/src/index.tsx | 16 ++++++++++++++++ .../default-app/packages/app/src/plugins.ts | 16 ++++++++++++++++ .../default-app/packages/app/src/setupTests.ts | 16 ++++++++++++++++ .../welcome/src/components/Timer/Timer.tsx | 16 ++++++++++++++++ .../welcome/src/components/Timer/index.ts | 16 ++++++++++++++++ .../components/WelcomePage/WelcomePage.test.tsx | 16 ++++++++++++++++ .../src/components/WelcomePage/WelcomePage.tsx | 16 ++++++++++++++++ .../welcome/src/components/WelcomePage/index.ts | 16 ++++++++++++++++ .../default-app/plugins/welcome/src/index.ts | 16 ++++++++++++++++ .../plugins/welcome/src/plugin.test.ts | 16 ++++++++++++++++ .../default-app/plugins/welcome/src/plugin.ts | 16 ++++++++++++++++ .../plugins/welcome/src/setupTests.ts | 16 ++++++++++++++++ 14 files changed, 224 insertions(+) diff --git a/packages/cli/templates/default-app/packages/app/src/App.test.tsx b/packages/cli/templates/default-app/packages/app/src/App.test.tsx index 0074416375..ace8f42f45 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.test.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.test.tsx @@ -1,3 +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. + */ + import React from 'react'; import { render } from '@testing-library/react'; import App from './App'; diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index ec8d8d435a..ea318c6721 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -1,3 +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. + */ + import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core'; import { createApp } from '@backstage/core'; import { BackstageTheme } from '@backstage/theme'; diff --git a/packages/cli/templates/default-app/packages/app/src/index.tsx b/packages/cli/templates/default-app/packages/app/src/index.tsx index b597a44232..2ea8d3f1dd 100644 --- a/packages/cli/templates/default-app/packages/app/src/index.tsx +++ b/packages/cli/templates/default-app/packages/app/src/index.tsx @@ -1,3 +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. + */ + import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; diff --git a/packages/cli/templates/default-app/packages/app/src/plugins.ts b/packages/cli/templates/default-app/packages/app/src/plugins.ts index 000bd79f3e..ba7b721672 100644 --- a/packages/cli/templates/default-app/packages/app/src/plugins.ts +++ b/packages/cli/templates/default-app/packages/app/src/plugins.ts @@ -1 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { plugin as WelcomePlugin } from 'plugin-welcome'; diff --git a/packages/cli/templates/default-app/packages/app/src/setupTests.ts b/packages/cli/templates/default-app/packages/app/src/setupTests.ts index 666127af39..8925258421 100644 --- a/packages/cli/templates/default-app/packages/app/src/setupTests.ts +++ b/packages/cli/templates/default-app/packages/app/src/setupTests.ts @@ -1 +1,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. + */ + import '@testing-library/jest-dom/extend-expect'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx index 24c79f91ee..770f98e762 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx @@ -1,3 +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. + */ + import React, { FC } from 'react'; import { HeaderLabel } from '@backstage/core'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts index f1fc55bfe9..a67293c20e 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts @@ -1 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { default } from './Timer'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index 6d9268fadd..0bf0b2135f 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -1,3 +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. + */ + import React from 'react'; import { render } from '@testing-library/react'; import WelcomePage from './WelcomePage'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index d9ef0524c6..feafaa0ade 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -1,3 +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. + */ + import React, { FC } from 'react'; import { Link as RouterLink } from 'react-router-dom'; import { diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts index b031301e7e..fcdde9d498 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts @@ -1 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { default } from './WelcomePage'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/index.ts b/packages/cli/templates/default-app/plugins/welcome/src/index.ts index 99edba26c3..3a0a0fe2d3 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/index.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/index.ts @@ -1 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export { plugin } from './plugin'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts b/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts index f5bf8e68c3..d60c73ec68 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts @@ -1,3 +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. + */ + import { plugin } from './plugin'; describe('welcome', () => { diff --git a/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts b/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts index a65fad5348..addf4c8c34 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts @@ -1,3 +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. + */ + import { createPlugin } from '@backstage/core'; import WelcomePage from './components/WelcomePage'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts b/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts index 666127af39..8925258421 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts @@ -1 +1,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. + */ + import '@testing-library/jest-dom/extend-expect'; From ce950bed7aa5b6e68093e9d30fde472cf39f637c Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Tue, 14 Apr 2020 14:13:58 +0200 Subject: [PATCH 37/80] Add exit() method to Task and use in create-app and create-plugin --- packages/cli/src/commands/create-app/createApp.ts | 2 ++ packages/cli/src/commands/create-plugin/createPlugin.ts | 2 ++ packages/cli/src/helpers/tasks.ts | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index 5863d9e705..b596d9ac73 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -134,6 +134,7 @@ export default async () => { chalk.green(`🥇 Successfully created ${chalk.cyan(answers.name)}`), ); Task.log(); + Task.exit(); } catch (error) { Task.error(error.message); @@ -143,5 +144,6 @@ export default async () => { Task.section('Cleanup'); await cleanUp(tempDir); Task.error('🔥 Failed to create app!'); + Task.exit(1); } }; diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index df8b71f619..51f749a6ac 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -268,6 +268,7 @@ export default async () => { )}`, ); Task.log(); + Task.exit(); } catch (error) { Task.error(error.message); @@ -277,5 +278,6 @@ export default async () => { Task.section('Cleanup'); await cleanUp(tempDir); Task.error('🔥 Failed to create plugin!'); + Task.exit(1); } }; diff --git a/packages/cli/src/helpers/tasks.ts b/packages/cli/src/helpers/tasks.ts index 3cba4d262f..0753301b78 100644 --- a/packages/cli/src/helpers/tasks.ts +++ b/packages/cli/src/helpers/tasks.ts @@ -37,6 +37,10 @@ export class Task { process.stdout.write(`\n ${title}\n`); } + static exit(code: number = 0) { + process.exit(code); + } + static async forItem( task: string, item: string, From 300a891f71f369662bc58e35271fb43e9db07260 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Wed, 15 Apr 2020 14:58:20 +0200 Subject: [PATCH 38/80] Create new app in temp dir --- .github/workflows/cli.yml | 14 ++++++--- packages/cli/bin/backstage-cli | 6 +++- .../cli/src/commands/build-cache/index.ts | 2 +- .../cli/src/commands/plugin/rollup.config.ts | 1 + .../templates/default-app/package.json.hbs | 3 ++ .../default-app/packages/app/package.json.hbs | 4 ++- scripts/cli-e2e-test.js | 23 ++++++++++---- scripts/createTestApp.js | 4 +-- scripts/createTestPlugin.js | 4 +-- scripts/generateTempDir.js | 30 +++++++++++++++++++ 10 files changed, 74 insertions(+), 17 deletions(-) create mode 100644 scripts/generateTempDir.js diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 44a18f10da..6b32834ebd 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -41,18 +41,24 @@ jobs: - name: yarn install run: yarn install --frozen-lockfile - run: yarn build - # This creates a new app and plugin which pollutes the workspace, so it should be run last. + # generate temp directory + - name: generate tempdir + id: generate_tempdir + run: echo ::set-output name=tempdir::$(node scripts/generateTempDir.js) + # This creates a new app and plugin which pollutes the workspace, so it should be run last. - name: verify app and plugin creation on Windows + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} if: runner.os == 'Windows' - run: node scripts/cli-e2e-test.js + run: node ${{ github.workspace }}/scripts/cli-e2e-test.js - name: verify app and plugin creation on Linux + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 - node scripts/cli-e2e-test.js + node ${{ github.workspace }}/scripts/cli-e2e-test.js # This should lint and test both an app and a plugin - name: yarn lint, test after creation - working-directory: test-app + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app run: | yarn lint yarn test diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli index a8ba74f28a..1cdf8c81af 100755 --- a/packages/cli/bin/backstage-cli +++ b/packages/cli/bin/backstage-cli @@ -19,7 +19,11 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); -if (!isLocal) { + +// This is used for e2e-tests where we create a new app in a tmp folder +const isTemp = path.resolve(__dirname).includes(require('os').tmpdir()); + +if (!isLocal || isTemp || process.env.E2E) { // src-relative imports are a pain to get to work with plain tsc compilation, as the // transpiled code will maintain the imports as they are in the source. Which means an // import for `helpers/paths` will start like that in the output, which won't work in NodeJS. diff --git a/packages/cli/src/commands/build-cache/index.ts b/packages/cli/src/commands/build-cache/index.ts index a8a1a27e9d..368fac2593 100644 --- a/packages/cli/src/commands/build-cache/index.ts +++ b/packages/cli/src/commands/build-cache/index.ts @@ -30,7 +30,7 @@ export async function withCache( buildFunc: () => Promise, ): Promise { const key = await Cache.readInputKey(options.inputs); - if (!key) { + if (!key || process.env.E2E) { print('input directory is dirty, skipping cache'); await fs.remove(options.output); await buildFunc(); diff --git a/packages/cli/src/commands/plugin/rollup.config.ts b/packages/cli/src/commands/plugin/rollup.config.ts index fe2675e0e0..c991fa3efb 100644 --- a/packages/cli/src/commands/plugin/rollup.config.ts +++ b/packages/cli/src/commands/plugin/rollup.config.ts @@ -46,6 +46,7 @@ export default { json(), typescript({ include: `${paths.resolveTarget('src')}/**/*.{js,jsx,ts,tsx}`, + clean: true, }), ], } as RollupWatchOptions; diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs index 7b5fab5221..5161e85fe0 100644 --- a/packages/cli/templates/default-app/package.json.hbs +++ b/packages/cli/templates/default-app/package.json.hbs @@ -25,5 +25,8 @@ "@backstage/cli": "^{{version}}", "lerna": "^3.20.2", "prettier": "^1.19.1" + }, + "resolutions": { + "@backstage/cli": "file:/home/runner/work/backstage/backstage/packages/cli" } } diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs index a5b2b07b96..940091380a 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -5,6 +5,7 @@ "dependencies": { "@material-ui/core": "^4.9.1", "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "4.0.0-alpha.45", "@backstage/cli": "^{{version}}", "@backstage/core": "^{{version}}", "@backstage/theme": "^{{version}}", @@ -17,7 +18,8 @@ "plugin-welcome": "0.0.0", "react": "^16.12.0", "react-dom": "^16.12.0", - "react-router-dom": "^5.1.2" + "react-router-dom": "^5.1.2", + "react-use": "^13.24.0" }, "scripts": { "start": "backstage-cli app:serve", diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index e852f73ed0..814d2d6f60 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -27,27 +27,38 @@ const { const createTestApp = require('./createTestApp'); const createTestPlugin = require('./createTestPlugin'); +const generateTempDir = require('./generateTempDir.js'); Browser.localhost('localhost', 3000); async function main() { - process.env.CI = 'true'; + process.env.E2E = 'true'; - const projectDir = resolvePath(__dirname, '..'); - process.chdir(projectDir); + const rootDir = process.env.CI + ? resolvePath(process.env.GITHUB_WORKSPACE) + : resolvePath(__dirname, '..'); - await createTestApp(); + const tempDir = process.env.CI + ? resolvePath(__dirname) + : await generateTempDir(); - const appDir = resolvePath(projectDir, 'test-app'); + process.chdir(tempDir); + await waitForExit(spawnPiped(['yarn', 'init --yes'])); + + const createAppCmd = `${rootDir}/packages/cli/bin/backstage-cli create-app`; + await createTestApp(createAppCmd); + + const appDir = resolvePath(tempDir, 'test-app'); process.chdir(appDir); + await createTestPlugin(); + print('Starting the app'); const startApp = spawnPiped(['yarn', 'start']); try { const browser = new Browser(); - await createTestPlugin(); await waitForPageWithText(browser, '/', 'Welcome to Backstage'); await waitForPageWithText( browser, diff --git a/scripts/createTestApp.js b/scripts/createTestApp.js index 89fa023436..80f7bf3b18 100644 --- a/scripts/createTestApp.js +++ b/scripts/createTestApp.js @@ -16,9 +16,9 @@ const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); -async function createTestApp() { +async function createTestApp(cmd) { print('Creating a Backstage App'); - const createApp = spawnPiped(['yarn', 'create-app']); + const createApp = spawnPiped(['node', cmd]); try { let stdout = ''; diff --git a/scripts/createTestPlugin.js b/scripts/createTestPlugin.js index c204067ae3..59e8d6dd77 100644 --- a/scripts/createTestPlugin.js +++ b/scripts/createTestPlugin.js @@ -29,8 +29,8 @@ async function createTestPlugin() { await waitFor(() => stdout.includes('Enter an ID for the plugin')); createPlugin.stdin.write('test-plugin\n'); - await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); - createPlugin.stdin.write('@someuser\n'); + // await waitFor(() => stdout.includes('Enter the owner(s) of the plugin')); + // createPlugin.stdin.write('@someuser\n'); print('Waiting for plugin create script to be done'); await waitForExit(createPlugin); diff --git a/scripts/generateTempDir.js b/scripts/generateTempDir.js new file mode 100644 index 0000000000..e455a29320 --- /dev/null +++ b/scripts/generateTempDir.js @@ -0,0 +1,30 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const { handleError } = require('./helpers'); + +async function generateTempDir() { + const tempDir = await require('fs-extra').mkdtemp( + require('path').join(require('os').tmpdir(), 'backstage-e2e-'), + ); + process.stdout.write(tempDir); + return tempDir; +} + +module.exports = generateTempDir; + +process.on('unhandledRejection', handleError); +generateTempDir().catch(handleError); From 06150f7094f8af67a6283007c1f473b37edb7532 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 08:53:19 +0200 Subject: [PATCH 39/80] Remove yarn create-app from package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 64f5765312..0387a4e7ce 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "lint:all": "lerna run lint --", "docker-build": "yarn bundle && docker build . -t spotify/backstage", "create-plugin": "backstage-cli create-plugin", - "create-app": "backstage-cli create-app", "release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push; fi", "lerna": "lerna", "storybook": "yarn workspace storybook start" From 85752b4795f979503b6c88dee88b52b152512f51 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 08:53:55 +0200 Subject: [PATCH 40/80] Revert "Add copyright notice to default-app template files" This reverts commit fbc2048350c3a199e503ec3316b1fce8663821e3. --- .../default-app/packages/app/src/App.test.tsx | 16 ---------------- .../default-app/packages/app/src/App.tsx | 16 ---------------- .../default-app/packages/app/src/index.tsx | 16 ---------------- .../default-app/packages/app/src/plugins.ts | 16 ---------------- .../default-app/packages/app/src/setupTests.ts | 16 ---------------- .../welcome/src/components/Timer/Timer.tsx | 16 ---------------- .../welcome/src/components/Timer/index.ts | 16 ---------------- .../components/WelcomePage/WelcomePage.test.tsx | 16 ---------------- .../src/components/WelcomePage/WelcomePage.tsx | 16 ---------------- .../welcome/src/components/WelcomePage/index.ts | 16 ---------------- .../default-app/plugins/welcome/src/index.ts | 16 ---------------- .../plugins/welcome/src/plugin.test.ts | 16 ---------------- .../default-app/plugins/welcome/src/plugin.ts | 16 ---------------- .../plugins/welcome/src/setupTests.ts | 16 ---------------- 14 files changed, 224 deletions(-) diff --git a/packages/cli/templates/default-app/packages/app/src/App.test.tsx b/packages/cli/templates/default-app/packages/app/src/App.test.tsx index ace8f42f45..0074416375 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.test.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.test.tsx @@ -1,19 +1,3 @@ -/* - * 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 App from './App'; diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index ea318c6721..ec8d8d435a 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -1,19 +1,3 @@ -/* - * 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 { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core'; import { createApp } from '@backstage/core'; import { BackstageTheme } from '@backstage/theme'; diff --git a/packages/cli/templates/default-app/packages/app/src/index.tsx b/packages/cli/templates/default-app/packages/app/src/index.tsx index 2ea8d3f1dd..b597a44232 100644 --- a/packages/cli/templates/default-app/packages/app/src/index.tsx +++ b/packages/cli/templates/default-app/packages/app/src/index.tsx @@ -1,19 +1,3 @@ -/* - * 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 ReactDOM from 'react-dom'; import App from './App'; diff --git a/packages/cli/templates/default-app/packages/app/src/plugins.ts b/packages/cli/templates/default-app/packages/app/src/plugins.ts index ba7b721672..000bd79f3e 100644 --- a/packages/cli/templates/default-app/packages/app/src/plugins.ts +++ b/packages/cli/templates/default-app/packages/app/src/plugins.ts @@ -1,17 +1 @@ -/* - * 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 { plugin as WelcomePlugin } from 'plugin-welcome'; diff --git a/packages/cli/templates/default-app/packages/app/src/setupTests.ts b/packages/cli/templates/default-app/packages/app/src/setupTests.ts index 8925258421..666127af39 100644 --- a/packages/cli/templates/default-app/packages/app/src/setupTests.ts +++ b/packages/cli/templates/default-app/packages/app/src/setupTests.ts @@ -1,17 +1 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import '@testing-library/jest-dom/extend-expect'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx index 770f98e762..24c79f91ee 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/Timer.tsx @@ -1,19 +1,3 @@ -/* - * 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 { HeaderLabel } from '@backstage/core'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts index a67293c20e..f1fc55bfe9 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/Timer/index.ts @@ -1,17 +1 @@ -/* - * 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 './Timer'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index 0bf0b2135f..6d9268fadd 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -1,19 +1,3 @@ -/* - * 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 WelcomePage from './WelcomePage'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index feafaa0ade..d9ef0524c6 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -1,19 +1,3 @@ -/* - * 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 { Link as RouterLink } from 'react-router-dom'; import { diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts index fcdde9d498..b031301e7e 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/index.ts @@ -1,17 +1 @@ -/* - * 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 './WelcomePage'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/index.ts b/packages/cli/templates/default-app/plugins/welcome/src/index.ts index 3a0a0fe2d3..99edba26c3 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/index.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/index.ts @@ -1,17 +1 @@ -/* - * 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 { plugin } from './plugin'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts b/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts index d60c73ec68..f5bf8e68c3 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/plugin.test.ts @@ -1,19 +1,3 @@ -/* - * 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 { plugin } from './plugin'; describe('welcome', () => { diff --git a/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts b/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts index addf4c8c34..a65fad5348 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/plugin.ts @@ -1,19 +1,3 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import { createPlugin } from '@backstage/core'; import WelcomePage from './components/WelcomePage'; diff --git a/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts b/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts index 8925258421..666127af39 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts +++ b/packages/cli/templates/default-app/plugins/welcome/src/setupTests.ts @@ -1,17 +1 @@ -/* - * Copyright 2020 Spotify AB - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - import '@testing-library/jest-dom/extend-expect'; From 866dac825292c68204260f191506fc4477393dd1 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez <62359443+braulio-balanza@users.noreply.github.com> Date: Thu, 16 Apr 2020 01:58:46 -0500 Subject: [PATCH 41/80] Fix error when using yarn lint (#562) --- packages/core/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/package.json b/packages/core/package.json index 94195942f1..366620d279 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.4", + "@backstage/test-utils": "0.1.1-alpha.4", "@backstage/test-utils-core": "^0.1.1-alpha.4", "@backstage/theme": "^0.1.1-alpha.4", "@storybook/addon-storysource": "^5.3.18", From 111f75cf3d75e2d861477623766f72e768c6fbc5 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 09:02:58 +0200 Subject: [PATCH 42/80] packages/storybook: added note in readme explaining why the package exists (#560) --- packages/storybook/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/storybook/README.md b/packages/storybook/README.md index 2e716a5466..66768c0fe5 100644 --- a/packages/storybook/README.md +++ b/packages/storybook/README.md @@ -1,3 +1,7 @@ # storybook This package provides a storybook build for Backstage. See [storybook.backstage.io](http://storybook.backstage.io) + +## Why is this not part of `@backstage/core`? + +This separate storybook package exists because of dependency conflicts with `@backstage/cli`. It uses nohoist to avoid the conflicts, and since you can only use that in private packages it has to be separated out of `@backstage/core`. From ed32b1d161f99f161ed8db738c324551f83239cd Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 09:07:02 +0200 Subject: [PATCH 43/80] Properly change to temp directory --- .github/workflows/cli.yml | 2 ++ scripts/cli-e2e-test.js | 4 +--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 6b32834ebd..8b056a7772 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -60,6 +60,8 @@ jobs: - name: yarn lint, test after creation working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app run: | + pwd + ls -la yarn lint yarn test env: diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 814d2d6f60..934585bee5 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -38,9 +38,7 @@ async function main() { ? resolvePath(process.env.GITHUB_WORKSPACE) : resolvePath(__dirname, '..'); - const tempDir = process.env.CI - ? resolvePath(__dirname) - : await generateTempDir(); + const tempDir = process.env.CI ? process.cwd() : await generateTempDir(); process.chdir(tempDir); await waitForExit(spawnPiped(['yarn', 'init --yes'])); From 6f1c443ee02e2bb47756a21ad76ad9107af54544 Mon Sep 17 00:00:00 2001 From: Victor Viale Date: Thu, 16 Apr 2020 10:15:16 +0200 Subject: [PATCH 44/80] Convert WarningPanel to TypeScript (#554) (#561) Co-authored-by: Victor Viale --- ...ingPanel.test.js => WarningPanel.test.tsx} | 0 .../{WarningPanel.js => WarningPanel.tsx} | 55 ++++++++++--------- .../WarningPanel/{index.js => index.ts} | 0 3 files changed, 28 insertions(+), 27 deletions(-) rename packages/core/src/components/WarningPanel/{WarningPanel.test.js => WarningPanel.test.tsx} (100%) rename packages/core/src/components/WarningPanel/{WarningPanel.js => WarningPanel.tsx} (64%) rename packages/core/src/components/WarningPanel/{index.js => index.ts} (100%) diff --git a/packages/core/src/components/WarningPanel/WarningPanel.test.js b/packages/core/src/components/WarningPanel/WarningPanel.test.tsx similarity index 100% rename from packages/core/src/components/WarningPanel/WarningPanel.test.js rename to packages/core/src/components/WarningPanel/WarningPanel.test.tsx diff --git a/packages/core/src/components/WarningPanel/WarningPanel.js b/packages/core/src/components/WarningPanel/WarningPanel.tsx similarity index 64% rename from packages/core/src/components/WarningPanel/WarningPanel.js rename to packages/core/src/components/WarningPanel/WarningPanel.tsx index 679f7511d8..17b9548b81 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.js +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { Typography, withStyles } from '@material-ui/core'; +import React, { FC } from 'react'; +import { Typography, withStyles, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; import ErrorOutline from '@material-ui/icons/ErrorOutline'; const errorOutlineStyles = theme => ({ @@ -27,7 +27,7 @@ const errorOutlineStyles = theme => ({ }); const ErrorOutlineStyled = withStyles(errorOutlineStyles)(ErrorOutline); -const styles = theme => ({ +const useStyles = makeStyles(theme => ({ message: { display: 'flex', flexDirection: 'column', @@ -47,34 +47,35 @@ const styles = theme => ({ messageText: { color: theme.palette.warningText, }, -}); +})); /** * WarningPanel. Show a user friendly error message to a user similar to ErrorPanel except that the warning panel * only shows the warning message to the user */ -class WarningPanel extends Component { - static propTypes = { - message: PropTypes.node.isRequired, - }; - render() { - const { classes, title, message, children } = this.props; - return ( -

-
- - - {title} - -
- {message && ( - {message} - )} - {children} +type Props = { + message?: React.ReactNode; + title?: string; +}; + +const WarningPanel: FC = props => { + const classes = useStyles(props); + const { title, message, children } = props; + return ( +
+
+ + + {title} +
- ); - } -} + {message && ( + {message} + )} + {children} +
+ ); +}; -export default withStyles(styles)(WarningPanel); +export default WarningPanel; diff --git a/packages/core/src/components/WarningPanel/index.js b/packages/core/src/components/WarningPanel/index.ts similarity index 100% rename from packages/core/src/components/WarningPanel/index.js rename to packages/core/src/components/WarningPanel/index.ts From 432a2a3c31e508d74576368ca0af9e9ba9292e7e Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 10:15:17 +0200 Subject: [PATCH 45/80] Use :all flags for lint and test to not depend on git since --- .github/workflows/cli.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 8b056a7772..916caf3bfe 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -60,9 +60,7 @@ jobs: - name: yarn lint, test after creation working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app run: | - pwd - ls -la - yarn lint - yarn test + yarn lint:all + yarn test:all env: CI: true From df28af29d5edfbd21a5e385ce4f474b2685737ba Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Thu, 16 Apr 2020 10:16:29 +0200 Subject: [PATCH 46/80] Set a width on TabbedCard in Storybook using same method as InfoCard --- .../layout/TabbedCard/TabbedCard.stories.tsx | 50 ++++++++++++++----- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx b/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx index d670b0b908..cf9fa6e6c6 100644 --- a/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx +++ b/packages/core/src/layout/TabbedCard/TabbedCard.stories.tsx @@ -15,19 +15,37 @@ */ import React, { useState } from 'react'; import { TabbedCard, CardTab } from '.'; +import { Grid } from '@material-ui/core'; + +const cardContentStyle = { height: 200, width: 500 }; export default { title: 'Tabbed Card', component: TabbedCard, + decorators: [ + storyFn => ( + + {storyFn()} + + ), + ], }; export const Default = () => { return ( - some content 1 - some content 2 - some content 3 - some content 4 + +
Some content
+
+ +
Some content 2
+
+ +
Some content 3
+
+ +
Some content 4
+
); }; @@ -37,10 +55,18 @@ const linkInfo = { title: 'Go to XYZ Location', link: '#' }; export const WithFooterLink = () => { return ( - some content 1 - some content 2 - some content 3 - some content 4 + +
Some content
+
+ +
Some content 2
+
+ +
Some content 3
+
+ +
Some content 4
+
); }; @@ -60,16 +86,16 @@ export const WithControlledTabValue = () => { title="Controlled Value Example" > - some content 1 +
Some content
- some content 2 +
Some content 2
- some content 3 +
Some content 3
- some content 4 +
Some content 4
From 66a39273adcef4069da468c25ec1b7026943c07e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 10:33:55 +0200 Subject: [PATCH 47/80] packages/cli: added --build flag to watch-deps command --- packages/cli/src/commands/watch-deps/index.ts | 11 +++++++++-- packages/cli/src/index.ts | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/watch-deps/index.ts b/packages/cli/src/commands/watch-deps/index.ts index 11093d7387..68d2417e36 100644 --- a/packages/cli/src/commands/watch-deps/index.ts +++ b/packages/cli/src/commands/watch-deps/index.ts @@ -23,6 +23,7 @@ import { startCompiler } from './compiler'; import { startChild } from './child'; import { waitForExit, run } from 'helpers/run'; import { paths } from 'helpers/paths'; +import { Command } from 'commander'; const PACKAGE_BLACKLIST = [ // We never want to watch for changes in the cli, but all packages will depend on it. @@ -88,8 +89,14 @@ export async function watchDeps(options: Options = {}) { * 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[]) => { - await watchDeps(); +export default async (cmd: Command, args: string[]) => { + const options: Options = {}; + + if (cmd.build) { + options.build = true; + } + + await watchDeps(options); if (args?.length) { await waitForExit(startChild(args)); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ca39c1ac3b..1a05b31135 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -70,6 +70,7 @@ const main = (argv: string[]) => { program .command('watch-deps') + .option('--build', 'Build all dependencies on startup') .description('Watch all dependencies while running another command') .action(actionHandler(() => require('commands/watch-deps'))); From 4f9789f23a024cf8e516f939b86f1a92b0652085 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 10:35:13 +0200 Subject: [PATCH 48/80] packages/storybook: depend on dist version of local packages and make sure they are built --- packages/storybook/.storybook/main.js | 2 +- packages/storybook/package.json | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/storybook/.storybook/main.js b/packages/storybook/.storybook/main.js index f086fa0d4a..530cd161ec 100644 --- a/packages/storybook/.storybook/main.js +++ b/packages/storybook/.storybook/main.js @@ -13,7 +13,7 @@ module.exports = { webpackFinal: async config => { config.resolve.alias = { ...config.resolve.alias, - '@backstage/theme': path.resolve(__dirname, '../../theme/src'), + '@backstage/theme': path.resolve(__dirname, '../../theme'), }; config.resolve.modules.push(path.resolve(__dirname, '../../core/src')); config.module.rules.push( diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 8b54b2b8e1..857c915357 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -4,14 +4,17 @@ "description": "Storybook build for core package", "private": true, "scripts": { - "start": "start-storybook -p 6006", - "build-storybook": "build-storybook --output-dir dist" + "start": "backstage-cli watch-deps --build -- start-storybook -p 6006", + "build-storybook": "backstage-cli watch-deps --build -- build-storybook --output-dir dist" }, "workspaces": { "nohoist": [ "@storybook/**" ] }, + "dependencies": { + "@backstage/theme": "0.1.1-alpha.4" + }, "devDependencies": { "@storybook/addon-actions": "^5.3.17", "@storybook/addon-links": "^5.3.17", From f5e09895a157f87ae77a04129c35eae85488304a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 14 Apr 2020 19:11:29 +0200 Subject: [PATCH 49/80] packages/theme: refactor theme definitions --- packages/theme/src/BackstageTheme.ts | 217 ++++++++++---------- packages/theme/src/muiComponentOverrides.ts | 155 ++++++++++++++ packages/theme/src/themes.ts | 53 +++++ 3 files changed, 316 insertions(+), 109 deletions(-) create mode 100644 packages/theme/src/muiComponentOverrides.ts create mode 100644 packages/theme/src/themes.ts diff --git a/packages/theme/src/BackstageTheme.ts b/packages/theme/src/BackstageTheme.ts index 4f61437400..aa920c6e6a 100644 --- a/packages/theme/src/BackstageTheme.ts +++ b/packages/theme/src/BackstageTheme.ts @@ -123,138 +123,137 @@ const extendedThemeConfig: BackstageMuiThemeOptions = { const createOverrides = ( theme: BackstageMuiTheme, -): Partial => { +): Partial => { return { - overrides: { - MuiTableRow: { - // Alternating row backgrounds - root: { - '&:nth-of-type(odd)': { - backgroundColor: theme.palette.background.default, - }, - }, - // Use pointer for hoverable rows - hover: { - '&:hover': { - cursor: 'pointer', - }, - }, - // Alternating head backgrounds - head: { - '&:nth-of-type(odd)': { - backgroundColor: COLORS.NAMED.WHITE, - }, + MuiTableRow: { + // Alternating row backgrounds + root: { + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.default, }, }, - // Tables are more dense than default mui tables - MuiTableCell: { - root: { - wordBreak: 'break-word', - overflow: 'hidden', - verticalAlign: 'middle', - lineHeight: '1', - margin: 0, - padding: '8px', - borderBottom: 0, - }, - head: { - wordBreak: 'break-word', - overflow: 'hidden', - color: 'rgb(179, 179, 179)', - fontWeight: 'normal', - lineHeight: '1', + // Use pointer for hoverable rows + hover: { + '&:hover': { + cursor: 'pointer', }, }, - MuiTabs: { - // Tabs are smaller than default mui tab rows - root: { - minHeight: 24, + // Alternating head backgrounds + head: { + '&:nth-of-type(odd)': { + backgroundColor: COLORS.NAMED.WHITE, }, }, - MuiTab: { - // Tabs are smaller and have a hover background - root: { - color: theme.palette.link, - minHeight: 24, - textTransform: 'initial', - '&:hover': { - color: darken(theme.palette.link, 0.3), - background: lighten(theme.palette.link, 0.95), - }, - [theme.breakpoints.up('md')]: { - minWidth: 120, - fontSize: theme.typography.pxToRem(14), - fontWeight: 500, - }, + }, + // Tables are more dense than default mui tables + MuiTableCell: { + root: { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: '8px', + borderBottom: 0, + }, + head: { + wordBreak: 'break-word', + overflow: 'hidden', + color: 'rgb(179, 179, 179)', + fontWeight: 'normal', + lineHeight: '1', + }, + }, + MuiTabs: { + // Tabs are smaller than default mui tab rows + root: { + minHeight: 24, + }, + }, + MuiTab: { + // Tabs are smaller and have a hover background + root: { + color: theme.palette.link, + minHeight: 24, + textTransform: 'initial', + '&:hover': { + color: darken(theme.palette.link, 0.3), + background: lighten(theme.palette.link, 0.95), }, - textColorPrimary: { - color: theme.palette.link, + [theme.breakpoints.up('md')]: { + minWidth: 120, + fontSize: theme.typography.pxToRem(14), + fontWeight: 500, }, }, - MuiTableSortLabel: { - // No color change on hover, just rely on the arrow showing up instead. - root: { + textColorPrimary: { + color: theme.palette.link, + }, + }, + MuiTableSortLabel: { + // No color change on hover, just rely on the arrow showing up instead. + root: { + color: 'inherit', + '&:hover': { color: 'inherit', - '&:hover': { - color: 'inherit', - }, - '&:focus': { - color: 'inherit', - }, }, - // Bold font for highlighting selected column - active: { - fontWeight: 'bold', + '&:focus': { color: 'inherit', }, }, - MuiListItemText: { - dense: { - // Default dense list items to adding ellipsis for really long str... - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, + // Bold font for highlighting selected column + active: { + fontWeight: 'bold', + color: 'inherit', }, - MuiButton: { - text: { - // Text buttons have less padding by default, but we want to keep the original padding - padding: undefined, - }, + }, + MuiListItemText: { + dense: { + // Default dense list items to adding ellipsis for really long str... + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', }, - MuiChip: { - root: { - // By default there's no margin, but it's usually wanted, so we add some trailing margin - marginRight: theme.spacing(1), - marginBottom: theme.spacing(1), - }, + }, + MuiButton: { + text: { + // Text buttons have less padding by default, but we want to keep the original padding + padding: undefined, }, - MuiCardHeader: { - root: { - // Reduce padding between header and content - paddingBottom: 0, - }, + }, + MuiChip: { + root: { + // By default there's no margin, but it's usually wanted, so we add some trailing margin + marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), }, - MuiCardActions: { - root: { - // We default to putting the card actions at the end - justifyContent: 'flex-end', - }, + }, + MuiCardHeader: { + root: { + // Reduce padding between header and content + paddingBottom: 0, + }, + }, + MuiCardActions: { + root: { + // We default to putting the card actions at the end + justifyContent: 'flex-end', }, }, }; }; -const extendedTheme = createMuiTheme(extendedThemeConfig) as BackstageMuiTheme; +function createBackstageTheme( + ...config: BackstageMuiThemeOptions[] +): BackstageMuiTheme { + const withoutOverrides = createMuiTheme(...config) as BackstageMuiTheme; -// V1 theming -// https://material-ui-next.com/customization/themes/ -// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ -const BackstageTheme: BackstageMuiTheme = { - ...extendedTheme, - ...createOverrides(extendedTheme), -}; + return { + ...withoutOverrides, + overrides: createOverrides(withoutOverrides), + }; +} -// Temporary workaround for files incorrectly importing the theme directly -export const V1 = BackstageTheme; -export default BackstageTheme; +const defaultTheme = createBackstageTheme(extendedThemeConfig); + +export default defaultTheme; diff --git a/packages/theme/src/muiComponentOverrides.ts b/packages/theme/src/muiComponentOverrides.ts new file mode 100644 index 0000000000..50daf49e5e --- /dev/null +++ b/packages/theme/src/muiComponentOverrides.ts @@ -0,0 +1,155 @@ +/* + * 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 { createMuiTheme } from '@material-ui/core'; +import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; + +import { BackstageMuiTheme, BackstageMuiThemeOptions } from './types'; + +const createOverrides = ( + theme: BackstageMuiTheme, +): Partial => { + return { + MuiTableRow: { + // Alternating row backgrounds + root: { + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.default, + }, + }, + // Use pointer for hoverable rows + hover: { + '&:hover': { + cursor: 'pointer', + }, + }, + // Alternating head backgrounds + head: { + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.paper, + }, + }, + }, + // Tables are more dense than default mui tables + MuiTableCell: { + root: { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: '8px', + borderBottom: 0, + }, + head: { + wordBreak: 'break-word', + overflow: 'hidden', + color: 'rgb(179, 179, 179)', + fontWeight: 'normal', + lineHeight: '1', + }, + }, + MuiTabs: { + // Tabs are smaller than default mui tab rows + root: { + minHeight: 24, + }, + }, + MuiTab: { + // Tabs are smaller and have a hover background + root: { + color: theme.palette.link, + minHeight: 24, + textTransform: 'initial', + '&:hover': { + color: darken(theme.palette.link, 0.3), + background: lighten(theme.palette.link, 0.95), + }, + [theme.breakpoints.up('md')]: { + minWidth: 120, + fontSize: theme.typography.pxToRem(14), + fontWeight: 500, + }, + }, + textColorPrimary: { + color: theme.palette.link, + }, + }, + MuiTableSortLabel: { + // No color change on hover, just rely on the arrow showing up instead. + root: { + color: 'inherit', + '&:hover': { + color: 'inherit', + }, + '&:focus': { + color: 'inherit', + }, + }, + // Bold font for highlighting selected column + active: { + fontWeight: 'bold', + color: 'inherit', + }, + }, + MuiListItemText: { + dense: { + // Default dense list items to adding ellipsis for really long str... + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, + MuiButton: { + text: { + // Text buttons have less padding by default, but we want to keep the original padding + padding: undefined, + }, + }, + MuiChip: { + root: { + // By default there's no margin, but it's usually wanted, so we add some trailing margin + marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + }, + MuiCardHeader: { + root: { + // Reduce padding between header and content + paddingBottom: 0, + }, + }, + MuiCardActions: { + root: { + // We default to putting the card actions at the end + justifyContent: 'flex-end', + }, + }, + }; +}; + +function applyComponentOverrides( + ...config: BackstageMuiThemeOptions[] +): BackstageMuiTheme { + const withoutOverrides = createMuiTheme(...config) as BackstageMuiTheme; + + return { + ...withoutOverrides, + overrides: createOverrides(withoutOverrides), + }; +} + +const defaultTheme = createBackstageTheme(extendedThemeConfig); diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts new file mode 100644 index 0000000000..e7472463d6 --- /dev/null +++ b/packages/theme/src/themes.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +const darkColors = { + PAGE_BACKGROUND: '#282828', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + NAMED: { + WHITE: '#FEFEFE', + }, + STATUS: { + OK: '#1db855', + WARNING: '#f49b20', + ERROR: '#CA001B', + }, +}; + +const lightColors = { + PAGE_BACKGROUND: '#F8F8F8', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + NAMED: { + WHITE: '#FEFEFE', + }, + STATUS: { + OK: '#1db855', + WARNING: '#f49b20', + ERROR: '#CA001B', + }, +}; From d959179729e717b8d09e567500734ea83cd46ad6 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:00:50 +0200 Subject: [PATCH 50/80] packages/theme: merge themes and differ through color definitions --- packages/theme/src/BackstageThemeDark.ts | 273 ----------------- packages/theme/src/BackstageThemeLight.ts | 274 ------------------ .../src/{BackstageTheme.ts => baseTheme.ts} | 218 +++++++------- packages/theme/src/index.ts | 14 +- packages/theme/src/muiComponentOverrides.ts | 155 ---------- packages/theme/src/themes.ts | 54 ++-- packages/theme/src/types.ts | 27 +- 7 files changed, 163 insertions(+), 852 deletions(-) delete mode 100644 packages/theme/src/BackstageThemeDark.ts delete mode 100644 packages/theme/src/BackstageThemeLight.ts rename packages/theme/src/{BackstageTheme.ts => baseTheme.ts} (57%) delete mode 100644 packages/theme/src/muiComponentOverrides.ts diff --git a/packages/theme/src/BackstageThemeDark.ts b/packages/theme/src/BackstageThemeDark.ts deleted file mode 100644 index 9952a5eeaa..0000000000 --- a/packages/theme/src/BackstageThemeDark.ts +++ /dev/null @@ -1,273 +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 { createMuiTheme } from '@material-ui/core'; -import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; -import { blue, yellow } from '@material-ui/core/colors'; - -import { BackstageMuiTheme, BackstageMuiThemeOptions } from './types'; - -const COLORS = { - PAGE_BACKGROUND: '#282828', - DEFAULT_PAGE_THEME_COLOR: '#7C3699', - DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', - SIDEBAR_BACKGROUND_COLOR: '#424242', - ERROR_BACKGROUND_COLOR: '#FFEBEE', - ERROR_TEXT_COLOR: '#CA001B', - INFO_TEXT_COLOR: '#004e8a', - LINK_TEXT: '#0A6EBE', - LINK_TEXT_HOVER: '#2196F3', - NAMED: { - WHITE: '#FEFEFE', - }, - STATUS: { - OK: '#1db855', - WARNING: '#f49b20', - ERROR: '#CA001B', - }, -}; - -const extendedThemeConfig: BackstageMuiThemeOptions = { - props: { - MuiGrid: { - spacing: 2, - }, - MuiSwitch: { - color: 'primary', - }, - }, - palette: { - background: { - default: COLORS.PAGE_BACKGROUND, - // @ts-ignore - informational: '#60a3cb', - }, - color: { - default: '#fff', - }, - type: 'dark', - status: { - ok: COLORS.STATUS.OK, - warning: COLORS.STATUS.WARNING, - error: COLORS.STATUS.ERROR, - running: '#BEBEBE', - pending: '#5BC0DE', - background: COLORS.NAMED.WHITE, - }, - bursts: { - fontColor: COLORS.NAMED.WHITE, - slackChannelText: '#ddd', - backgroundColor: { - default: COLORS.DEFAULT_PAGE_THEME_COLOR, - }, - }, - // @ts-ignore - primary: { - main: blue[500], - }, - border: '#E6E6E6', - textVerySubtle: '#DDD', - textSubtle: '#6E6E6E', - highlight: '#FFFBCC', - errorBackground: COLORS.ERROR_BACKGROUND_COLOR, - warningBackground: '#F59B23', - infoBackground: '#ebf5ff', - errorText: COLORS.ERROR_TEXT_COLOR, - infoText: COLORS.INFO_TEXT_COLOR, - warningText: COLORS.NAMED.WHITE, - linkHover: COLORS.LINK_TEXT_HOVER, - link: COLORS.LINK_TEXT, - gold: yellow.A700, - sidebar: COLORS.SIDEBAR_BACKGROUND_COLOR, - }, - navigation: { - width: 220, - background: '#333333', - }, - typography: { - fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', - h5: { - fontWeight: 700, - }, - h4: { - fontWeight: 700, - fontSize: 28, - marginBottom: 6, - }, - h3: { - fontSize: 32, - fontWeight: 700, - marginBottom: 6, - }, - h2: { - fontSize: 40, - fontWeight: 700, - marginBottom: 8, - }, - h1: { - fontSize: 54, - fontWeight: 700, - marginBottom: 10, - }, - }, -}; - -const createOverrides = (theme: BackstageMuiTheme): BackstageMuiTheme => { - return { - overrides: { - // @ts-ignore - MuiCSSBaseline: { - '@global': { - body: { - backgroundColor: theme.palette.background.default, - // @ts-ignore - color: theme.palette.color.default, - }, - }, - }, - MuiTableRow: { - // Alternating row backgrounds - root: { - '&:nth-of-type(odd)': { - backgroundColor: theme.palette.background.default, - }, - }, - // Use pointer for hoverable rows - hover: { - '&:hover': { - cursor: 'pointer', - }, - }, - // Alternating head backgrounds - head: { - '&:nth-of-type(odd)': { - backgroundColor: COLORS.NAMED.WHITE, - }, - }, - }, - // Tables are more dense than default mui tables - MuiTableCell: { - root: { - wordBreak: 'break-word', - overflow: 'hidden', - verticalAlign: 'middle', - lineHeight: '1', - margin: 0, - padding: '8px', - borderBottom: 0, - }, - head: { - wordBreak: 'break-word', - overflow: 'hidden', - color: 'rgb(179, 179, 179)', - fontWeight: 'normal', - lineHeight: '1', - }, - }, - MuiTabs: { - // Tabs are smaller than default mui tab rows - root: { - minHeight: 24, - }, - }, - MuiTab: { - // Tabs are smaller and have a hover background - root: { - color: theme.palette.link, - minHeight: 24, - textTransform: 'initial', - '&:hover': { - color: darken(theme.palette.link, 0.3), - background: lighten(theme.palette.link, 0.95), - }, - [theme.breakpoints.up('md')]: { - minWidth: 120, - fontSize: theme.typography.pxToRem(14), - fontWeight: 500, - }, - }, - textColorPrimary: { - color: theme.palette.link, - }, - }, - MuiTableSortLabel: { - // No color change on hover, just rely on the arrow showing up instead. - root: { - color: 'inherit', - '&:hover': { - color: 'inherit', - }, - '&:focus': { - color: 'inherit', - }, - }, - // Bold font for highlighting selected column - active: { - fontWeight: 'bold', - color: 'inherit', - }, - }, - MuiListItemText: { - dense: { - // Default dense list items to adding ellipsis for really long str... - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - }, - MuiButton: { - text: { - // Text buttons have less padding by default, but we want to keep the original padding - padding: undefined, - }, - }, - MuiChip: { - root: { - // By default there's no margin, but it's usually wanted, so we add some trailing margin - marginRight: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - }, - MuiCardHeader: { - root: { - // Reduce padding between header and content - paddingBottom: 0, - }, - }, - MuiCardActions: { - root: { - // We default to putting the card actions at the end - justifyContent: 'flex-end', - }, - }, - }, - }; -}; - -const extendedTheme = createMuiTheme(extendedThemeConfig) as BackstageMuiTheme; - -// V1 theming -// https://material-ui-next.com/customization/themes/ -// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ -const BackstageThemeDark = { - ...extendedTheme, - ...createOverrides(extendedTheme), -}; - -// Temporary workaround for files incorrectly importing the theme directly -export const V1 = BackstageThemeDark; - -export default BackstageThemeDark; diff --git a/packages/theme/src/BackstageThemeLight.ts b/packages/theme/src/BackstageThemeLight.ts deleted file mode 100644 index a0a063ad24..0000000000 --- a/packages/theme/src/BackstageThemeLight.ts +++ /dev/null @@ -1,274 +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 { createMuiTheme } from '@material-ui/core'; -import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; -import { blue, yellow } from '@material-ui/core/colors'; - -import { BackstageMuiTheme, BackstageMuiThemeOptions } from './types'; - -const COLORS = { - PAGE_BACKGROUND: '#F8F8F8', - DEFAULT_PAGE_THEME_COLOR: '#7C3699', - DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', - SIDEBAR_BACKGROUND_COLOR: '#171717', - ERROR_BACKGROUND_COLOR: '#FFEBEE', - ERROR_TEXT_COLOR: '#CA001B', - INFO_TEXT_COLOR: '#004e8a', - LINK_TEXT: '#0A6EBE', - LINK_TEXT_HOVER: '#2196F3', - NAMED: { - WHITE: '#FEFEFE', - }, - STATUS: { - OK: '#1db855', - WARNING: '#f49b20', - ERROR: '#CA001B', - }, -}; - -const extendedThemeConfig: BackstageMuiThemeOptions = { - props: { - MuiGrid: { - spacing: 2, - }, - MuiSwitch: { - color: 'primary', - }, - }, - palette: { - background: { - default: COLORS.PAGE_BACKGROUND, - // @ts-ignore - informational: '#60a3cb', - }, - color: { - default: '#000', - }, - status: { - ok: COLORS.STATUS.OK, - warning: COLORS.STATUS.WARNING, - error: COLORS.STATUS.ERROR, - running: '#BEBEBE', - pending: '#5BC0DE', - background: COLORS.NAMED.WHITE, - }, - bursts: { - fontColor: COLORS.NAMED.WHITE, - slackChannelText: '#ddd', - backgroundColor: { - default: COLORS.DEFAULT_PAGE_THEME_COLOR, - }, - }, - // @ts-ignore - primary: { - main: blue[500], - }, - border: '#E6E6E6', - textVerySubtle: '#DDD', - textSubtle: '#6E6E6E', - highlight: '#FFFBCC', - errorBackground: COLORS.ERROR_BACKGROUND_COLOR, - warningBackground: '#F59B23', - infoBackground: '#ebf5ff', - errorText: COLORS.ERROR_TEXT_COLOR, - infoText: COLORS.INFO_TEXT_COLOR, - warningText: COLORS.NAMED.WHITE, - linkHover: COLORS.LINK_TEXT_HOVER, - link: COLORS.LINK_TEXT, - gold: yellow.A700, - sidebar: COLORS.SIDEBAR_BACKGROUND_COLOR, - }, - navigation: { - width: 220, - background: '#333333', - }, - typography: { - fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', - h5: { - fontWeight: 700, - }, - h4: { - fontWeight: 700, - fontSize: 28, - marginBottom: 6, - }, - h3: { - fontSize: 32, - fontWeight: 700, - marginBottom: 6, - }, - h2: { - fontSize: 40, - fontWeight: 700, - marginBottom: 8, - }, - h1: { - fontSize: 54, - fontWeight: 700, - marginBottom: 10, - }, - }, -}; - -const createOverrides = ( - theme: BackstageMuiTheme, -): Partial => { - return { - overrides: { - // @ts-ignore - MuiCSSBaseline: { - '@global': { - body: { - backgroundColor: theme.palette.background.default, - // @ts-ignore - color: theme.palette.color.default, - }, - }, - }, - MuiTableRow: { - // Alternating row backgrounds - root: { - '&:nth-of-type(odd)': { - backgroundColor: theme.palette.background.default, - }, - }, - // Use pointer for hoverable rows - hover: { - '&:hover': { - cursor: 'pointer', - }, - }, - // Alternating head backgrounds - head: { - '&:nth-of-type(odd)': { - backgroundColor: COLORS.NAMED.WHITE, - }, - }, - }, - // Tables are more dense than default mui tables - MuiTableCell: { - root: { - wordBreak: 'break-word', - overflow: 'hidden', - verticalAlign: 'middle', - lineHeight: '1', - margin: 0, - padding: '8px', - borderBottom: 0, - }, - head: { - wordBreak: 'break-word', - overflow: 'hidden', - color: 'rgb(179, 179, 179)', - fontWeight: 'normal', - lineHeight: '1', - }, - }, - MuiTabs: { - // Tabs are smaller than default mui tab rows - root: { - minHeight: 24, - }, - }, - MuiTab: { - // Tabs are smaller and have a hover background - root: { - color: theme.palette.link, - minHeight: 24, - textTransform: 'initial', - '&:hover': { - color: darken(theme.palette.link, 0.3), - background: lighten(theme.palette.link, 0.95), - }, - [theme.breakpoints.up('md')]: { - minWidth: 120, - fontSize: theme.typography.pxToRem(14), - fontWeight: 500, - }, - }, - textColorPrimary: { - color: theme.palette.link, - }, - }, - MuiTableSortLabel: { - // No color change on hover, just rely on the arrow showing up instead. - root: { - color: 'inherit', - '&:hover': { - color: 'inherit', - }, - '&:focus': { - color: 'inherit', - }, - }, - // Bold font for highlighting selected column - active: { - fontWeight: 'bold', - color: 'inherit', - }, - }, - MuiListItemText: { - dense: { - // Default dense list items to adding ellipsis for really long str... - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - }, - MuiButton: { - text: { - // Text buttons have less padding by default, but we want to keep the original padding - padding: undefined, - }, - }, - MuiChip: { - root: { - // By default there's no margin, but it's usually wanted, so we add some trailing margin - marginRight: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - }, - MuiCardHeader: { - root: { - // Reduce padding between header and content - paddingBottom: 0, - }, - }, - MuiCardActions: { - root: { - // We default to putting the card actions at the end - justifyContent: 'flex-end', - }, - }, - }, - }; -}; - -const extendedTheme = createMuiTheme(extendedThemeConfig) as BackstageMuiTheme; - -// V1 theming -// https://material-ui-next.com/customization/themes/ -// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ -const BackstageThemeLight = { - ...extendedTheme, - ...createOverrides(extendedTheme), -}; - -// Temporary workaround for files incorrectly importing the theme directly -export const V1 = BackstageThemeLight; - -export default BackstageThemeLight; diff --git a/packages/theme/src/BackstageTheme.ts b/packages/theme/src/baseTheme.ts similarity index 57% rename from packages/theme/src/BackstageTheme.ts rename to packages/theme/src/baseTheme.ts index aa920c6e6a..f07333ef1f 100644 --- a/packages/theme/src/BackstageTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -17,113 +17,105 @@ import { createMuiTheme } from '@material-ui/core'; import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; import { blue, yellow } from '@material-ui/core/colors'; +import { + BackstageTheme, + BackstageThemeOptions, + BackstageColorScheme, +} from './types'; -import { BackstageMuiTheme, BackstageMuiThemeOptions } from './types'; +type Overrides = Partial; -const COLORS = { - PAGE_BACKGROUND: '#F8F8F8', - DEFAULT_PAGE_THEME_COLOR: '#7C3699', - DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', - SIDEBAR_BACKGROUND_COLOR: '#171717', - ERROR_BACKGROUND_COLOR: '#FFEBEE', - ERROR_TEXT_COLOR: '#CA001B', - INFO_TEXT_COLOR: '#004e8a', - LINK_TEXT: '#0A6EBE', - LINK_TEXT_HOVER: '#2196F3', - NAMED: { - WHITE: '#FEFEFE', - }, - STATUS: { - OK: '#1db855', - WARNING: '#f49b20', - ERROR: '#CA001B', - }, -}; - -const extendedThemeConfig: BackstageMuiThemeOptions = { - props: { - MuiGrid: { - spacing: 2, - }, - MuiSwitch: { - color: 'primary', - }, - }, - palette: { - background: { - default: COLORS.PAGE_BACKGROUND, - // @ts-ignore - informational: '#60a3cb', - }, - status: { - ok: COLORS.STATUS.OK, - warning: COLORS.STATUS.WARNING, - error: COLORS.STATUS.ERROR, - running: '#BEBEBE', - pending: '#5BC0DE', - background: COLORS.NAMED.WHITE, - }, - bursts: { - fontColor: COLORS.NAMED.WHITE, - slackChannelText: '#ddd', - backgroundColor: { - default: COLORS.DEFAULT_PAGE_THEME_COLOR, +export function createThemeOptions( + type: 'light' | 'dark', + colors: BackstageColorScheme, +): BackstageThemeOptions { + return { + props: { + MuiGrid: { + spacing: 2, + }, + MuiSwitch: { + color: 'primary', }, }, - // @ts-ignore - primary: { - main: blue[500], + palette: { + type, + background: { + default: colors.PAGE_BACKGROUND, + // @ts-ignore + informational: '#60a3cb', + }, + color: { + default: colors.TEXT_COLOR, + }, + status: { + ok: colors.STATUS_OK, + warning: colors.STATUS_WARNING, + error: colors.STATUS_ERROR, + running: '#BEBEBE', + pending: '#5BC0DE', + background: colors.NAMED_WHITE, + }, + bursts: { + fontColor: colors.NAMED_WHITE, + slackChannelText: '#ddd', + backgroundColor: { + default: colors.DEFAULT_PAGE_THEME_COLOR, + }, + }, + // @ts-ignore + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: colors.ERROR_BACKGROUND_COLOR, + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: colors.ERROR_TEXT_COLOR, + infoText: colors.INFO_TEXT_COLOR, + warningText: colors.NAMED_WHITE, + linkHover: colors.LINK_TEXT_HOVER, + link: colors.LINK_TEXT, + gold: yellow.A700, + sidebar: colors.SIDEBAR_BACKGROUND_COLOR, }, - border: '#E6E6E6', - textVerySubtle: '#DDD', - textSubtle: '#6E6E6E', - highlight: '#FFFBCC', - errorBackground: COLORS.ERROR_BACKGROUND_COLOR, - warningBackground: '#F59B23', - infoBackground: '#ebf5ff', - errorText: COLORS.ERROR_TEXT_COLOR, - infoText: COLORS.INFO_TEXT_COLOR, - warningText: COLORS.NAMED.WHITE, - linkHover: COLORS.LINK_TEXT_HOVER, - link: COLORS.LINK_TEXT, - gold: yellow.A700, - sidebar: COLORS.SIDEBAR_BACKGROUND_COLOR, - }, - navigation: { - width: 220, - background: '#333333', - }, - typography: { - fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', - h5: { - fontWeight: 700, + navigation: { + width: 220, + background: '#333333', }, - h4: { - fontWeight: 700, - fontSize: 28, - marginBottom: 6, + typography: { + fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', + h5: { + fontWeight: 700, + }, + h4: { + fontWeight: 700, + fontSize: 28, + marginBottom: 6, + }, + h3: { + fontSize: 32, + fontWeight: 700, + marginBottom: 6, + }, + h2: { + fontSize: 40, + fontWeight: 700, + marginBottom: 8, + }, + h1: { + fontSize: 54, + fontWeight: 700, + marginBottom: 10, + }, }, - h3: { - fontSize: 32, - fontWeight: 700, - marginBottom: 6, - }, - h2: { - fontSize: 40, - fontWeight: 700, - marginBottom: 8, - }, - h1: { - fontSize: 54, - fontWeight: 700, - marginBottom: 10, - }, - }, -}; + }; +} -const createOverrides = ( - theme: BackstageMuiTheme, -): Partial => { +export function createThemeOverrides(theme: BackstageTheme): Overrides { return { MuiTableRow: { // Alternating row backgrounds @@ -141,7 +133,7 @@ const createOverrides = ( // Alternating head backgrounds head: { '&:nth-of-type(odd)': { - backgroundColor: COLORS.NAMED.WHITE, + backgroundColor: theme.palette.background.paper, }, }, }, @@ -241,19 +233,17 @@ const createOverrides = ( }, }, }; -}; - -function createBackstageTheme( - ...config: BackstageMuiThemeOptions[] -): BackstageMuiTheme { - const withoutOverrides = createMuiTheme(...config) as BackstageMuiTheme; - - return { - ...withoutOverrides, - overrides: createOverrides(withoutOverrides), - }; } -const defaultTheme = createBackstageTheme(extendedThemeConfig); - -export default defaultTheme; +// Creates a Backstage MUI theme using a color scheme. +// The theme is created with the common Backstage options and component styles. +export function createTheme( + type: 'light' | 'dark', + colors: BackstageColorScheme, +): BackstageTheme { + const themeOptions = createThemeOptions(type, colors); + const baseTheme = createMuiTheme(themeOptions) as BackstageTheme; + const overrides = createThemeOverrides(baseTheme); + const theme = { ...baseTheme, overrides }; + return theme; +} diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 8aa7e30ef5..0367761c46 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -13,6 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export { default as BackstageThemeLight } from './BackstageThemeLight'; -export { default as BackstageThemeDark } from './BackstageThemeDark'; -export { default as BackstageTheme } from './BackstageTheme'; + +// TODO: backwards compatibility, remove +import { lightTheme, darkTheme } from './themes'; +export { + lightTheme as BackstageTheme, + lightTheme as BackstageThemeLight, + darkTheme as BackstageThemeDark, +}; + +export * from './themes'; +export * from './baseTheme'; diff --git a/packages/theme/src/muiComponentOverrides.ts b/packages/theme/src/muiComponentOverrides.ts deleted file mode 100644 index 50daf49e5e..0000000000 --- a/packages/theme/src/muiComponentOverrides.ts +++ /dev/null @@ -1,155 +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 { createMuiTheme } from '@material-ui/core'; -import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; - -import { BackstageMuiTheme, BackstageMuiThemeOptions } from './types'; - -const createOverrides = ( - theme: BackstageMuiTheme, -): Partial => { - return { - MuiTableRow: { - // Alternating row backgrounds - root: { - '&:nth-of-type(odd)': { - backgroundColor: theme.palette.background.default, - }, - }, - // Use pointer for hoverable rows - hover: { - '&:hover': { - cursor: 'pointer', - }, - }, - // Alternating head backgrounds - head: { - '&:nth-of-type(odd)': { - backgroundColor: theme.palette.background.paper, - }, - }, - }, - // Tables are more dense than default mui tables - MuiTableCell: { - root: { - wordBreak: 'break-word', - overflow: 'hidden', - verticalAlign: 'middle', - lineHeight: '1', - margin: 0, - padding: '8px', - borderBottom: 0, - }, - head: { - wordBreak: 'break-word', - overflow: 'hidden', - color: 'rgb(179, 179, 179)', - fontWeight: 'normal', - lineHeight: '1', - }, - }, - MuiTabs: { - // Tabs are smaller than default mui tab rows - root: { - minHeight: 24, - }, - }, - MuiTab: { - // Tabs are smaller and have a hover background - root: { - color: theme.palette.link, - minHeight: 24, - textTransform: 'initial', - '&:hover': { - color: darken(theme.palette.link, 0.3), - background: lighten(theme.palette.link, 0.95), - }, - [theme.breakpoints.up('md')]: { - minWidth: 120, - fontSize: theme.typography.pxToRem(14), - fontWeight: 500, - }, - }, - textColorPrimary: { - color: theme.palette.link, - }, - }, - MuiTableSortLabel: { - // No color change on hover, just rely on the arrow showing up instead. - root: { - color: 'inherit', - '&:hover': { - color: 'inherit', - }, - '&:focus': { - color: 'inherit', - }, - }, - // Bold font for highlighting selected column - active: { - fontWeight: 'bold', - color: 'inherit', - }, - }, - MuiListItemText: { - dense: { - // Default dense list items to adding ellipsis for really long str... - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - }, - MuiButton: { - text: { - // Text buttons have less padding by default, but we want to keep the original padding - padding: undefined, - }, - }, - MuiChip: { - root: { - // By default there's no margin, but it's usually wanted, so we add some trailing margin - marginRight: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - }, - MuiCardHeader: { - root: { - // Reduce padding between header and content - paddingBottom: 0, - }, - }, - MuiCardActions: { - root: { - // We default to putting the card actions at the end - justifyContent: 'flex-end', - }, - }, - }; -}; - -function applyComponentOverrides( - ...config: BackstageMuiThemeOptions[] -): BackstageMuiTheme { - const withoutOverrides = createMuiTheme(...config) as BackstageMuiTheme; - - return { - ...withoutOverrides, - overrides: createOverrides(withoutOverrides), - }; -} - -const defaultTheme = createBackstageTheme(extendedThemeConfig); diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index e7472463d6..1cb824bceb 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -14,40 +14,38 @@ * limitations under the License. */ -const darkColors = { - PAGE_BACKGROUND: '#282828', - DEFAULT_PAGE_THEME_COLOR: '#7C3699', - DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', - ERROR_BACKGROUND_COLOR: '#FFEBEE', - ERROR_TEXT_COLOR: '#CA001B', - INFO_TEXT_COLOR: '#004e8a', - LINK_TEXT: '#0A6EBE', - LINK_TEXT_HOVER: '#2196F3', - NAMED: { - WHITE: '#FEFEFE', - }, - STATUS: { - OK: '#1db855', - WARNING: '#f49b20', - ERROR: '#CA001B', - }, -}; +import { createTheme } from 'baseTheme'; -const lightColors = { +export const lightTheme = createTheme('light', { + TEXT_COLOR: '#000', PAGE_BACKGROUND: '#F8F8F8', DEFAULT_PAGE_THEME_COLOR: '#7C3699', DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + SIDEBAR_BACKGROUND_COLOR: '#171717', ERROR_BACKGROUND_COLOR: '#FFEBEE', ERROR_TEXT_COLOR: '#CA001B', INFO_TEXT_COLOR: '#004e8a', LINK_TEXT: '#0A6EBE', LINK_TEXT_HOVER: '#2196F3', - NAMED: { - WHITE: '#FEFEFE', - }, - STATUS: { - OK: '#1db855', - WARNING: '#f49b20', - ERROR: '#CA001B', - }, -}; + NAMED_WHITE: '#FEFEFE', + STATUS_OK: '#1db855', + STATUS_WARNING: '#f49b20', + STATUS_ERROR: '#CA001B', +}); + +export const darkTheme = createTheme('dark', { + TEXT_COLOR: '#fff', + PAGE_BACKGROUND: '#282828', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + SIDEBAR_BACKGROUND_COLOR: '#424242', + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + NAMED_WHITE: '#FEFEFE', + STATUS_OK: '#1db855', + STATUS_WARNING: '#f49b20', + STATUS_ERROR: '#CA001B', +}); diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index cbd3de4efa..cd5a6cdf0b 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -16,7 +16,24 @@ import { Theme, ThemeOptions } from '@material-ui/core'; -export type BackstageMuiPalette = Theme['palette'] & { +export type BackstageColorScheme = { + TEXT_COLOR: string; + PAGE_BACKGROUND: string; + DEFAULT_PAGE_THEME_COLOR: string; + DEFAULT_PAGE_THEME_LIGHT_COLOR: string; + SIDEBAR_BACKGROUND_COLOR: string; + ERROR_BACKGROUND_COLOR: string; + ERROR_TEXT_COLOR: string; + INFO_TEXT_COLOR: string; + LINK_TEXT: string; + LINK_TEXT_HOVER: string; + NAMED_WHITE: string; + STATUS_OK: string; + STATUS_WARNING: string; + STATUS_ERROR: string; +}; + +export type BackstagePalette = Theme['palette'] & { status: { ok: string; warning: string; @@ -48,10 +65,10 @@ export type BackstageMuiPalette = Theme['palette'] & { }; }; -export interface BackstageMuiTheme extends Theme { - palette: BackstageMuiPalette; +export interface BackstageTheme extends Theme { + palette: BackstagePalette; } -export interface BackstageMuiThemeOptions extends ThemeOptions { - palette: Partial; +export interface BackstageThemeOptions extends ThemeOptions { + palette: Partial; } From 9e605009fd4fe86588d70cec22e069bdde66f0ce Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:10:38 +0200 Subject: [PATCH 51/80] packages/theme: fix theme palette types --- packages/theme/src/types.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index cd5a6cdf0b..8f16f6bdd7 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -15,6 +15,10 @@ */ import { Theme, ThemeOptions } from '@material-ui/core'; +import { + PaletteOptions, + Palette, +} from '@material-ui/core/styles/createPalette'; export type BackstageColorScheme = { TEXT_COLOR: string; @@ -33,7 +37,7 @@ export type BackstageColorScheme = { STATUS_ERROR: string; }; -export type BackstagePalette = Theme['palette'] & { +type PaletteAdditions = { status: { ok: string; warning: string; @@ -65,10 +69,13 @@ export type BackstagePalette = Theme['palette'] & { }; }; +export type BackstagePalette = Palette & PaletteAdditions; +export type BackstagePaletteOptions = PaletteOptions & PaletteAdditions; + export interface BackstageTheme extends Theme { palette: BackstagePalette; } export interface BackstageThemeOptions extends ThemeOptions { - palette: Partial; + palette: BackstagePaletteOptions; } From dd4acb71a89c51762b220c7d6ae43dec2689f6be Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:13:24 +0200 Subject: [PATCH 52/80] packages/theme: remove invalid and ignored theme options --- packages/theme/src/baseTheme.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index f07333ef1f..998442fe10 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -42,11 +42,6 @@ export function createThemeOptions( type, background: { default: colors.PAGE_BACKGROUND, - // @ts-ignore - informational: '#60a3cb', - }, - color: { - default: colors.TEXT_COLOR, }, status: { ok: colors.STATUS_OK, @@ -63,7 +58,6 @@ export function createThemeOptions( default: colors.DEFAULT_PAGE_THEME_COLOR, }, }, - // @ts-ignore primary: { main: blue[500], }, @@ -82,10 +76,6 @@ export function createThemeOptions( gold: yellow.A700, sidebar: colors.SIDEBAR_BACKGROUND_COLOR, }, - navigation: { - width: 220, - background: '#333333', - }, typography: { fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', h5: { From 615597ee8708df9e32c16c237c3aa66f9c4abc6e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:19:37 +0200 Subject: [PATCH 53/80] packages/theme: remove color schemes and provide full palette for themes instead --- packages/theme/src/baseTheme.ts | 55 +++-------------- packages/theme/src/themes.ts | 105 +++++++++++++++++++++++--------- packages/theme/src/types.ts | 17 ------ 3 files changed, 82 insertions(+), 95 deletions(-) diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index 998442fe10..d924ae171c 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -16,20 +16,20 @@ import { createMuiTheme } from '@material-ui/core'; import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; -import { blue, yellow } from '@material-ui/core/colors'; + import { BackstageTheme, BackstageThemeOptions, - BackstageColorScheme, + BackstagePaletteOptions, } from './types'; type Overrides = Partial; export function createThemeOptions( - type: 'light' | 'dark', - colors: BackstageColorScheme, + palette: BackstagePaletteOptions, ): BackstageThemeOptions { return { + palette, props: { MuiGrid: { spacing: 2, @@ -38,44 +38,6 @@ export function createThemeOptions( color: 'primary', }, }, - palette: { - type, - background: { - default: colors.PAGE_BACKGROUND, - }, - status: { - ok: colors.STATUS_OK, - warning: colors.STATUS_WARNING, - error: colors.STATUS_ERROR, - running: '#BEBEBE', - pending: '#5BC0DE', - background: colors.NAMED_WHITE, - }, - bursts: { - fontColor: colors.NAMED_WHITE, - slackChannelText: '#ddd', - backgroundColor: { - default: colors.DEFAULT_PAGE_THEME_COLOR, - }, - }, - primary: { - main: blue[500], - }, - border: '#E6E6E6', - textVerySubtle: '#DDD', - textSubtle: '#6E6E6E', - highlight: '#FFFBCC', - errorBackground: colors.ERROR_BACKGROUND_COLOR, - warningBackground: '#F59B23', - infoBackground: '#ebf5ff', - errorText: colors.ERROR_TEXT_COLOR, - infoText: colors.INFO_TEXT_COLOR, - warningText: colors.NAMED_WHITE, - linkHover: colors.LINK_TEXT_HOVER, - link: colors.LINK_TEXT, - gold: yellow.A700, - sidebar: colors.SIDEBAR_BACKGROUND_COLOR, - }, typography: { fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', h5: { @@ -225,13 +187,10 @@ export function createThemeOverrides(theme: BackstageTheme): Overrides { }; } -// Creates a Backstage MUI theme using a color scheme. +// Creates a Backstage MUI theme using a palette. // The theme is created with the common Backstage options and component styles. -export function createTheme( - type: 'light' | 'dark', - colors: BackstageColorScheme, -): BackstageTheme { - const themeOptions = createThemeOptions(type, colors); +export function createTheme(palette: BackstagePaletteOptions): BackstageTheme { + const themeOptions = createThemeOptions(palette); const baseTheme = createMuiTheme(themeOptions) as BackstageTheme; const overrides = createThemeOverrides(baseTheme); const theme = { ...baseTheme, overrides }; diff --git a/packages/theme/src/themes.ts b/packages/theme/src/themes.ts index 1cb824bceb..eb36a37bf5 100644 --- a/packages/theme/src/themes.ts +++ b/packages/theme/src/themes.ts @@ -15,37 +15,82 @@ */ import { createTheme } from 'baseTheme'; +import { blue, yellow } from '@material-ui/core/colors'; -export const lightTheme = createTheme('light', { - TEXT_COLOR: '#000', - PAGE_BACKGROUND: '#F8F8F8', - DEFAULT_PAGE_THEME_COLOR: '#7C3699', - DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', - SIDEBAR_BACKGROUND_COLOR: '#171717', - ERROR_BACKGROUND_COLOR: '#FFEBEE', - ERROR_TEXT_COLOR: '#CA001B', - INFO_TEXT_COLOR: '#004e8a', - LINK_TEXT: '#0A6EBE', - LINK_TEXT_HOVER: '#2196F3', - NAMED_WHITE: '#FEFEFE', - STATUS_OK: '#1db855', - STATUS_WARNING: '#f49b20', - STATUS_ERROR: '#CA001B', +export const lightTheme = createTheme({ + type: 'light', + background: { + default: '#F8F8F8', + }, + status: { + ok: '#1db855', + warning: '#f49b20', + error: '#CA001B', + running: '#BEBEBE', + pending: '#5BC0DE', + background: '#FEFEFE', + }, + bursts: { + fontColor: '#FEFEFE', + slackChannelText: '#ddd', + backgroundColor: { + default: '#7C3699', + }, + }, + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: '#FFEBEE', + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: '#CA001B', + infoText: '#004e8a', + warningText: '#FEFEFE', + linkHover: '#2196F3', + link: '#0A6EBE', + gold: yellow.A700, + sidebar: '#171717', }); -export const darkTheme = createTheme('dark', { - TEXT_COLOR: '#fff', - PAGE_BACKGROUND: '#282828', - DEFAULT_PAGE_THEME_COLOR: '#7C3699', - DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', - SIDEBAR_BACKGROUND_COLOR: '#424242', - ERROR_BACKGROUND_COLOR: '#FFEBEE', - ERROR_TEXT_COLOR: '#CA001B', - INFO_TEXT_COLOR: '#004e8a', - LINK_TEXT: '#0A6EBE', - LINK_TEXT_HOVER: '#2196F3', - NAMED_WHITE: '#FEFEFE', - STATUS_OK: '#1db855', - STATUS_WARNING: '#f49b20', - STATUS_ERROR: '#CA001B', +export const darkTheme = createTheme({ + type: 'dark', + background: { + default: '#282828', + }, + status: { + ok: '#1db855', + warning: '#f49b20', + error: '#CA001B', + running: '#BEBEBE', + pending: '#5BC0DE', + background: '#FEFEFE', + }, + bursts: { + fontColor: '#FEFEFE', + slackChannelText: '#ddd', + backgroundColor: { + default: '#7C3699', + }, + }, + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: '#FFEBEE', + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: '#CA001B', + infoText: '#004e8a', + warningText: '#FEFEFE', + linkHover: '#2196F3', + link: '#0A6EBE', + gold: yellow.A700, + sidebar: '#424242', }); diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index 8f16f6bdd7..f0e43c54d1 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -20,23 +20,6 @@ import { Palette, } from '@material-ui/core/styles/createPalette'; -export type BackstageColorScheme = { - TEXT_COLOR: string; - PAGE_BACKGROUND: string; - DEFAULT_PAGE_THEME_COLOR: string; - DEFAULT_PAGE_THEME_LIGHT_COLOR: string; - SIDEBAR_BACKGROUND_COLOR: string; - ERROR_BACKGROUND_COLOR: string; - ERROR_TEXT_COLOR: string; - INFO_TEXT_COLOR: string; - LINK_TEXT: string; - LINK_TEXT_HOVER: string; - NAMED_WHITE: string; - STATUS_OK: string; - STATUS_WARNING: string; - STATUS_ERROR: string; -}; - type PaletteAdditions = { status: { ok: string; From e4f3495284d72c52319eb6626097ecf2050e7a25 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:21:15 +0200 Subject: [PATCH 54/80] packages/theme: use Overrides type from MUI --- packages/theme/src/baseTheme.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/theme/src/baseTheme.ts b/packages/theme/src/baseTheme.ts index d924ae171c..da9b3299a3 100644 --- a/packages/theme/src/baseTheme.ts +++ b/packages/theme/src/baseTheme.ts @@ -16,6 +16,7 @@ import { createMuiTheme } from '@material-ui/core'; import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; +import { Overrides } from '@material-ui/core/styles/overrides'; import { BackstageTheme, @@ -23,8 +24,6 @@ import { BackstagePaletteOptions, } from './types'; -type Overrides = Partial; - export function createThemeOptions( palette: BackstagePaletteOptions, ): BackstageThemeOptions { From d766d7aac42170760054c138b4793f0f24a3883c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:31:41 +0200 Subject: [PATCH 55/80] packages,plugins: use lightTheme/darkTheme exports from theme package --- packages/app/src/App.tsx | 16 +++++++--------- .../default-app/packages/app/src/App.tsx | 4 ++-- .../components/WelcomePage/WelcomePage.test.tsx | 4 ++-- .../ExampleComponent.test.tsx.hbs | 4 ++-- packages/storybook/.storybook/config.js | 4 ++-- .../test-utils/src/testUtils/appWrappers.tsx | 8 +++----- packages/theme/src/index.ts | 8 ++------ .../src/components/HomePage/HomePage.test.tsx | 4 ++-- .../components/WelcomePage/WelcomePage.test.tsx | 4 ++-- 9 files changed, 24 insertions(+), 32 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 7f203db196..3b0a8c894b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -20,7 +20,7 @@ import { Theme, ThemeProvider, } from '@material-ui/core'; -import { BackstageThemeLight, BackstageThemeDark } from '@backstage/theme'; +import { lightTheme, darkTheme } from '@backstage/theme'; import { createApp } from '@backstage/core'; import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; @@ -53,30 +53,28 @@ app.registerApis(apis); app.registerPlugin(...Object.values(plugins)); const AppComponent = app.build(); -type T = typeof BackstageThemeLight | typeof BackstageThemeDark; - const App: FC<{}> = () => { useStyles(); const [theme, toggleTheme] = useThemeType( localStorage.getItem('theme') || 'auto', ); - let backstageTheme: T = BackstageThemeLight; + let backstageTheme = lightTheme; switch (theme) { case 'light': - backstageTheme = BackstageThemeLight; + backstageTheme = lightTheme; break; case 'dark': - backstageTheme = BackstageThemeDark; + backstageTheme = darkTheme; break; default: if (!window.matchMedia) { - backstageTheme = BackstageThemeLight; + backstageTheme = lightTheme; break; } backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches - ? BackstageThemeDark - : BackstageThemeLight; + ? darkTheme + : lightTheme; break; } diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index ec8d8d435a..5fa952239a 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -1,6 +1,6 @@ import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core'; import { createApp } from '@backstage/core'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import * as plugins from './plugins'; @@ -31,7 +31,7 @@ const App: FC<{}> = () => { useStyles(); return ( - + diff --git a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index 6d9268fadd..27e44a3f75 100644 --- a/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/packages/cli/templates/default-app/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -2,12 +2,12 @@ import React from 'react'; import { render } from '@testing-library/react'; import WelcomePage from './WelcomePage'; import { ThemeProvider } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; describe('WelcomePage', () => { it('should render', () => { const rendered = render( - + , ); diff --git a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs index 80b97169bf..dff57e66a4 100644 --- a/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs +++ b/packages/cli/templates/default-plugin/src/components/ExampleComponent/ExampleComponent.test.tsx.hbs @@ -19,13 +19,13 @@ import { render } from '@testing-library/react'; import mockFetch from 'jest-fetch-mock'; import ExampleComponent from './ExampleComponent'; import { ThemeProvider } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; describe('ExampleComponent', () => { it('should render', () => { mockFetch.mockResponse(() => new Promise(() => {})); const rendered = render( - + , ); diff --git a/packages/storybook/.storybook/config.js b/packages/storybook/.storybook/config.js index b083bc0e9e..b9962b5bd3 100644 --- a/packages/storybook/.storybook/config.js +++ b/packages/storybook/.storybook/config.js @@ -1,10 +1,10 @@ import React from 'react'; import { addDecorator } from '@storybook/react'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; import { CssBaseline, ThemeProvider } from '@material-ui/core'; addDecorator(story => ( - + {story()} )); diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index 93f92ec537..06d8781669 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -18,7 +18,7 @@ import React, { ComponentType, ReactNode, FunctionComponent } from 'react'; import { ThemeProvider } from '@material-ui/core'; import { MemoryRouter } from 'react-router'; import { Route } from 'react-router-dom'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; export function wrapInTestApp( Component: ComponentType | ReactNode, @@ -42,12 +42,10 @@ export function wrapInThemedTestApp( component: ReactNode, initialRouterEntries: string[] = ['/'], ) { - const themed = ( - {component} - ); + const themed = {component}; return wrapInTestApp(themed, initialRouterEntries); } -export const wrapInTheme = (component: ReactNode, theme = BackstageTheme) => ( +export const wrapInTheme = (component: ReactNode, theme = lightTheme) => ( {component} ); diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 0367761c46..48cdeb5e51 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -15,12 +15,8 @@ */ // TODO: backwards compatibility, remove -import { lightTheme, darkTheme } from './themes'; -export { - lightTheme as BackstageTheme, - lightTheme as BackstageThemeLight, - darkTheme as BackstageThemeDark, -}; +import { lightTheme } from './themes'; +export { lightTheme as BackstageTheme }; export * from './themes'; export * from './baseTheme'; diff --git a/plugins/home-page/src/components/HomePage/HomePage.test.tsx b/plugins/home-page/src/components/HomePage/HomePage.test.tsx index 713f0afe1e..e7df794a00 100644 --- a/plugins/home-page/src/components/HomePage/HomePage.test.tsx +++ b/plugins/home-page/src/components/HomePage/HomePage.test.tsx @@ -18,12 +18,12 @@ import React from 'react'; import { render } from '@testing-library/react'; import HomePage from './HomePage'; import { ThemeProvider } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; describe('HomePage', () => { it('should render', () => { const rendered = render( - + , ); diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index 9b78726f0a..9cba76fddc 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import WelcomePage from './WelcomePage'; import { ThemeProvider } from '@material-ui/core'; -import { BackstageTheme } from '@backstage/theme'; +import { lightTheme } from '@backstage/theme'; import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; describe('WelcomePage', () => { @@ -28,7 +28,7 @@ describe('WelcomePage', () => { - + , From d9f34e3fffae8b2a6871799b71a45f56df68e3c1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 01:34:53 +0200 Subject: [PATCH 56/80] packages/theme: make BackstageTheme a type and update usages --- packages/core/src/components/CircleProgress.tsx | 2 +- packages/core/src/components/Status/Status.tsx | 2 +- packages/core/src/components/WarningPanel/WarningPanel.tsx | 2 +- packages/core/src/layout/Header/Header.tsx | 2 +- packages/core/src/layout/Sidebar/Bar.tsx | 2 +- packages/theme/src/index.ts | 5 +---- .../lighthouse/src/components/CategoryTrendline/index.tsx | 7 ++----- 7 files changed, 8 insertions(+), 14 deletions(-) diff --git a/packages/core/src/components/CircleProgress.tsx b/packages/core/src/components/CircleProgress.tsx index 4d9c5c3340..9290132eec 100644 --- a/packages/core/src/components/CircleProgress.tsx +++ b/packages/core/src/components/CircleProgress.tsx @@ -19,7 +19,7 @@ import { BackstageTheme } from '@backstage/theme'; import { Circle } from 'rc-progress'; import React, { FC } from 'react'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ root: { position: 'relative', lineHeight: 0, diff --git a/packages/core/src/components/Status/Status.tsx b/packages/core/src/components/Status/Status.tsx index d03dc52b6e..0d02d86fb1 100644 --- a/packages/core/src/components/Status/Status.tsx +++ b/packages/core/src/components/Status/Status.tsx @@ -19,7 +19,7 @@ import { BackstageTheme } from '@backstage/theme'; import classNames from 'classnames'; import React, { FC } from 'react'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ status: { width: 12, height: 12, diff --git a/packages/core/src/components/WarningPanel/WarningPanel.tsx b/packages/core/src/components/WarningPanel/WarningPanel.tsx index 17b9548b81..1d3b3e03db 100644 --- a/packages/core/src/components/WarningPanel/WarningPanel.tsx +++ b/packages/core/src/components/WarningPanel/WarningPanel.tsx @@ -27,7 +27,7 @@ const errorOutlineStyles = theme => ({ }); const ErrorOutlineStyled = withStyles(errorOutlineStyles)(ErrorOutline); -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ message: { display: 'flex', flexDirection: 'column', diff --git a/packages/core/src/layout/Header/Header.tsx b/packages/core/src/layout/Header/Header.tsx index 5ed929d9a0..94de84ad8c 100644 --- a/packages/core/src/layout/Header/Header.tsx +++ b/packages/core/src/layout/Header/Header.tsx @@ -23,7 +23,7 @@ import { Theme } from 'layout/Page/Page'; // import { Link } from 'shared/components'; import Waves from './Waves'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ header: { gridArea: 'pageHeader', padding: theme.spacing(3), diff --git a/packages/core/src/layout/Sidebar/Bar.tsx b/packages/core/src/layout/Sidebar/Bar.tsx index 4cf3db63ab..b32101a270 100644 --- a/packages/core/src/layout/Sidebar/Bar.tsx +++ b/packages/core/src/layout/Sidebar/Bar.tsx @@ -20,7 +20,7 @@ import React, { FC, useRef, useState } from 'react'; import { sidebarConfig, SidebarContext } from './config'; import { BackstageTheme } from '@backstage/theme'; -const useStyles = makeStyles(theme => ({ +const useStyles = makeStyles(theme => ({ root: { zIndex: 1000, position: 'relative', diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 48cdeb5e51..862f9b7755 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -14,9 +14,6 @@ * limitations under the License. */ -// TODO: backwards compatibility, remove -import { lightTheme } from './themes'; -export { lightTheme as BackstageTheme }; - export * from './themes'; export * from './baseTheme'; +export * from './types'; diff --git a/plugins/lighthouse/src/components/CategoryTrendline/index.tsx b/plugins/lighthouse/src/components/CategoryTrendline/index.tsx index a46aff52e5..e3dbd186e0 100644 --- a/plugins/lighthouse/src/components/CategoryTrendline/index.tsx +++ b/plugins/lighthouse/src/components/CategoryTrendline/index.tsx @@ -18,10 +18,7 @@ import { Sparklines, SparklinesLine, SparklinesProps } from 'react-sparklines'; import { useTheme } from '@material-ui/core'; import { BackstageTheme } from '@backstage/theme'; -function color( - data: number[], - theme: typeof BackstageTheme, -): string | undefined { +function color(data: number[], theme: BackstageTheme): string | undefined { const lastNum = data[data.length - 1]; if (!lastNum) return undefined; if (lastNum >= 0.9) return theme.palette.status.ok; @@ -30,7 +27,7 @@ function color( } const CategoryTrendline: FC = props => { - const theme = useTheme(); + const theme = useTheme(); if (!props.data) return null; return ( From e6c444b95544383f0f44bdf921062a09dac940c3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 13:13:55 +0200 Subject: [PATCH 57/80] packages/storybook: hoist storybook addons so they can be loaded from other packages --- packages/core/package.json | 1 - packages/storybook/package.json | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index a2fa3db771..609f00e2d0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,7 +46,6 @@ "@backstage/test-utils": "0.1.1-alpha.4", "@backstage/test-utils-core": "^0.1.1-alpha.4", "@backstage/theme": "^0.1.1-alpha.4", - "@storybook/addon-storysource": "^5.3.18", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 857c915357..56ac5c6264 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -9,7 +9,8 @@ }, "workspaces": { "nohoist": [ - "@storybook/**" + "@storybook/react/**", + "@storybook/addons/**" ] }, "dependencies": { From 55b767a3fab707875a2c6a01d5159a0cbc3a9306 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 12:16:12 +0200 Subject: [PATCH 58/80] Link local packages in package.json if in e2e-test --- .../cli/src/commands/create-app/createApp.ts | 37 +++++++++++++++++++ packages/cli/src/helpers/paths.ts | 2 +- .../templates/default-app/package.json.hbs | 3 -- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index b596d9ac73..475c9d7cdc 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -86,6 +86,34 @@ export async function moveApp( }); } +async function addPackageResolutions(rootDir: string, appDir: string) { + process.chdir(appDir); + + const packageFileContent = await fs.readFile('package.json', 'utf-8'); + const packageFileJson = JSON.parse(packageFileContent); + + if (packageFileJson.resolutions) { + throw new Error('package.json already contains resolutions'); + } + packageFileJson.resolutions = {}; + + const packages = ['cli', 'core', 'test-utils', 'test-utils-core', 'theme']; + + for (const pkg of packages) { + await Task.forItem('adding', `${pkg} link to package.json`, async () => { + const pkgPath = require('path').join(rootDir, 'packages', pkg); + packageFileJson.resolutions[`@backstage/${pkg}`] = `file:${pkgPath}`; + const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`; + + await fs.writeFile('package.json', newContents, 'utf-8').catch(error => { + throw new Error( + `Failed to add resolutions to package.json: ${error.message}`, + ); + }); + }); + } +} + export default async () => { const questions: Question[] = [ { @@ -126,6 +154,15 @@ export default async () => { Task.section('Moving to final location'); await moveApp(tempDir, appDir, answers.name); + // e2e testing needs special treatment + if (process.env.E2E) { + Task.section('Linking packages locally for e2e tests'); + const rootDir = process.env.CI + ? resolvePath(process.env.GITHUB_WORKSPACE!) + : resolvePath(__dirname, '..', '..', '..'); + await addPackageResolutions(rootDir, appDir); + } + Task.section('Building the app'); await buildApp(appDir); diff --git a/packages/cli/src/helpers/paths.ts b/packages/cli/src/helpers/paths.ts index 746b3f6ea8..173e77e1c5 100644 --- a/packages/cli/src/helpers/paths.ts +++ b/packages/cli/src/helpers/paths.ts @@ -53,7 +53,7 @@ export function findRootPath(topPath: string): string { try { const contents = fs.readFileSync(packagePath, 'utf8'); const data = JSON.parse(contents); - if (data.name === 'root') { + if (data.name === 'root' || data.name.includes('backstage-e2e')) { return path; } } catch (error) { diff --git a/packages/cli/templates/default-app/package.json.hbs b/packages/cli/templates/default-app/package.json.hbs index 5161e85fe0..7b5fab5221 100644 --- a/packages/cli/templates/default-app/package.json.hbs +++ b/packages/cli/templates/default-app/package.json.hbs @@ -25,8 +25,5 @@ "@backstage/cli": "^{{version}}", "lerna": "^3.20.2", "prettier": "^1.19.1" - }, - "resolutions": { - "@backstage/cli": "file:/home/runner/work/backstage/backstage/packages/cli" } } From c8f8759379ed9710bc4ffd64bda6b5767769108c Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 14:41:53 +0200 Subject: [PATCH 59/80] Try different approach to change dir in final step --- .github/workflows/cli.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 916caf3bfe..8adc4efdad 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -58,8 +58,9 @@ jobs: node ${{ github.workspace }}/scripts/cli-e2e-test.js # This should lint and test both an app and a plugin - name: yarn lint, test after creation - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} run: | + cd test-app yarn lint:all yarn test:all env: From 3eca6cc9d5cf83e4e8104a6f8738031b49327362 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 15:00:21 +0200 Subject: [PATCH 60/80] Change name of env variable used in script --- packages/cli/bin/backstage-cli | 5 +---- packages/cli/src/commands/build-cache/index.ts | 2 +- packages/cli/src/commands/create-app/createApp.ts | 2 +- scripts/cli-e2e-test.js | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli index 1cdf8c81af..5288f913d9 100755 --- a/packages/cli/bin/backstage-cli +++ b/packages/cli/bin/backstage-cli @@ -20,10 +20,7 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); -// This is used for e2e-tests where we create a new app in a tmp folder -const isTemp = path.resolve(__dirname).includes(require('os').tmpdir()); - -if (!isLocal || isTemp || process.env.E2E) { +if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { // src-relative imports are a pain to get to work with plain tsc compilation, as the // transpiled code will maintain the imports as they are in the source. Which means an // import for `helpers/paths` will start like that in the output, which won't work in NodeJS. diff --git a/packages/cli/src/commands/build-cache/index.ts b/packages/cli/src/commands/build-cache/index.ts index 368fac2593..a825a7f415 100644 --- a/packages/cli/src/commands/build-cache/index.ts +++ b/packages/cli/src/commands/build-cache/index.ts @@ -30,7 +30,7 @@ export async function withCache( buildFunc: () => Promise, ): Promise { const key = await Cache.readInputKey(options.inputs); - if (!key || process.env.E2E) { + if (!key || process.env.BACKSTAGE_E2E_CLI_TEST) { print('input directory is dirty, skipping cache'); await fs.remove(options.output); await buildFunc(); diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index 475c9d7cdc..219406704d 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -155,7 +155,7 @@ export default async () => { await moveApp(tempDir, appDir, answers.name); // e2e testing needs special treatment - if (process.env.E2E) { + if (process.env.BACKSTAGE_E2E_CLI_TEST) { Task.section('Linking packages locally for e2e tests'); const rootDir = process.env.CI ? resolvePath(process.env.GITHUB_WORKSPACE!) diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 934585bee5..d67154537e 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -32,7 +32,7 @@ const generateTempDir = require('./generateTempDir.js'); Browser.localhost('localhost', 3000); async function main() { - process.env.E2E = 'true'; + process.env.BACKSTAGE_E2E_CLI_TEST = 'true'; const rootDir = process.env.CI ? resolvePath(process.env.GITHUB_WORKSPACE) From e762e953687e56c14caaab13bab4d5378080fcf1 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 15:20:20 +0200 Subject: [PATCH 61/80] Modify envs in cli workflow --- .github/workflows/cli.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 8adc4efdad..972087ffff 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -20,6 +20,7 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 + BACKSTAGE_E2E_CLI_TEST: true name: Node ${{ matrix.node-version }} on ${{ matrix.os }} steps: @@ -63,5 +64,3 @@ jobs: cd test-app yarn lint:all yarn test:all - env: - CI: true From 99986cfc1116475d2b933b8f2d131761a266c1a4 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 15:23:38 +0200 Subject: [PATCH 62/80] Fix path for cli cmd and add debug logs --- scripts/cli-e2e-test.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index d67154537e..4af26e2e41 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -40,14 +40,24 @@ async function main() { const tempDir = process.env.CI ? process.cwd() : await generateTempDir(); + process.stdout.write(`Initial directory: ${process.cwd()}\n`); process.chdir(tempDir); + process.stdout.write(`Temp directory: ${process.cwd()}\n`); + await waitForExit(spawnPiped(['yarn', 'init --yes'])); - const createAppCmd = `${rootDir}/packages/cli/bin/backstage-cli create-app`; - await createTestApp(createAppCmd); + const createCmdPath = require('path').join( + rootDir, + 'packages', + 'cli', + 'bin', + 'backstage-cli', + ); + await createTestApp(`${createCmdPath} create-app`); const appDir = resolvePath(tempDir, 'test-app'); process.chdir(appDir); + process.stdout.write(`App directory: ${appDir}\n`); await createTestPlugin(); From 99c2c5879e0b540f91e82464df0cd96b743d3b79 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 15:31:14 +0200 Subject: [PATCH 63/80] Export helper methods differently --- scripts/helpers.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/scripts/helpers.js b/scripts/helpers.js index 7ad1bedfd5..7e34f10a63 100644 --- a/scripts/helpers.js +++ b/scripts/helpers.js @@ -129,9 +129,11 @@ function print(msg) { return process.stdout.write(`${msg}\n`); } -module.exports.spawnPiped = spawnPiped; -module.exports.handleError = handleError; -module.exports.waitFor = waitFor; -module.exports.waitForExit = waitForExit; -module.exports.waitForPageWithText = waitForPageWithText; -module.exports.print = print; +module.exports = { + spawnPiped, + handleError, + waitFor, + waitForExit, + waitForPageWithText, + print, +}; From 60d6392e4183843ad773fedbe90f2baa3e3aa0a4 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 15:43:54 +0200 Subject: [PATCH 64/80] Add back isTemp check --- packages/cli/bin/backstage-cli | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli index 5288f913d9..385911896c 100755 --- a/packages/cli/bin/backstage-cli +++ b/packages/cli/bin/backstage-cli @@ -20,7 +20,10 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); -if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { +// This is used for e2e-tests where we create a new app in a tmp folder +const isTemp = path.resolve(__dirname).includes(require('os').tmpdir()); + +if (!isLocal || isTemp || process.env.BACKSTAGE_E2E_CLI_TEST) { // src-relative imports are a pain to get to work with plain tsc compilation, as the // transpiled code will maintain the imports as they are in the source. Which means an // import for `helpers/paths` will start like that in the output, which won't work in NodeJS. From fb42fd490b2f9ce8748413aa79e28c4ee313ce36 Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Thu, 16 Apr 2020 15:49:18 +0200 Subject: [PATCH 65/80] wip: trying different things --- .github/workflows/cli.yml | 9 +++++++-- packages/cli/bin/backstage-cli | 5 +---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 972087ffff..dad8437613 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -20,7 +20,6 @@ jobs: env: CI: true NODE_OPTIONS: --max-old-space-size=4096 - BACKSTAGE_E2E_CLI_TEST: true name: Node ${{ matrix.node-version }} on ${{ matrix.os }} steps: @@ -45,18 +44,22 @@ jobs: # generate temp directory - name: generate tempdir id: generate_tempdir - run: echo ::set-output name=tempdir::$(node scripts/generateTempDir.js) + run: echo "::set-output name=tempdir::$(node scripts/generateTempDir.js)" # This creates a new app and plugin which pollutes the workspace, so it should be run last. - name: verify app and plugin creation on Windows working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} if: runner.os == 'Windows' run: node ${{ github.workspace }}/scripts/cli-e2e-test.js + env: + BACKSTAGE_E2E_CLI_TEST: true - name: verify app and plugin creation on Linux working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 node ${{ github.workspace }}/scripts/cli-e2e-test.js + env: + BACKSTAGE_E2E_CLI_TEST: true # This should lint and test both an app and a plugin - name: yarn lint, test after creation working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} @@ -64,3 +67,5 @@ jobs: cd test-app yarn lint:all yarn test:all + env: + BACKSTAGE_E2E_CLI_TEST: true diff --git a/packages/cli/bin/backstage-cli b/packages/cli/bin/backstage-cli index 385911896c..5288f913d9 100755 --- a/packages/cli/bin/backstage-cli +++ b/packages/cli/bin/backstage-cli @@ -20,10 +20,7 @@ const path = require('path'); // Figure out whether we're running inside the backstage repo or as an installed dependency const isLocal = require('fs').existsSync(path.resolve(__dirname, '../src')); -// This is used for e2e-tests where we create a new app in a tmp folder -const isTemp = path.resolve(__dirname).includes(require('os').tmpdir()); - -if (!isLocal || isTemp || process.env.BACKSTAGE_E2E_CLI_TEST) { +if (!isLocal || process.env.BACKSTAGE_E2E_CLI_TEST) { // src-relative imports are a pain to get to work with plain tsc compilation, as the // transpiled code will maintain the imports as they are in the source. Which means an // import for `helpers/paths` will start like that in the output, which won't work in NodeJS. From 372200bb1950c9dfafb86868aada7727b715672e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 16:49:53 +0200 Subject: [PATCH 66/80] github/workflows: split final cli lint and test task --- .github/workflows/cli.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index dad8437613..8ab722f29b 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -60,12 +60,17 @@ jobs: node ${{ github.workspace }}/scripts/cli-e2e-test.js env: BACKSTAGE_E2E_CLI_TEST: true - # This should lint and test both an app and a plugin - - name: yarn lint, test after creation + - name: lint newly created app and plugin working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} run: | cd test-app yarn lint:all + env: + BACKSTAGE_E2E_CLI_TEST: true + - name: test newly created app and plugin + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} + run: | + cd test-app yarn test:all env: BACKSTAGE_E2E_CLI_TEST: true From bdafffb2371ad12873c016df845eb2fdd2ea4034 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 16:52:47 +0200 Subject: [PATCH 67/80] packages/cli: no special handling of cli e2e tests wrt caching --- packages/cli/src/commands/build-cache/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands/build-cache/index.ts b/packages/cli/src/commands/build-cache/index.ts index a825a7f415..a8a1a27e9d 100644 --- a/packages/cli/src/commands/build-cache/index.ts +++ b/packages/cli/src/commands/build-cache/index.ts @@ -30,7 +30,7 @@ export async function withCache( buildFunc: () => Promise, ): Promise { const key = await Cache.readInputKey(options.inputs); - if (!key || process.env.BACKSTAGE_E2E_CLI_TEST) { + if (!key) { print('input directory is dirty, skipping cache'); await fs.remove(options.output); await buildFunc(); From fe0b3e5b10e1414eee2013a5bc310b80d19a7ae2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 17:12:22 +0200 Subject: [PATCH 68/80] packages/cli: added new ownRootDir to paths and use for create-app e2e override --- .../cli/src/commands/create-app/createApp.ts | 21 +++++-------- packages/cli/src/helpers/paths.ts | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/commands/create-app/createApp.ts b/packages/cli/src/commands/create-app/createApp.ts index 219406704d..cb34dc0686 100644 --- a/packages/cli/src/commands/create-app/createApp.ts +++ b/packages/cli/src/commands/create-app/createApp.ts @@ -86,26 +86,22 @@ export async function moveApp( }); } -async function addPackageResolutions(rootDir: string, appDir: string) { - process.chdir(appDir); - - const packageFileContent = await fs.readFile('package.json', 'utf-8'); +async function addPackageResolutions(appDir: string) { + const pkgJsonPath = resolvePath(appDir, 'package.json'); + const packageFileContent = await fs.readFile(pkgJsonPath, 'utf-8'); const packageFileJson = JSON.parse(packageFileContent); - if (packageFileJson.resolutions) { - throw new Error('package.json already contains resolutions'); - } - packageFileJson.resolutions = {}; + packageFileJson.resolutions = packageFileJson.resolutions || {}; const packages = ['cli', 'core', 'test-utils', 'test-utils-core', 'theme']; for (const pkg of packages) { await Task.forItem('adding', `${pkg} link to package.json`, async () => { - const pkgPath = require('path').join(rootDir, 'packages', pkg); + const pkgPath = paths.resolveOwnRoot('packages', pkg); packageFileJson.resolutions[`@backstage/${pkg}`] = `file:${pkgPath}`; const newContents = `${JSON.stringify(packageFileJson, null, 2)}\n`; - await fs.writeFile('package.json', newContents, 'utf-8').catch(error => { + await fs.writeFile(pkgJsonPath, newContents, 'utf-8').catch(error => { throw new Error( `Failed to add resolutions to package.json: ${error.message}`, ); @@ -157,10 +153,7 @@ export default async () => { // e2e testing needs special treatment if (process.env.BACKSTAGE_E2E_CLI_TEST) { Task.section('Linking packages locally for e2e tests'); - const rootDir = process.env.CI - ? resolvePath(process.env.GITHUB_WORKSPACE!) - : resolvePath(__dirname, '..', '..', '..'); - await addPackageResolutions(rootDir, appDir); + await addPackageResolutions(appDir); } Task.section('Building the app'); diff --git a/packages/cli/src/helpers/paths.ts b/packages/cli/src/helpers/paths.ts index 173e77e1c5..e7b351ec16 100644 --- a/packages/cli/src/helpers/paths.ts +++ b/packages/cli/src/helpers/paths.ts @@ -25,6 +25,9 @@ export type Paths = { // Root dir of the cli itself, containing package.json ownDir: string; + // Monorepo root dir of the cli itself. Only accessible when running inside Backstage repo. + ownRoot: string; + // The location of the app that the cli is being executed in targetDir: string; @@ -34,6 +37,9 @@ export type Paths = { // Resolve a path relative to own repo resolveOwn: ResolveFunc; + // Resolve a path relative to own monorepo root. Only accessible when running inside Backstage repo. + resolveOwnRoot: ResolveFunc; + // Resolve a path relative to the app resolveTarget: ResolveFunc; @@ -91,10 +97,31 @@ export function findOwnDir() { return resolvePath(__dirname, path); } +// Finds the root of the monorepo that the cli exists in. Only accessible when running inside Backstage repo. +export function findOwnRootPath(ownDir: string) { + const isLocal = fs.pathExistsSync(resolvePath(ownDir, 'src')); + if (!isLocal) { + throw new Error( + 'Tried to access monorepo package root dir outside of Backstage repository', + ); + } + + return resolvePath(ownDir, '../..'); +} + export function findPaths(): Paths { const ownDir = findOwnDir(); const targetDir = fs.realpathSync(process.cwd()); + // Lazy load this as it will throw an error if we're not inside the Backstage repo. + let ownRoot = ''; + const getOwnRoot = () => { + if (!ownRoot) { + ownRoot = findOwnRootPath(ownDir); + } + return ownRoot; + }; + // We're not always running in a monorepo, so we lazy init this to only crash commands // that require a monorepo when we're not in one. let targetRoot = ''; @@ -107,11 +134,15 @@ export function findPaths(): Paths { return { ownDir, + get ownRoot() { + return getOwnRoot(); + }, targetDir, get targetRoot() { return getTargetRoot(); }, resolveOwn: (...paths) => resolvePath(ownDir, ...paths), + resolveOwnRoot: (...paths) => resolvePath(getOwnRoot(), ...paths), resolveTarget: (...paths) => resolvePath(targetDir, ...paths), resolveTargetRoot: (...paths) => resolvePath(getTargetRoot(), ...paths), }; From 54108354ff402b4cd25c545338bd847f0b388a7f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 17:52:08 +0200 Subject: [PATCH 69/80] github/workflows: avoid cd in final cli tests --- .github/workflows/cli.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 8ab722f29b..d7cb262088 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -61,16 +61,12 @@ jobs: env: BACKSTAGE_E2E_CLI_TEST: true - name: lint newly created app and plugin - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} - run: | - cd test-app - yarn lint:all + run: yarn lint:all + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app env: BACKSTAGE_E2E_CLI_TEST: true - name: test newly created app and plugin - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} - run: | - cd test-app - yarn test:all + run: yarn test:all + working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app env: BACKSTAGE_E2E_CLI_TEST: true From be0881ff35676b74fc3bfd4d9b26945521193b91 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 18:46:30 +0200 Subject: [PATCH 70/80] scripts/cli-e2e-test: removed yarn init --- scripts/cli-e2e-test.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 4af26e2e41..92d73bfd6a 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -44,8 +44,6 @@ async function main() { process.chdir(tempDir); process.stdout.write(`Temp directory: ${process.cwd()}\n`); - await waitForExit(spawnPiped(['yarn', 'init --yes'])); - const createCmdPath = require('path').join( rootDir, 'packages', From 7a320302c10562bc60ffede310c26103145313a1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 18:54:50 +0200 Subject: [PATCH 71/80] scripts/cli-e2e-test: refactor path handling a bit --- scripts/cli-e2e-test.js | 13 +------------ scripts/createTestApp.js | 7 +++++-- scripts/generateTempDir.js | 7 ++++--- 3 files changed, 10 insertions(+), 17 deletions(-) diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index 92d73bfd6a..c73e812985 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -34,24 +34,13 @@ Browser.localhost('localhost', 3000); async function main() { process.env.BACKSTAGE_E2E_CLI_TEST = 'true'; - const rootDir = process.env.CI - ? resolvePath(process.env.GITHUB_WORKSPACE) - : resolvePath(__dirname, '..'); - const tempDir = process.env.CI ? process.cwd() : await generateTempDir(); process.stdout.write(`Initial directory: ${process.cwd()}\n`); process.chdir(tempDir); process.stdout.write(`Temp directory: ${process.cwd()}\n`); - const createCmdPath = require('path').join( - rootDir, - 'packages', - 'cli', - 'bin', - 'backstage-cli', - ); - await createTestApp(`${createCmdPath} create-app`); + await createTestApp(); const appDir = resolvePath(tempDir, 'test-app'); process.chdir(appDir); diff --git a/scripts/createTestApp.js b/scripts/createTestApp.js index 80f7bf3b18..88b9d772f1 100644 --- a/scripts/createTestApp.js +++ b/scripts/createTestApp.js @@ -14,11 +14,14 @@ * limitations under the License. */ +const { resolve: resolvePath } = require('path'); const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); -async function createTestApp(cmd) { +async function createTestApp() { + const cliPath = resolvePath(__dirname, '../packages/cli/bin/backstage-cli'); + print('Creating a Backstage App'); - const createApp = spawnPiped(['node', cmd]); + const createApp = spawnPiped(['node', cliPath, 'create-app']); try { let stdout = ''; diff --git a/scripts/generateTempDir.js b/scripts/generateTempDir.js index e455a29320..593c6eb74a 100644 --- a/scripts/generateTempDir.js +++ b/scripts/generateTempDir.js @@ -14,12 +14,13 @@ * limitations under the License. */ +const fs = require('fs-extra'); +const os = require('os'); +const { resolve: resolvePath } = require('path'); const { handleError } = require('./helpers'); async function generateTempDir() { - const tempDir = await require('fs-extra').mkdtemp( - require('path').join(require('os').tmpdir(), 'backstage-e2e-'), - ); + const tempDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); process.stdout.write(tempDir); return tempDir; } From e522989249388eddcf9b891a14b8d98050e9fab4 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 19:06:11 +0200 Subject: [PATCH 72/80] github/workflows,scripts/cli-e2e-test: use runner temp dir instead of generated dir --- .github/workflows/cli.yml | 13 ++++--------- scripts/cli-e2e-test.js | 15 ++++++++++----- scripts/generateTempDir.js | 31 ------------------------------- 3 files changed, 14 insertions(+), 45 deletions(-) delete mode 100644 scripts/generateTempDir.js diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index d7cb262088..dea02ea206 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -41,19 +41,14 @@ jobs: - name: yarn install run: yarn install --frozen-lockfile - run: yarn build - # generate temp directory - - name: generate tempdir - id: generate_tempdir - run: echo "::set-output name=tempdir::$(node scripts/generateTempDir.js)" - # This creates a new app and plugin which pollutes the workspace, so it should be run last. - name: verify app and plugin creation on Windows - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} + working-directory: ${{ runner.temp }} if: runner.os == 'Windows' run: node ${{ github.workspace }}/scripts/cli-e2e-test.js env: BACKSTAGE_E2E_CLI_TEST: true - name: verify app and plugin creation on Linux - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }} + working-directory: ${{ runner.temp }} if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 @@ -62,11 +57,11 @@ jobs: BACKSTAGE_E2E_CLI_TEST: true - name: lint newly created app and plugin run: yarn lint:all - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app + working-directory: ${{ runner.temp }}/test-app env: BACKSTAGE_E2E_CLI_TEST: true - name: test newly created app and plugin run: yarn test:all - working-directory: ${{ steps.generate_tempdir.outputs.tempdir }}/test-app + working-directory: ${{ runner.temp }}/test-app env: BACKSTAGE_E2E_CLI_TEST: true diff --git a/scripts/cli-e2e-test.js b/scripts/cli-e2e-test.js index c73e812985..c8374af0eb 100644 --- a/scripts/cli-e2e-test.js +++ b/scripts/cli-e2e-test.js @@ -14,6 +14,8 @@ * limitations under the License. */ +const os = require('os'); +const fs = require('fs-extra'); const { resolve: resolvePath } = require('path'); const Browser = require('zombie'); @@ -27,22 +29,25 @@ const { const createTestApp = require('./createTestApp'); const createTestPlugin = require('./createTestPlugin'); -const generateTempDir = require('./generateTempDir.js'); Browser.localhost('localhost', 3000); +async function createTempDir() { + return fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); +} + async function main() { process.env.BACKSTAGE_E2E_CLI_TEST = 'true'; - const tempDir = process.env.CI ? process.cwd() : await generateTempDir(); + const workDir = process.env.CI ? process.cwd() : await createTempDir(); process.stdout.write(`Initial directory: ${process.cwd()}\n`); - process.chdir(tempDir); - process.stdout.write(`Temp directory: ${process.cwd()}\n`); + process.chdir(workDir); + process.stdout.write(`Working directory: ${process.cwd()}\n`); await createTestApp(); - const appDir = resolvePath(tempDir, 'test-app'); + const appDir = resolvePath(workDir, 'test-app'); process.chdir(appDir); process.stdout.write(`App directory: ${appDir}\n`); diff --git a/scripts/generateTempDir.js b/scripts/generateTempDir.js deleted file mode 100644 index 593c6eb74a..0000000000 --- a/scripts/generateTempDir.js +++ /dev/null @@ -1,31 +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. - */ - -const fs = require('fs-extra'); -const os = require('os'); -const { resolve: resolvePath } = require('path'); -const { handleError } = require('./helpers'); - -async function generateTempDir() { - const tempDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-e2e-')); - process.stdout.write(tempDir); - return tempDir; -} - -module.exports = generateTempDir; - -process.on('unhandledRejection', handleError); -generateTempDir().catch(handleError); From d70337eb53dab9c8d2428100f88ef7915face173 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 16 Apr 2020 19:38:01 +0200 Subject: [PATCH 73/80] scripts: move cli-e2e-test to packages/cli/e2e-test --- .github/workflows/cli.yml | 4 +-- package.json | 3 +- packages/cli/e2e-test/.eslintrc.js | 29 +++++++++++++++++++ .../cli/e2e-test}/cli-e2e-test.js | 0 .../cli/e2e-test}/createTestApp.js | 2 +- .../cli/e2e-test}/createTestPlugin.js | 0 {scripts => packages/cli/e2e-test}/helpers.js | 0 packages/cli/package.json | 4 ++- 8 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 packages/cli/e2e-test/.eslintrc.js rename {scripts => packages/cli/e2e-test}/cli-e2e-test.js (100%) rename {scripts => packages/cli/e2e-test}/createTestApp.js (94%) rename {scripts => packages/cli/e2e-test}/createTestPlugin.js (100%) rename {scripts => packages/cli/e2e-test}/helpers.js (100%) diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index dea02ea206..612375ca1c 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -44,7 +44,7 @@ jobs: - name: verify app and plugin creation on Windows working-directory: ${{ runner.temp }} if: runner.os == 'Windows' - run: node ${{ github.workspace }}/scripts/cli-e2e-test.js + run: node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js env: BACKSTAGE_E2E_CLI_TEST: true - name: verify app and plugin creation on Linux @@ -52,7 +52,7 @@ jobs: if: runner.os == 'Linux' run: | sudo sysctl fs.inotify.max_user_watches=524288 - node ${{ github.workspace }}/scripts/cli-e2e-test.js + node ${{ github.workspace }}/packages/cli/e2e-test/cli-e2e-test.js env: BACKSTAGE_E2E_CLI_TEST: true - name: lint newly created app and plugin diff --git a/package.json b/package.json index 10a5daa4c1..f04c982621 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,7 @@ "lerna": "^3.20.2", "lint-staged": "^10.1.0", "prettier": "^1.19.1", - "typescript": "^3.7.5", - "zombie": "^6.1.4" + "typescript": "^3.7.5" }, "dependencies": { "@types/classnames": "^2.2.9", diff --git a/packages/cli/e2e-test/.eslintrc.js b/packages/cli/e2e-test/.eslintrc.js new file mode 100644 index 0000000000..274c7426b8 --- /dev/null +++ b/packages/cli/e2e-test/.eslintrc.js @@ -0,0 +1,29 @@ +/* + * 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 = { + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: true, + optionalDependencies: true, + peerDependencies: true, + bundledDependencies: true, + }, + ], + }, +}; diff --git a/scripts/cli-e2e-test.js b/packages/cli/e2e-test/cli-e2e-test.js similarity index 100% rename from scripts/cli-e2e-test.js rename to packages/cli/e2e-test/cli-e2e-test.js diff --git a/scripts/createTestApp.js b/packages/cli/e2e-test/createTestApp.js similarity index 94% rename from scripts/createTestApp.js rename to packages/cli/e2e-test/createTestApp.js index 88b9d772f1..5437804975 100644 --- a/scripts/createTestApp.js +++ b/packages/cli/e2e-test/createTestApp.js @@ -18,7 +18,7 @@ const { resolve: resolvePath } = require('path'); const { spawnPiped, waitFor, waitForExit, print } = require('./helpers'); async function createTestApp() { - const cliPath = resolvePath(__dirname, '../packages/cli/bin/backstage-cli'); + const cliPath = resolvePath(__dirname, '../bin/backstage-cli'); print('Creating a Backstage App'); const createApp = spawnPiped(['node', cliPath, 'create-app']); diff --git a/scripts/createTestPlugin.js b/packages/cli/e2e-test/createTestPlugin.js similarity index 100% rename from scripts/createTestPlugin.js rename to packages/cli/e2e-test/createTestPlugin.js diff --git a/scripts/helpers.js b/packages/cli/e2e-test/helpers.js similarity index 100% rename from scripts/helpers.js rename to packages/cli/e2e-test/helpers.js diff --git a/packages/cli/package.json b/packages/cli/package.json index 2a2adba6a8..faf8710d14 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,6 +22,7 @@ "build": "backstage-cli build-cache -- tsc", "lint": "backstage-cli lint", "test": "backstage-cli test", + "test:e2e": "node e2e-test/cli-e2e-test.js", "clean": "backstage-cli clean", "start": "nodemon ." }, @@ -41,7 +42,8 @@ "del": "^5.1.0", "nodemon": "^2.0.2", "ts-node": "^8.6.2", - "tsconfig-paths": "^3.9.0" + "tsconfig-paths": "^3.9.0", + "zombie": "^6.1.4" }, "bin": { "backstage-cli": "bin/backstage-cli" From 6c514e1a1e0856b7d99905e9abcacb487260432c Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Thu, 16 Apr 2020 13:12:07 -0500 Subject: [PATCH 74/80] Updates test, uses temporary directory now --- .../remove-plugin/removePlugin.test.ts | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts index aac3caece9..1cd47864c6 100644 --- a/packages/cli/src/commands/remove-plugin/removePlugin.test.ts +++ b/packages/cli/src/commands/remove-plugin/removePlugin.test.ts @@ -16,8 +16,13 @@ import fse from 'fs-extra'; import path from 'path'; +import os from 'os'; import { paths } from '../../helpers/paths'; -import { addExportStatement, capitalize } from '../create-plugin/createPlugin'; +import { + addExportStatement, + capitalize, + createTemporaryPluginFolder, +} from '../create-plugin/createPlugin'; import { addCodeownersEntry } from '../create-plugin/lib/codeowners'; import { removeReferencesFromAppPackage, @@ -27,9 +32,11 @@ import { removePluginFromCodeOwners, } from './removePlugin'; +// Some constant variables const BACKSTAGE = `@backstage`; const testPluginName = 'yarn-test-package'; const testPluginPackage = `${BACKSTAGE}/plugin-${testPluginName}`; +const tempDir = path.join(os.tmpdir(), 'remove-plugin-test'); const removeEmptyLines = (file: string): string => file @@ -53,6 +60,7 @@ const createTestPackageFile = async ( ); return; }; + const createTestPluginFile = async ( testFilePath: string, pluginsFilePath: string, @@ -63,16 +71,20 @@ const createTestPluginFile = async ( .split('-') .map(name => capitalize(name)) .join(''); - const importStatement = `import { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; - const exportStatement = `export {${pluginNameCapitalized}}`; - addExportStatement(testFilePath, importStatement, exportStatement); + const exportStatement = `export { default as ${pluginNameCapitalized}} from @backstage/plugin-${testPluginName}`; + addExportStatement(testFilePath, exportStatement); }; -function mkTestDir(testDirPath: string) { +const mkTestPluginDir = (testDirPath: string) => { fse.mkdirSync(testDirPath); for (let i = 0; i < 50; i++) fse.createFileSync(path.join(testDirPath, `testFile${i}.ts`)); -} +}; + +beforeAll(() => { + // Create temporary directory for all tests + createTemporaryPluginFolder(tempDir); +}); describe('removePlugin', () => { describe('Remove Plugin Dependencies', () => { @@ -81,7 +93,7 @@ describe('removePlugin', () => { it('removes plugin references from /packages/app/package.json', async () => { // Set up test const packageFilePath = path.join(appPath, 'package.json'); - const testFilePath = path.join(appPath, 'test.json'); + const testFilePath = path.join(tempDir, 'test.json'); createTestPackageFile(testFilePath, packageFilePath); try { await removeReferencesFromAppPackage(testFilePath, testPluginName); @@ -97,7 +109,7 @@ describe('removePlugin', () => { } }); it('removes plugin exports from /packages/app/src/packacge.json', async () => { - const testFilePath = path.join(appPath, 'src', 'test.ts'); + const testFilePath = path.join(tempDir, 'test.ts'); const pluginsFilePaths = path.join(appPath, 'src', 'plugins.ts'); createTestPluginFile(testFilePath, pluginsFilePaths); try { @@ -114,7 +126,7 @@ describe('removePlugin', () => { } }); it('removes codeOwners references', async () => { - const testFilePath = path.join(githubDir, 'test'); + const testFilePath = path.join(tempDir, 'test'); const codeownersPath = path.join(githubDir, 'CODEOWNERS'); try { fse.copySync(codeownersPath, testFilePath); @@ -144,7 +156,7 @@ describe('removePlugin', () => { describe('Removes Plugin Directory', () => { it('removes plugin directory from /plugins', async () => { try { - mkTestDir(testDirPath); + mkTestPluginDir(testDirPath); expect(fse.existsSync(testDirPath)).toBeTruthy(); await removePluginDirectory(testDirPath); expect(fse.existsSync(testDirPath)).toBeFalsy(); @@ -161,7 +173,7 @@ describe('removePlugin', () => { `plugin-${testPluginName}`, ); try { - mkTestDir(testDirPath); + mkTestPluginDir(testDirPath); fse.ensureSymlinkSync(testSymLinkPath, testDirPath); await removeSymLink(testSymLinkPath); @@ -174,3 +186,8 @@ describe('removePlugin', () => { }); }); }); + +afterAll(() => { + // Remove temporary directory + fse.removeSync(tempDir); +}); From fd190f8bd542dd0ef073e1ca01c8c530d042b6ee Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Thu, 16 Apr 2020 13:14:38 -0500 Subject: [PATCH 75/80] Update Create plugin logic --- .../src/commands/create-plugin/createPlugin.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index ac200d01f1..1af5832818 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -70,14 +70,13 @@ export const capitalize = (str: string): string => export const addExportStatement = async ( file: string, - importStatement: string, exportStatement: string, ) => { const newContents = fs .readFileSync(file, 'utf8') .split('\n') .filter(Boolean) // get rid of empty lines - .concat([importStatement, exportStatement]) + .concat([exportStatement]) .concat(['']) // newline at end of file .join('\n'); @@ -122,19 +121,16 @@ export async function addPluginToApp(rootDir: string, pluginName: string) { .split('-') .map(name => capitalize(name)) .join(''); - const pluginImport = `import { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`; - const pluginExport = `export { ${pluginNameCapitalized} };`; + const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`; const pluginsFilePath = 'packages/app/src/plugins.ts'; const pluginsFile = resolvePath(rootDir, pluginsFilePath); await Task.forItem('processing', pluginsFilePath, async () => { - await addExportStatement(pluginsFile, pluginImport, pluginExport).catch( - error => { - throw new Error( - `Failed to import plugin in app: ${pluginsFile}: ${error.message}`, - ); - }, - ); + await addExportStatement(pluginsFile, pluginExport).catch(error => { + throw new Error( + `Failed to import plugin in app: ${pluginsFile}: ${error.message}`, + ); + }); }); } From 40b4ea835bbf9c422d754f394dfd46eb49aeb464 Mon Sep 17 00:00:00 2001 From: Jose Balanza Martinez Date: Thu, 16 Apr 2020 13:29:26 -0500 Subject: [PATCH 76/80] Update logic for create app --- .../commands/create-plugin/createPlugin.ts | 23 +++++++------------ 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 51f749a6ac..f1c0230e09 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -68,16 +68,12 @@ const sortObjectByKeys = (obj: { [name in string]: string }) => { const capitalize = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1); -const addExportStatement = async ( - file: string, - importStatement: string, - exportStatement: string, -) => { +const addExportStatement = async (file: string, exportStatement: string) => { const newContents = fs .readFileSync(file, 'utf8') .split('\n') .filter(Boolean) // get rid of empty lines - .concat([importStatement, exportStatement]) + .concat([exportStatement]) .concat(['']) // newline at end of file .join('\n'); @@ -122,19 +118,16 @@ export async function addPluginToApp(rootDir: string, pluginName: string) { .split('-') .map(name => capitalize(name)) .join(''); - const pluginImport = `import { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`; - const pluginExport = `export { ${pluginNameCapitalized} };`; + const pluginExport = `export { plugin as ${pluginNameCapitalized} } from '${pluginPackage}';`; const pluginsFilePath = 'packages/app/src/plugins.ts'; const pluginsFile = resolvePath(rootDir, pluginsFilePath); await Task.forItem('processing', pluginsFilePath, async () => { - await addExportStatement(pluginsFile, pluginImport, pluginExport).catch( - error => { - throw new Error( - `Failed to import plugin in app: ${pluginsFile}: ${error.message}`, - ); - }, - ); + await addExportStatement(pluginsFile, pluginExport).catch(error => { + throw new Error( + `Failed to import plugin in app: ${pluginsFile}: ${error.message}`, + ); + }); }); } From f774c115dc02e9739f1f3e5d9330eb80c072fb07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20=C3=85lund?= Date: Fri, 17 Apr 2020 08:25:20 +0200 Subject: [PATCH 77/80] Extract TrendLine from Lighthouse plugin (#532) * Extract TrendLine from Lighthouse plugin * Create TrendLine.stories.tsx * Change lifecycle names * Made story example smaller * Review comment * Update TrendLine.tsx --- packages/core/package.json | 2 + .../Lifecycle/LifecycleAlpha.stories.tsx | 2 +- .../Lifecycle/LifecycleBeta.stories.tsx | 2 +- .../TrendLine/TrendLine.stories.tsx | 43 +++++++++++++++++++ .../components/TrendLine/TrendLine.test.tsx | 23 +++------- .../src/components/TrendLine/TrendLine.tsx | 5 ++- .../core/src/components/TrendLine/index.ts | 17 ++++++++ packages/core/src/index.ts | 1 + plugins/lighthouse/package.json | 4 +- .../components/AuditList/AuditListTable.tsx | 4 +- 10 files changed, 78 insertions(+), 25 deletions(-) create mode 100644 packages/core/src/components/TrendLine/TrendLine.stories.tsx rename plugins/lighthouse/src/components/CategoryTrendline/index.test.tsx => packages/core/src/components/TrendLine/TrendLine.test.tsx (77%) rename plugins/lighthouse/src/components/CategoryTrendline/index.tsx => packages/core/src/components/TrendLine/TrendLine.tsx (92%) create mode 100644 packages/core/src/components/TrendLine/index.ts diff --git a/packages/core/package.json b/packages/core/package.json index 609f00e2d0..651ac1006c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -39,6 +39,7 @@ "react-dom": "^16.12.0", "react-helmet": "5.2.1", "react-router-dom": "^5.1.2", + "react-sparklines": "^1.7.0", "recompose": "0.30.0" }, "devDependencies": { @@ -49,6 +50,7 @@ "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", + "@types/react-sparklines": "^1.7.0", "react-router": "^5.1.2" }, "peerDependencies": { diff --git a/packages/core/src/components/Lifecycle/LifecycleAlpha.stories.tsx b/packages/core/src/components/Lifecycle/LifecycleAlpha.stories.tsx index 1b044fcd5f..072fb90a55 100644 --- a/packages/core/src/components/Lifecycle/LifecycleAlpha.stories.tsx +++ b/packages/core/src/components/Lifecycle/LifecycleAlpha.stories.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { AlphaLabel } from './Lifecycle'; export default { - title: 'Alpha Lifecycle', + title: 'Lifecycle - Alpha', component: AlphaLabel, }; diff --git a/packages/core/src/components/Lifecycle/LifecycleBeta.stories.tsx b/packages/core/src/components/Lifecycle/LifecycleBeta.stories.tsx index b74171b5ea..f8cce2a562 100644 --- a/packages/core/src/components/Lifecycle/LifecycleBeta.stories.tsx +++ b/packages/core/src/components/Lifecycle/LifecycleBeta.stories.tsx @@ -17,7 +17,7 @@ import React from 'react'; import { BetaLabel } from './Lifecycle'; export default { - title: 'Beta Lifecycle', + title: 'Lifecycle - Beta', component: BetaLabel, }; diff --git a/packages/core/src/components/TrendLine/TrendLine.stories.tsx b/packages/core/src/components/TrendLine/TrendLine.stories.tsx new file mode 100644 index 0000000000..855b14207c --- /dev/null +++ b/packages/core/src/components/TrendLine/TrendLine.stories.tsx @@ -0,0 +1,43 @@ +/* + * 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 TrendLine from '.'; + +export default { + title: 'TrendLine', + component: TrendLine, +}; + +const width = 140; + +export const Default = () => ( +
+ +
+); + +export const TrendingUp = () => ( +
+ +
+); + +export const TrendingDown = () => ( +
+ +
+); diff --git a/plugins/lighthouse/src/components/CategoryTrendline/index.test.tsx b/packages/core/src/components/TrendLine/TrendLine.test.tsx similarity index 77% rename from plugins/lighthouse/src/components/CategoryTrendline/index.test.tsx rename to packages/core/src/components/TrendLine/TrendLine.test.tsx index c45db58b9e..985e5d2b31 100644 --- a/plugins/lighthouse/src/components/CategoryTrendline/index.test.tsx +++ b/packages/core/src/components/TrendLine/TrendLine.test.tsx @@ -15,18 +15,17 @@ */ /* eslint-disable jest/no-disabled-tests */ - import React from 'react'; import { render } from '@testing-library/react'; import { wrapInThemedTestApp } from '@backstage/test-utils'; -import CategoryTrendline from '.'; +import TrendLine from '.'; -describe('CategoryTrendline', () => { +describe('TrendLine', () => { describe('when no data is present', () => { it('renders null without throwing', () => { const rendered = render( - wrapInThemedTestApp(), + wrapInThemedTestApp(), ); expect(rendered.queryByTitle('sparkline')).not.toBeInTheDocument(); }); @@ -35,9 +34,7 @@ describe('CategoryTrendline', () => { describe('when one datapoint is present', () => { it('renders as a straight line', () => { const rendered = render( - wrapInThemedTestApp( - , - ), + wrapInThemedTestApp(), ); expect(rendered.getByTitle('sparkline')).toBeInTheDocument(); }); @@ -46,9 +43,7 @@ describe('CategoryTrendline', () => { describe.skip('when the data finishes above the success threshold', () => { it('renders with the correct color', () => { const rendered = render( - wrapInThemedTestApp( - , - ), + wrapInThemedTestApp(), ); expect(rendered.getByTitle('sparkline')).toBeInTheDocument(); }); @@ -57,9 +52,7 @@ describe('CategoryTrendline', () => { describe.skip('when the data finishes within the the warning threshold', () => { it('renders with the correct color', () => { const rendered = render( - wrapInThemedTestApp( - , - ), + wrapInThemedTestApp(), ); expect(rendered.getByTitle('sparkline')).toBeInTheDocument(); }); @@ -68,9 +61,7 @@ describe('CategoryTrendline', () => { describe.skip('when the data finishes within the the error threshold', () => { it('renders with the correct color', () => { const rendered = render( - wrapInThemedTestApp( - , - ), + wrapInThemedTestApp(), ); expect(rendered.getByTitle('sparkline')).toBeInTheDocument(); }); diff --git a/plugins/lighthouse/src/components/CategoryTrendline/index.tsx b/packages/core/src/components/TrendLine/TrendLine.tsx similarity index 92% rename from plugins/lighthouse/src/components/CategoryTrendline/index.tsx rename to packages/core/src/components/TrendLine/TrendLine.tsx index e3dbd186e0..3ab9b67e53 100644 --- a/plugins/lighthouse/src/components/CategoryTrendline/index.tsx +++ b/packages/core/src/components/TrendLine/TrendLine.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React, { FC } from 'react'; import { Sparklines, SparklinesLine, SparklinesProps } from 'react-sparklines'; import { useTheme } from '@material-ui/core'; @@ -26,7 +27,7 @@ function color(data: number[], theme: BackstageTheme): string | undefined { return theme.palette.status.error; } -const CategoryTrendline: FC = props => { +const Trendline: FC = props => { const theme = useTheme(); if (!props.data) return null; @@ -38,4 +39,4 @@ const CategoryTrendline: FC = props => { ); }; -export default CategoryTrendline; +export default Trendline; diff --git a/packages/core/src/components/TrendLine/index.ts b/packages/core/src/components/TrendLine/index.ts new file mode 100644 index 0000000000..168c6e6d3f --- /dev/null +++ b/packages/core/src/components/TrendLine/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './TrendLine'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9153b9fa3b..a13146991c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -31,6 +31,7 @@ export { default as Progress } from './components/Progress'; export { AlphaLabel, BetaLabel } from './components/Lifecycle'; export { default as SupportButton } from './components/SupportButton'; export { default as SortableTable } from './components/SortableTable'; +export { default as TrendLine } from './components/TrendLine'; export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular'; export * from './components/Status'; export { default as WarningPanel } from './components/WarningPanel'; diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 442211a38c..d082b0be0f 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -13,8 +13,7 @@ "clean": "backstage-cli clean" }, "dependencies": { - "react-markdown": "^4.3.1", - "react-sparklines": "^1.7.0" + "react-markdown": "^4.3.1" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.4", @@ -29,7 +28,6 @@ "@testing-library/user-event": "^7.1.2", "@types/jest": "^24.0.0", "@types/node": "^12.0.0", - "@types/react-sparklines": "^1.7.0", "@types/testing-library__jest-dom": "5.0.2", "jest-fetch-mock": "^3.0.3", "react": "^16.13.1", diff --git a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx index 52f3eeb5e2..ba0e79f066 100644 --- a/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx +++ b/plugins/lighthouse/src/components/AuditList/AuditListTable.tsx @@ -24,6 +24,7 @@ import { TableRow, } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; +import { TrendLine } from '@backstage/core'; import { Audit, @@ -32,7 +33,6 @@ import { Website, } from '../../api'; import { formatTime } from '../../utils'; -import CategoryTrendline from '../CategoryTrendline'; import AuditStatusIcon from '../AuditStatusIcon'; export const CATEGORIES: LighthouseCategoryId[] = [ @@ -132,7 +132,7 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => { key={`${website.url}|${category}`} className={classes.sparklinesCell} > - From eb76a4d05a1e31659794835a893d5208ca7a86c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20=C3=85lund?= Date: Fri, 17 Apr 2020 10:53:11 +0200 Subject: [PATCH 78/80] Add CONTRIBUTING (#572) * Add CONTRIBUTING * Update CONTRIBUTING.md * Fixed review comments * Typo --- CONTRIBUTING.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000..68fb208caa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing + +Our vision for Backstage is for it to become the trusted standard toolbox (read: UX layer) for the open source infrastructure landscape. Think of it like Kubernetes for developer experience. We realize this is an ambitious goal. We can’t do it alone. + +Therefore we want to create strong community of contributors -- all working together to create the kind of delightful experience that our developers deserve. + +Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. ❤️ + +# Types of Contributions + +## Report bugs + +No one likes bugs. Report bugs as an issue [here](https://github.com/spotify/backstage/issues/new?template=bug_template.md). + +## Fix bugs or build new features + +Look through the GitHub issues for [bugs](https://github.com/spotify/backstage/labels/bugs), [good first issues](https://github.com/spotify/backstage/labels/good%20first%20issue) or [help wanted](https://github.com/spotify/backstage/labels/help%20wanted). + +## Build a plugin + +The value of Backstage grows with every new plugin that gets added. Wouldn't it be fantastic if there was a plugin for every infrastructure project out there? We think so. And we would love your help. + +What kind of plugins should/could be created? Some inspiration from the 120+ plugins that we have developed inside Spotify can be found [here](https://backstage.io/demos), but we will keep a running list of suggestions labeled with [[plugin]](https://github.com/spotify/backstage/labels/plugin). + +A great reference example of a plugin can be found on [our blog](https://backstage.io/blog/2020/04/06/lighthouse-plugin) (thanks [@fastfrwrd](https://github.com/fastfrwrd)!) + +## Write Documentation + +The current documentation is very limited. Help us make the `/docs` folder come alive. + +## Contribute to Storybook + +We think the best way to ensure different plugins provide a consistent experience is through a solid set of reusable UI/UX components. Backstage uses [Storybook](http://storybook.backstage.io). + +Either help us [create new components](https://github.com/spotify/backstage/labels/help%20wanted) or improve stories for the existing ones (look for files with `*.stories.tsx`). + +## Submit Feedback + +The best way to send feedback is to file [an issue](https://github.com/spotify/backstage/issues). + +If you are proposing a feature: + +- Explain in detail how it would work. +- Keep the scope as narrow as possible, to make it easier to implement. +- Use appropriate labels +- Remember that this is a volunteer-driven project, and that contributions + are welcome :) + +# Get Started! + +So...feel ready to jump in? Let's do this. Head over to the [Getting Started guide](https://github.com/spotify/backstage#getting-started) 👏🏻💯 + +If you need help, just jump into our [Discord chatroom](https://discord.gg/MUpMjP2). + +# Code of Conduct + +This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By participating, you are expected to honor this code. + +[code-of-conduct]: https://github.com/spotify/backstage/blob/master/CODE_OF_CONDUCT.md + +# Security Issues? + +Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub. From 23802d6c58665d41d0ed5a5301f7f3fadab0eb2b Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Fri, 17 Apr 2020 11:44:36 +0200 Subject: [PATCH 79/80] Clarify creating a new plugin as the next step (#575) * Move create-app docs out from getting started * Add contributing to readme.md --- README.md | 20 ++++++++++++------ docs/{getting-started => }/create-an-app.md | 6 +----- .../create-app_output.png | Bin docs/getting-started/README.md | 5 +++-- .../development-environment.md | 2 ++ 5 files changed, 19 insertions(+), 14 deletions(-) rename docs/{getting-started => }/create-an-app.md (92%) rename docs/{getting-started => }/create-app_output.png (100%) diff --git a/README.md b/README.md index c012638e7d..70abaa7a65 100644 --- a/README.md +++ b/README.md @@ -59,23 +59,25 @@ To run a Backstage app, you will need to have the following installed: - [NodeJS](https://nodejs.org/en/download/) - Active LTS Release, currently v12 - [yarn](https://classic.yarnpkg.com/en/docs/install) -Open a terminal window and start the web app using the following commands from the project root: +After cloning this repo, open a terminal window and start the web app using the following commands from the project root: ```bash -$ yarn install # may take a while - -$ yarn start +yarn install +yarn start ``` The final `yarn start` command should open a local instance of Backstage in your browser, otherwise open one of the URLs printed in the terminal. -For more complex development environment configuration, see the -[Development Environment](docs/getting-started/development-environment.md) section of the Getting Started docs. +And thats it! You are good to go 👍 + +### Next step + +Take a look at the [Getting Started](docs/getting-started/README.md) guide to learn more about how to extend the functionality with Plugins. ## Documentation -- [FAQs](docs/FAQ.md) - [Getting Started](docs/getting-started/README.md) +- [Create a Backstage App](docs/create-an-app.md) - [Architecture](docs/architecture-terminology.md) - [API references](docs/reference/README.md) - [Designing for Backstage](docs/design.md) @@ -95,6 +97,10 @@ For more complex development environment configuration, see the Or, if you are an open source developer and are interested in joining our team, please reach out to [foss-opportunities@spotify.com ](mailto:foss-opportunities@spotify.com) +## Contributing + +We would love your help in building Backstage! See [CONTRIBUTING](CONTRIBUTING.md) for more information. + ## License Copyright 2020 Spotify AB. diff --git a/docs/getting-started/create-an-app.md b/docs/create-an-app.md similarity index 92% rename from docs/getting-started/create-an-app.md rename to docs/create-an-app.md index 703d1d59a5..8c8050c2ff 100644 --- a/docs/getting-started/create-an-app.md +++ b/docs/create-an-app.md @@ -17,7 +17,7 @@ npx @backstage/cli create-app This will create a new Backstage App inside the current folder. The name of the app-folder is the name that was provided when prompted.

- create app + create app

Inside that directory, it will generate all the files and folder structure needed for you to run your app. @@ -70,7 +70,3 @@ yarn start ``` _When `yarn start` is ready it should open up a browser window displaying your app, if not you can navigate to `http://localhost:3000`._ - -[Next Step - Create a Backstage plugin](create-a-plugin.md) - -[Back to Docs](README.md) diff --git a/docs/getting-started/create-app_output.png b/docs/create-app_output.png similarity index 100% rename from docs/getting-started/create-app_output.png rename to docs/create-app_output.png diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 163e52b950..064776e712 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -1,9 +1,10 @@ # Getting started with Backstage -Here is a collection of tutorials that will guide you through setting up and extending an instance of Backstage with your own plugins. +## Creating a Plugin + +The value of Backstage grows with every new plugin that gets added. Here is a collection of tutorials that will guide you through setting up and extending an instance of Backstage with your own plugins. - [Development Environment](development-environment.md) -- [Create a Backstage App](create-an-app.md) - [Create a Backstage plugin](create-a-plugin.md) - [Structure of a plugin](structure-of-a-plugin.md) - Using Backstage components (TODO) diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index e4fe69af68..7f6e2bcb44 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -34,4 +34,6 @@ Then open http://localhost/ on your browser. > See [package.json](/package.json) for other yarn commands/options. +[Next Step - Create a Backstage plugin](create-a-plugin.md) + [Back to Docs](README.md) From 4c2331d4bbebcd3086e60eab25cd349bc48ababe Mon Sep 17 00:00:00 2001 From: Marcus Eide Date: Fri, 17 Apr 2020 12:12:49 +0200 Subject: [PATCH 80/80] Add version badge (#577) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 70abaa7a65..87a3de6138 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ ![](https://github.com/spotify/backstage/workflows/Frontend%20CI/badge.svg) [![Discord](https://img.shields.io/discord/687207715902193673)](https://discord.gg/EBHEGzX) ![Code style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg) +[![](https://img.shields.io/npm/v/@backstage/core?label=Version)](https://github.com/spotify/backstage/releases) ## What is Backstage?