diff --git a/.changeset/big-jeans-love.md b/.changeset/big-jeans-love.md new file mode 100644 index 0000000000..e97ae15ff4 --- /dev/null +++ b/.changeset/big-jeans-love.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The `plugin:diff` command no longer validates the existence of any of the files within `dev/` or `src/`. diff --git a/.changeset/brave-tools-drop.md b/.changeset/brave-tools-drop.md new file mode 100644 index 0000000000..02b6b7ebb1 --- /dev/null +++ b/.changeset/brave-tools-drop.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Introduced initial support for an experimental `backstage.role` field in package.json, as well as experimental and hidden `migrate` and `script` sub-commands. We do not recommend usage of any of these additions yet. diff --git a/.changeset/chilly-pans-jog.md b/.changeset/chilly-pans-jog.md new file mode 100644 index 0000000000..b63d5fde50 --- /dev/null +++ b/.changeset/chilly-pans-jog.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-todo': minor +--- + +**BREAKING**: The `EntityTodoContent` is now a routable extension. This means it must be rendered within a route, but that's most likely already the case for most apps. The mount point `RouteRef` is available via `todoPlugin.routes.entityContent`. diff --git a/.changeset/dependabot-f436b5b.md b/.changeset/dependabot-f436b5b.md new file mode 100644 index 0000000000..9dc9fbccc8 --- /dev/null +++ b/.changeset/dependabot-f436b5b.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-common': patch +--- + +chore(deps-dev): bump `@types/concat-stream` from 1.6.1 to 2.0.0 diff --git a/.changeset/neat-icons-fry.md b/.changeset/neat-icons-fry.md new file mode 100644 index 0000000000..33c4a4512c --- /dev/null +++ b/.changeset/neat-icons-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Tweaked frontend bundling configuration to avoid leaking declarations into global scope. diff --git a/.changeset/seven-apes-shave.md b/.changeset/seven-apes-shave.md new file mode 100644 index 0000000000..356fd377dd --- /dev/null +++ b/.changeset/seven-apes-shave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Updated `useEntityListProvider` and catalog pickers to respond to external changes to query parameters in the URL, such as two sidebar links that apply different catalog filters. diff --git a/.changeset/seven-teachers-arrive.md b/.changeset/seven-teachers-arrive.md new file mode 100644 index 0000000000..bdbbc33c44 --- /dev/null +++ b/.changeset/seven-teachers-arrive.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +fix for the `gitlab:publish` action to use the `oauthToken` key when creating a +`Gitlab` client. This only happens if `ctx.input.token` is provided else the key `token` will be used. diff --git a/.changeset/three-pigs-sniff.md b/.changeset/three-pigs-sniff.md new file mode 100644 index 0000000000..c2020c9e80 --- /dev/null +++ b/.changeset/three-pigs-sniff.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +The `ErrorPage` now falls back to using the default support configuration if the `ConfigApi` is not available. diff --git a/.changeset/wise-peaches-flow.md b/.changeset/wise-peaches-flow.md new file mode 100644 index 0000000000..68e89cf841 --- /dev/null +++ b/.changeset/wise-peaches-flow.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': patch +'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-catalog-import': patch +'@backstage/plugin-catalog-react': patch +--- + +Minor API cleanups diff --git a/.changeset/wise-plants-tease.md b/.changeset/wise-plants-tease.md new file mode 100644 index 0000000000..206704435d --- /dev/null +++ b/.changeset/wise-plants-tease.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Adds new StarredEntities component responsible for rendering a list of starred entities on the home page diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 08057d5af6..eaba7ef3e1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,42 +5,49 @@ # https://help.github.com/articles/about-codeowners/ * @backstage/reviewers -/docs/features/techdocs @backstage/techdocs-core -/docs/features/search @backstage/techdocs-core -/docs/assets/search @backstage/techdocs-core -/plugins/allure @backstage/reviewers @deepak-bhardwaj-ps -/plugins/circleci @backstage/reviewers @adamdmharvey -/plugins/code-coverage @backstage/reviewers @alde @nissayeva -/plugins/code-coverage-backend @backstage/reviewers @alde @nissayeva -/plugins/cost-insights @backstage/silver-lining -/plugins/cloudbuild @backstage/reviewers @trivago/ebarrios -/plugins/search @backstage/techdocs-core -/plugins/search-* @backstage/techdocs-core -/plugins/techdocs @backstage/techdocs-core -/plugins/techdocs-backend @backstage/techdocs-core -/plugins/ilert @backstage/reviewers @yacut -/plugins/home @backstage/techdocs-core -/plugins/azure-devops @backstage/reviewers @marleypowell @awanlin -/plugins/azure-devops-backend @backstage/reviewers @marleypowell @awanlin -/plugins/azure-devops-common @backstage/reviewers @marleypowell @awanlin -/plugins/jenkins @backstage/reviewers @timja -/plugins/jenkins-backend @backstage/reviewers @timja -/plugins/kafka @backstage/reviewers @nirga -/plugins/kafka-backend @backstage/reviewers @nirga -/plugins/scaffolder-backend-module-yeoman @backstage/reviewers @pawelmitka -/plugins/scaffolder-backend-module-rails @backstage/reviewers @angeliski -/plugins/git-release-manager @backstage/reviewers @erikengervall -/tech-insights-backend @backstage/reviewers @xantier @iain-b -/tech-insights-backend-module-jsonfc @backstage/reviewers @xantier @iain-b -/tech-insights-tech-insights-common @backstage/reviewers @xantier @iain-b -/tech-insights-tech-insights-node @backstage/reviewers @xantier @iain-b -/packages/search-common @backstage/techdocs-core -/packages/techdocs-cli @backstage/techdocs-core -/packages/techdocs-cli-embedded-app @backstage/techdocs-core -/packages/techdocs-common @backstage/techdocs-core /.changeset/cost-insights-* @backstage/reviewers @backstage/silver-lining /.changeset/search-* @backstage/techdocs-core /.changeset/techdocs-* @backstage/techdocs-core /cypress/src/integration/plugins/techdocs.spec.ts @backstage/techdocs-core +/docs/assets/search @backstage/techdocs-core +/docs/features/search @backstage/techdocs-core +/docs/features/techdocs @backstage/techdocs-core +/packages/search-common @backstage/techdocs-core +/packages/techdocs-cli @backstage/techdocs-core +/packages/techdocs-cli-embedded-app @backstage/techdocs-core +/packages/techdocs-common @backstage/techdocs-core +/plugins/allure @backstage/reviewers @deepak-bhardwaj-ps /plugins/apache-airflow @backstage/reviewers @cmpadden +/plugins/api-docs @backstage/reviewers @backstage/sda-se-reviewers +/plugins/azure-devops @backstage/reviewers @marleypowell @awanlin +/plugins/azure-devops-backend @backstage/reviewers @marleypowell @awanlin +/plugins/azure-devops-common @backstage/reviewers @marleypowell @awanlin +/plugins/bitrise @backstage/reviewers @backstage/sda-se-reviewers +/plugins/catalog-graph @backstage/reviewers @backstage/sda-se-reviewers +/plugins/circleci @backstage/reviewers @adamdmharvey +/plugins/cloudbuild @backstage/reviewers @trivago/ebarrios +/plugins/code-coverage @backstage/reviewers @alde @nissayeva +/plugins/code-coverage-backend @backstage/reviewers @alde @nissayeva +/plugins/cost-insights @backstage/silver-lining +/plugins/explore @backstage/reviewers @backstage/sda-se-reviewers +/plugins/explore-react @backstage/reviewers @backstage/sda-se-reviewers +/plugins/fossa @backstage/reviewers @backstage/sda-se-reviewers +/plugins/git-release-manager @backstage/reviewers @erikengervall +/plugins/home @backstage/techdocs-core +/plugins/ilert @backstage/reviewers @yacut +/plugins/jenkins @backstage/reviewers @timja +/plugins/jenkins-backend @backstage/reviewers @timja +/plugins/kafka @backstage/reviewers @nirga +/plugins/kafka-backend @backstage/reviewers @nirga /plugins/newrelic-dashboard @backstage/reviewers @mufaddal7 +/plugins/scaffolder-backend-module-rails @backstage/reviewers @angeliski +/plugins/scaffolder-backend-module-yeoman @backstage/reviewers @pawelmitka +/plugins/search @backstage/techdocs-core +/plugins/search-* @backstage/techdocs-core +/plugins/sonarqube @backstage/reviewers @backstage/sda-se-reviewers +/plugins/techdocs @backstage/techdocs-core +/plugins/techdocs-backend @backstage/techdocs-core +/tech-insights-backend @backstage/reviewers @xantier @iain-b +/tech-insights-backend-module-jsonfc @backstage/reviewers @xantier @iain-b +/tech-insights-tech-insights-common @backstage/reviewers @xantier @iain-b +/tech-insights-tech-insights-node @backstage/reviewers @xantier @iain-b diff --git a/ADOPTERS.md b/ADOPTERS.md index 97bfbc255b..58ee92e094 100644 --- a/ADOPTERS.md +++ b/ADOPTERS.md @@ -89,3 +89,4 @@ | [HP Inc](https://www.hp.com) | [Damon Kaswell](https://github.com/dekoding) | DevEx engagement hub (dev portal: docs, standards, Q&A) and extensive assets catalog (APIs, services, code, data, etc.) for the pan-HP internal developer community. | | [VMware](https://www.vmware.com) | [@mpriamo](https://github.com/mpriamo), [@krisapplegate](https://github.com/krisapplegate) | Part of [Tanzu Application Platform](https://docs.vmware.com/en/VMware-Tanzu-Application-Platform/index.html) offering; internal developer portal | | [Ualá](https://www.uala.com.ar/) | [Santiago Bernal](https://github.com/sabernal) | Initial work being done to centralize documentation for all our microservices and APIs, as well as scaffolding new services and tracking code quality | +| [IKEA IT AB](https://www.ingka.com) | [@bjornramberg](https://github.com/bjornramberg), [@supriyachitale](https://github.com/supriyachitale) | Supporting engineers at scale with self serve access and connecting the dots of our engineering platform and services, enabling product teams to move faster and go further, and unleashing innovation, reuse and co-creation across the organisation. | diff --git a/package.json b/package.json index 5c60cc41c6..54841128a9 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "concurrently": "^7.0.0", "eslint-plugin-notice": "^0.9.10", "fs-extra": "9.1.0", - "husky": "^6.0.0", + "husky": "^7.0.4", "lerna": "^4.0.0", "lint-staged": "^12.2.0", "minimist": "^1.2.5", diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index fdff7ca414..b661241623 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -88,7 +88,7 @@ "@backstage/test-utils": "^0.2.4", "@types/archiver": "^5.1.0", "@types/compression": "^1.7.0", - "@types/concat-stream": "^1.6.0", + "@types/concat-stream": "^2.0.0", "@types/fs-extra": "^9.0.3", "@types/http-errors": "^1.6.3", "@types/minimist": "^1.2.0", diff --git a/packages/cli/package.json b/packages/cli/package.json index aad08a21eb..41cefe6c53 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -112,7 +112,8 @@ "webpack-node-externals": "^3.0.0", "yaml": "^1.10.0", "yml-loader": "^2.1.0", - "yn": "^4.0.0" + "yn": "^4.0.0", + "zod": "^3.11.6" }, "devDependencies": { "@backstage/backend-common": "^0.10.6", diff --git a/packages/cli/src/commands/build/buildApp.ts b/packages/cli/src/commands/build/buildApp.ts new file mode 100644 index 0000000000..3d86d796b7 --- /dev/null +++ b/packages/cli/src/commands/build/buildApp.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { buildBundle } from '../../lib/bundler'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { loadCliConfig } from '../../lib/config'; +import { paths } from '../../lib/paths'; + +interface BuildAppOptions { + writeStats: boolean; + configPaths: string[]; +} + +export async function buildApp(options: BuildAppOptions) { + const { name } = await fs.readJson(paths.resolveTarget('package.json')); + await buildBundle({ + entry: 'src/index', + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + statsJsonEnabled: options.writeStats, + ...(await loadCliConfig({ + args: options.configPaths, + fromPackage: name, + })), + }); +} diff --git a/packages/cli/src/commands/build/buildBackend.ts b/packages/cli/src/commands/build/buildBackend.ts new file mode 100644 index 0000000000..a4d8858cf8 --- /dev/null +++ b/packages/cli/src/commands/build/buildBackend.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 os from 'os'; +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import tar, { CreateOptions } from 'tar'; +import { createDistWorkspace } from '../../lib/packager'; +import { paths } from '../../lib/paths'; +import { parseParallel, PARALLEL_ENV_VAR } from '../../lib/parallel'; +import { buildPackage, Output } from '../../lib/builder'; + +const BUNDLE_FILE = 'bundle.tar.gz'; +const SKELETON_FILE = 'skeleton.tar.gz'; + +interface BuildBackendOptions { + skipBuildDependencies: boolean; +} + +export async function buildBackend(options: BuildBackendOptions) { + const targetDir = paths.resolveTarget('dist'); + const pkg = await fs.readJson(paths.resolveTarget('package.json')); + + // We build the target package without generating type declarations. + await buildPackage({ outputs: new Set([Output.cjs]) }); + + const tmpDir = await fs.mkdtemp(resolvePath(os.tmpdir(), 'backstage-bundle')); + try { + await createDistWorkspace([pkg.name], { + targetDir: tmpDir, + buildDependencies: !options.skipBuildDependencies, + buildExcludes: [pkg.name], + parallel: parseParallel(process.env[PARALLEL_ENV_VAR]), + skeleton: SKELETON_FILE, + }); + + // We built the target backend package using the regular build process, but the result of + // that has now been packed into the dist workspace, so clean up the dist dir. + await fs.remove(targetDir); + await fs.mkdir(targetDir); + + // Move out skeleton.tar.gz before we create the main bundle, no point having that included up twice. + await fs.move( + resolvePath(tmpDir, SKELETON_FILE), + resolvePath(targetDir, SKELETON_FILE), + ); + + // Create main bundle.tar.gz, with some tweaks to make it more likely hit Docker build cache. + await tar.create( + { + file: resolvePath(targetDir, BUNDLE_FILE), + cwd: tmpDir, + portable: true, + noMtime: true, + gzip: true, + } as CreateOptions & { noMtime: boolean }, + [''], + ); + } finally { + await fs.remove(tmpDir); + } +} diff --git a/packages/cli/src/commands/build/command.ts b/packages/cli/src/commands/build/command.ts new file mode 100644 index 0000000000..8c13b515d7 --- /dev/null +++ b/packages/cli/src/commands/build/command.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Command } from 'commander'; +import { buildPackage, Output } from '../../lib/builder'; +import { findRoleFromCommand, getRoleInfo } from '../../lib/role'; +import { buildApp } from './buildApp'; +import { buildBackend } from './buildBackend'; + +export async function command(cmd: Command): Promise { + const role = await findRoleFromCommand(cmd); + + if (role === 'app') { + return buildApp({ + configPaths: cmd.config as string[], + writeStats: Boolean(cmd.stats), + }); + } + if (role === 'backend') { + return buildBackend({ + skipBuildDependencies: Boolean(cmd.skipBuildDependencies), + }); + } + + const roleInfo = getRoleInfo(role); + + const outputs = new Set(); + + if (roleInfo.output.includes('cjs')) { + outputs.add(Output.cjs); + } + if (roleInfo.output.includes('esm')) { + outputs.add(Output.esm); + } + if (roleInfo.output.includes('types')) { + outputs.add(Output.types); + } + + return buildPackage({ + outputs, + minify: Boolean(cmd.minify), + useApiExtractor: Boolean(cmd.experimentalTypeBuild), + }); +} diff --git a/plugins/todo/src/plugin.test.ts b/packages/cli/src/commands/build/index.ts similarity index 74% rename from plugins/todo/src/plugin.test.ts rename to packages/cli/src/commands/build/index.ts index a99373abc6..680fe9e11d 100644 --- a/plugins/todo/src/plugin.test.ts +++ b/packages/cli/src/commands/build/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * Copyright 2022 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { todoPlugin } from './plugin'; -describe('todo', () => { - it('should export plugin', () => { - expect(todoPlugin).toBeDefined(); - }); -}); +export { command } from './command'; diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 362771f8f2..a33e7d4afe 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -18,14 +18,112 @@ import { assertError } from '@backstage/errors'; import { CommanderStatic } from 'commander'; import { exitWithError } from '../lib/errors'; -export function registerCommands(program: CommanderStatic) { - const configOption = [ - '--config ', - 'Config files to load instead of app-config.yaml', - (opt: string, opts: string[]) => [...opts, opt], - Array(), - ] as const; +const configOption = [ + '--config ', + 'Config files to load instead of app-config.yaml', + (opt: string, opts: string[]) => [...opts, opt], + Array(), +] as const; +export function registerScriptCommand(program: CommanderStatic) { + const command = program + .command('script [command]', { hidden: true }) + .description('Lifecycle scripts for Backstage packages [EXPERIMENTAL]'); + + command + .command('start') + .description('Start a package for local development') + .option(...configOption) + .option('--role ', 'Run the command with an explicit package role') + .option('--check', 'Enable type checking and linting if available') + .option('--inspect', 'Enable debugger in Node.js environments') + .option( + '--inspect-brk', + 'Enable debugger in Node.js environments, breaking before code starts', + ) + .action(lazy(() => import('./start').then(m => m.command))); + + command + .command('build') + .description('Build a package for production deployment or publishing') + .option( + '--minify', + 'Minify the generated code. Does not apply to app or backend packages.', + ) + .option( + '--experimental-type-build', + 'Enable experimental type build. Does not apply to app or backend packages.', + ) + .option( + '--skip-build-dependencies', + 'Skip the automatic building of local dependencies. Applies to backend packages only.', + ) + .option( + '--stats', + 'If bundle stats are available, write them to the output directory. Applies to app packages only.', + ) + .option( + '--config ', + 'Config files to load instead of app-config.yaml. Applies to app packages only.', + (opt: string, opts: string[]) => [...opts, opt], + Array(), + ) + .action(lazy(() => import('./build').then(m => m.command))); + + program + .command('lint') + .option( + '--format ', + 'Lint report output format', + 'eslint-formatter-friendly', + ) + .option('--fix', 'Attempt to automatically fix violations') + .description('Lint a package') + .action(lazy(() => import('./lint').then(m => m.default))); + + program + .command('test') + .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args + .helpOption(', --backstage-cli-help') // Let Jest handle help + .description('Run tests, forwarding args to Jest, defaulting to watch mode') + .action(lazy(() => import('./testCommand').then(m => m.default))); + + command + .command('clean') + .description('Delete cache directories') + .action(lazy(() => import('./clean/clean').then(m => m.default))); + + command + .command('prepack') + .description('Prepares a package for packaging before publishing') + .action(lazy(() => import('./pack').then(m => m.pre))); + + command + .command('postpack') + .description('Restores the changes made by the prepack command') + .action(lazy(() => import('./pack').then(m => m.post))); +} + +export function registerMigrateCommand(program: CommanderStatic) { + const command = program + .command('migrate [command]', { hidden: true }) + .description('Migration utilities [EXPERIMENTAL]'); + + command + .command('package-role') + .description(`Add package role field to packages that don't have it`) + .action(lazy(() => import('./migrate/packageRole').then(m => m.default))); + + command + .command('package-scripts') + .description('Set package scripts according to each package role') + .action( + lazy(() => import('./migrate/packageScripts').then(m => m.command)), + ); +} + +export function registerCommands(program: CommanderStatic) { + // TODO(Rugvip): Deprecate in favor of script variant program .command('app:build') .description('Build an app for a production release') @@ -33,6 +131,7 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./app/build').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('app:serve') .description('Serve an app for local development') @@ -40,6 +139,7 @@ export function registerCommands(program: CommanderStatic) { .option(...configOption) .action(lazy(() => import('./app/serve').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('backend:build') .description('Build a backend plugin') @@ -47,6 +147,7 @@ export function registerCommands(program: CommanderStatic) { .option('--experimental-type-build', 'Enable experimental type build') .action(lazy(() => import('./backend/build').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('backend:bundle') .description('Bundle the backend into a deployment archive') @@ -56,6 +157,7 @@ export function registerCommands(program: CommanderStatic) { ) .action(lazy(() => import('./backend/bundle').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('backend:dev') .description('Start local development server with HMR for the backend') @@ -104,6 +206,7 @@ export function registerCommands(program: CommanderStatic) { lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), ); + // TODO(Rugvip): Deprecate in favor of script variant program .command('plugin:build') .description('Build a plugin') @@ -111,6 +214,7 @@ export function registerCommands(program: CommanderStatic) { .option('--experimental-type-build', 'Enable experimental type build') .action(lazy(() => import('./plugin/build').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('plugin:serve') .description('Serves the dev/ folder of a plugin') @@ -125,14 +229,16 @@ export function registerCommands(program: CommanderStatic) { .description('Diff an existing plugin with the creation template') .action(lazy(() => import('./plugin/diff').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('build') .description('Build a package for publishing') .option('--outputs ', 'List of formats to output [types,cjs,esm]') .option('--minify', 'Minify the generated code') .option('--experimental-type-build', 'Enable experimental type build') - .action(lazy(() => import('./build').then(m => m.default))); + .action(lazy(() => import('./oldBuild').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('lint') .option( @@ -144,6 +250,7 @@ export function registerCommands(program: CommanderStatic) { .description('Lint a package') .action(lazy(() => import('./lint').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('test') .allowUnknownOption(true) // Allows the command to run, but we still need to parse raw args @@ -205,6 +312,9 @@ export function registerCommands(program: CommanderStatic) { .description('Print configuration schema') .action(lazy(() => import('./config/schema').then(m => m.default))); + registerScriptCommand(program); + registerMigrateCommand(program); + program .command('versions:bump') .option( @@ -220,16 +330,19 @@ export function registerCommands(program: CommanderStatic) { .description('Check Backstage package versioning') .action(lazy(() => import('./versions/lint').then(m => m.default))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('prepack') .description('Prepares a package for packaging before publishing') .action(lazy(() => import('./pack').then(m => m.pre))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('postpack') .description('Restores the changes made by the prepack command') .action(lazy(() => import('./pack').then(m => m.post))); + // TODO(Rugvip): Deprecate in favor of script variant program .command('clean') .description('Delete cache directories') diff --git a/packages/cli/src/commands/migrate/packageRole.ts b/packages/cli/src/commands/migrate/packageRole.ts new file mode 100644 index 0000000000..e8bfb8e7c8 --- /dev/null +++ b/packages/cli/src/commands/migrate/packageRole.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { resolve as resolvePath } from 'path'; +import { getPackages } from '@manypkg/get-packages'; +import { paths } from '../../lib/paths'; +import { getRoleFromPackage, detectRoleFromPackage } from '../../lib/role'; + +export default async () => { + const { packages } = await getPackages(paths.targetDir); + + await Promise.all( + packages.map(async ({ dir, packageJson: pkg }) => { + const { name } = pkg; + const existingRole = getRoleFromPackage(pkg); + if (existingRole) { + return; + } + + const detectedRole = detectRoleFromPackage(pkg); + if (!detectedRole) { + console.error(`No role detected for package ${name}`); + return; + } + + console.log(`Detected package role of ${name} as ${detectedRole}`); + + let newPkg = pkg as any; + + const pkgKeys = Object.keys(pkg); + if (pkgKeys.includes('backstage')) { + newPkg.backstage = { + ...newPkg.backstage, + role: detectedRole, + }; + } else { + // We insert the backstage field after one of these fields, otherwise at the end + const index = + Math.max( + pkgKeys.indexOf('version'), + pkgKeys.indexOf('private'), + pkgKeys.indexOf('publishConfig'), + ) + 1 || pkgKeys.length; + + const pkgEntries = Object.entries(pkg); + pkgEntries.splice(index, 0, ['backstage', { role: detectedRole }]); + newPkg = Object.fromEntries(pkgEntries); + } + + await fs.writeJson(resolvePath(dir, 'package.json'), newPkg, { + spaces: 2, + }); + }), + ); +}; diff --git a/packages/cli/src/commands/migrate/packageScripts.ts b/packages/cli/src/commands/migrate/packageScripts.ts new file mode 100644 index 0000000000..a3fdd20018 --- /dev/null +++ b/packages/cli/src/commands/migrate/packageScripts.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { resolve as resolvePath } from 'path'; +import { PackageGraph } from '../../lib/monorepo'; +import { getRoleFromPackage, getRoleInfo, PackageRole } from '../../lib/role'; + +const noStartRoles: PackageRole[] = ['cli', 'common-library']; + +export async function command() { + const packages = await PackageGraph.listTargetPackages(); + + await Promise.all( + packages.map(async ({ dir, packageJson }) => { + const role = getRoleFromPackage(packageJson); + if (!role) { + return; + } + + const roleInfo = getRoleInfo(role); + const hasStart = !noStartRoles.includes(role); + const isBundled = roleInfo.output.includes('bundle'); + + const expectedScripts = { + ...(hasStart && { start: 'backstage-cli script start' }), + build: 'backstage-cli script build', + lint: 'backstage-cli script lint', + test: 'backstage-cli script test', + clean: 'backstage-cli script clean', + ...(!isBundled && { + postpack: 'backstage-cli script postpack', + prepack: 'backstage-cli script prepack', + }), + }; + + let changed = false; + const currentScripts: Record = + (packageJson.scripts = packageJson.scripts || {}); + + for (const [name, value] of Object.entries(expectedScripts)) { + if (currentScripts[name] !== value) { + changed = true; + currentScripts[name] = value; + } + } + + if (changed) { + console.log(`Updating scripts for ${packageJson.name}`); + await fs.writeJson(resolvePath(dir, 'package.json'), packageJson, { + spaces: 2, + }); + } + }), + ); +} diff --git a/packages/cli/src/commands/build.ts b/packages/cli/src/commands/oldBuild.ts similarity index 100% rename from packages/cli/src/commands/build.ts rename to packages/cli/src/commands/oldBuild.ts diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index 6a0127794f..153bd16597 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -39,18 +39,13 @@ const fileHandlers = [ patterns: ['package.json'], handler: handlers.packageJson, }, - { - // Not all plugins have routes - patterns: ['src/routes.ts'], - handler: handlers.skip, - }, { // make sure files in 1st level of src/ and dev/ exist - patterns: ['.eslintrc.js', /^(src|dev)\/[^/]+$/], + patterns: ['.eslintrc.js'], handler: handlers.exists, }, { - patterns: ['README.md', 'tsconfig.json', /^src\//], + patterns: ['README.md', 'tsconfig.json', /^src\//, /^dev\//], handler: handlers.skip, }, ]; diff --git a/packages/cli/src/commands/start/command.ts b/packages/cli/src/commands/start/command.ts new file mode 100644 index 0000000000..6615d5fd46 --- /dev/null +++ b/packages/cli/src/commands/start/command.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { Command } from 'commander'; +import { startBackend } from './startBackend'; +import { startFrontend } from './startFrontend'; +import { findRoleFromCommand } from '../../lib/role'; + +export async function command(cmd: Command): Promise { + const role = await findRoleFromCommand(cmd); + + const options = { + configPaths: cmd.config as string[], + checksEnabled: Boolean(cmd.check), + inspectEnabled: Boolean(cmd.inspect), + inspectBrkEnabled: Boolean(cmd.inspectBrk), + }; + + switch (role) { + case 'backend': + case 'plugin-backend': + case 'plugin-backend-module': + case 'node-library': + return startBackend(options); + case 'app': + return startFrontend({ + ...options, + entry: 'src/index', + verifyVersions: true, + }); + case 'web-library': + case 'plugin-frontend': + case 'plugin-frontend-module': + return startFrontend({ entry: 'dev/index', ...options }); + default: + throw new Error( + `Start command is not supported for package role '${role}'`, + ); + } +} diff --git a/packages/cli/src/commands/start/index.ts b/packages/cli/src/commands/start/index.ts new file mode 100644 index 0000000000..680fe9e11d --- /dev/null +++ b/packages/cli/src/commands/start/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { command } from './command'; diff --git a/packages/cli/src/commands/start/startBackend.ts b/packages/cli/src/commands/start/startBackend.ts new file mode 100644 index 0000000000..61ada7a291 --- /dev/null +++ b/packages/cli/src/commands/start/startBackend.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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 { paths } from '../../lib/paths'; +import { serveBackend } from '../../lib/bundler'; + +interface StartBackendOptions { + checksEnabled: boolean; + inspectEnabled: boolean; + inspectBrkEnabled: boolean; +} + +export async function startBackend(options: StartBackendOptions) { + // Cleaning dist/ before we start the dev process helps work around an issue + // where we end up with the entrypoint executing multiple times, causing + // a port bind conflict among other things. + await fs.remove(paths.resolveTarget('dist')); + + const waitForExit = await serveBackend({ + entry: 'src/index', + checksEnabled: options.checksEnabled, + inspectEnabled: options.inspectEnabled, + inspectBrkEnabled: options.inspectBrkEnabled, + }); + + await waitForExit(); +} diff --git a/packages/cli/src/commands/start/startFrontend.ts b/packages/cli/src/commands/start/startFrontend.ts new file mode 100644 index 0000000000..e5500c310b --- /dev/null +++ b/packages/cli/src/commands/start/startFrontend.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import fs from 'fs-extra'; +import chalk from 'chalk'; +import uniq from 'lodash/uniq'; +import { serveBundle } from '../../lib/bundler'; +import { loadCliConfig } from '../../lib/config'; +import { paths } from '../../lib/paths'; +import { Lockfile } from '../../lib/versioning'; +import { includedFilter } from '../versions/lint'; + +interface StartAppOptions { + verifyVersions?: boolean; + entry: string; + + checksEnabled: boolean; + configPaths: string[]; +} + +export async function startFrontend(options: StartAppOptions) { + if (options.verifyVersions) { + const lockfile = await Lockfile.load(paths.resolveTargetRoot('yarn.lock')); + const result = lockfile.analyze({ + filter: includedFilter, + }); + const problemPackages = [...result.newVersions, ...result.newRanges].map( + ({ name }) => name, + ); + + if (problemPackages.length > 1) { + console.log( + chalk.yellow( + `⚠️ Some of the following packages may be outdated or have duplicate installations: + + ${uniq(problemPackages).join(', ')} + `, + ), + ); + console.log( + chalk.yellow( + `⚠️ This can be resolved using the following command: + + yarn backstage-cli versions:check --fix + `, + ), + ); + } + } + + const { name } = await fs.readJson(paths.resolveTarget('package.json')); + const waitForExit = await serveBundle({ + entry: options.entry, + checksEnabled: options.checksEnabled, + ...(await loadCliConfig({ + args: options.configPaths, + fromPackage: name, + withFilteredKeys: true, + })), + }); + + await waitForExit(); +} diff --git a/packages/cli/src/lib/bundler/optimization.ts b/packages/cli/src/lib/bundler/optimization.ts index a5cf38fc11..0a5ef178cb 100644 --- a/packages/cli/src/lib/bundler/optimization.ts +++ b/packages/cli/src/lib/bundler/optimization.ts @@ -29,6 +29,7 @@ export const optimization = ( minimizer: [ new ESBuildMinifyPlugin({ target: 'es2019', + format: 'iife', }), ], runtimeChunk: 'single', diff --git a/packages/cli/src/lib/monorepo/PackageGraph.ts b/packages/cli/src/lib/monorepo/PackageGraph.ts index 9860b92ef6..89d5f79cd2 100644 --- a/packages/cli/src/lib/monorepo/PackageGraph.ts +++ b/packages/cli/src/lib/monorepo/PackageGraph.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { Package } from '@manypkg/get-packages'; +import { getPackages, Package } from '@manypkg/get-packages'; +import { paths } from '../paths'; +import { PackageRole } from '../role'; type PackageJSON = Package['packageJson']; @@ -25,8 +27,17 @@ export interface ExtendedPackageJSON extends PackageJSON { // The `bundled` field is a field known within Backstage, it means // that the package bundles all of its dependencies in its build output. bundled?: boolean; + + backstage?: { + role?: PackageRole; + }; } +export type ExtendedPackage = { + dir: string; + packageJson: ExtendedPackageJSON; +}; + export type PackageGraphNode = { /** The name of the package */ name: string; @@ -47,6 +58,11 @@ export type PackageGraphNode = { }; export class PackageGraph extends Map { + static async listTargetPackages(): Promise { + const { packages } = await getPackages(paths.targetDir); + return packages as ExtendedPackage[]; + } + static fromPackages(packages: Package[]): PackageGraph { const graph = new PackageGraph(); diff --git a/packages/cli/src/lib/role/index.ts b/packages/cli/src/lib/role/index.ts new file mode 100644 index 0000000000..4e1047a628 --- /dev/null +++ b/packages/cli/src/lib/role/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type { + PackageRoleInfo, + PackagePlatform, + PackageOutputType, + PackageRole, +} from './types'; +export { + getRoleInfo, + getRoleFromPackage, + findRoleFromCommand, + detectRoleFromPackage, +} from './packageRoles'; diff --git a/packages/cli/src/lib/role/packageRoles.test.ts b/packages/cli/src/lib/role/packageRoles.test.ts new file mode 100644 index 0000000000..f6c9fb418a --- /dev/null +++ b/packages/cli/src/lib/role/packageRoles.test.ts @@ -0,0 +1,328 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 mockFs from 'mock-fs'; +import { Command } from 'commander'; +import { + getRoleInfo, + getRoleFromPackage, + findRoleFromCommand, + detectRoleFromPackage, +} from './packageRoles'; + +describe('getRoleInfo', () => { + it('provides role info by role', () => { + expect(getRoleInfo('web-library')).toEqual({ + role: 'web-library', + platform: 'web', + output: ['types', 'esm'], + }); + + expect(getRoleInfo('app')).toEqual({ + role: 'app', + platform: 'web', + output: ['bundle'], + }); + + expect(() => getRoleInfo('invalid')).toThrow( + `Unknown package role 'invalid'`, + ); + }); +}); + +describe('getRoleFromPackage', () => { + it('reads explicit package roles', () => { + expect( + getRoleFromPackage({ + backstage: { + role: 'web-library', + }, + }), + ).toEqual('web-library'); + + expect( + getRoleFromPackage({ + backstage: { + role: 'app', + }, + }), + ).toEqual('app'); + + expect(() => + getRoleFromPackage({ + name: 'test', + backstage: {}, + }), + ).toThrow('Package test must specify a role in the "backstage" field'); + + expect(() => + getRoleFromPackage({ + name: 'test', + backstage: { role: 'invalid' }, + }), + ).toThrow(`Unknown package role 'invalid'`); + }); +}); + +describe('findRoleFromCommand', () => { + function mkCommand(args: string) { + return new Command() + .option('--role ', 'test role') + .parse(['node', 'entry.js', ...args.split(' ')]) as Command; + } + + beforeEach(() => { + mockFs({ + 'package.json': JSON.stringify({ + name: 'test', + backstage: { + role: 'web-library', + }, + }), + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it('provides role info by role', async () => { + await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual( + 'web-library', + ); + + await expect( + findRoleFromCommand(mkCommand('--role node-library')), + ).resolves.toEqual('node-library'); + + await expect( + findRoleFromCommand(mkCommand('--role invalid')), + ).rejects.toThrow(`Unknown package role 'invalid'`); + }); +}); + +describe('detectRoleFromPackage', () => { + it('detects the role of example-app', () => { + expect( + detectRoleFromPackage({ + name: 'example-app', + private: true, + bundled: true, + scripts: { + start: 'backstage-cli app:serve', + build: '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', + lint: 'backstage-cli lint', + 'cy:dev': 'cypress open', + 'cy:run': 'cypress run', + }, + }), + ).toEqual('app'); + }); + + it('detects the role of example-backend', () => { + expect( + detectRoleFromPackage({ + name: 'example-backend', + main: 'dist/index.cjs.js', + types: 'src/index.ts', + scripts: { + build: 'backstage-cli backend:bundle', + 'build-image': + 'docker build ../.. -f Dockerfile --tag example-backend', + start: 'backstage-cli backend:dev', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + clean: 'backstage-cli clean', + 'migrate:create': 'knex migrate:make -x ts', + }, + }), + ).toEqual('backend'); + }); + + it('detects the role of @backstage/plugin-catalog', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.esm.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli plugin:build', + start: 'backstage-cli plugin:serve', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + diff: 'backstage-cli plugin:diff', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('plugin-frontend'); + }); + + it('detects the role of @backstage/plugin-catalog-backend', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-backend', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.cjs.js', + types: 'dist/index.d.ts', + }, + scripts: { + start: 'backstage-cli backend:dev', + build: 'backstage-cli backend:build', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('plugin-backend'); + }); + + it('detects the role of @backstage/plugin-catalog-react', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-react', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.esm.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli build', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('web-library'); + }); + + it('detects the role of @backstage/plugin-catalog-common', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-common', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.cjs.js', + module: 'dist/index.esm.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli build', + lint: 'backstage-cli lint', + test: 'backstage-cli test --passWithNoTests', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('common-library'); + }); + + it('detects the role of @backstage/plugin-catalog-backend-module-ldap', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-catalog-backend-module-ldap', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.cjs.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli backend:build', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('plugin-backend-module'); + }); + + it('detects the role of @backstage/plugin-permission-node', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-permission-node', + main: 'src/index.ts', + types: 'src/index.ts', + homepage: 'https://backstage.io', + publishConfig: { + access: 'public', + main: 'dist/index.cjs.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli backend:build', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('node-library'); + }); + + it('detects the role of @backstage/plugin-analytics-module-ga', () => { + expect( + detectRoleFromPackage({ + name: '@backstage/plugin-analytics-module-ga', + main: 'src/index.ts', + types: 'src/index.ts', + publishConfig: { + access: 'public', + main: 'dist/index.esm.js', + types: 'dist/index.d.ts', + }, + scripts: { + build: 'backstage-cli plugin:build', + start: 'backstage-cli plugin:serve', + lint: 'backstage-cli lint', + test: 'backstage-cli test', + diff: 'backstage-cli plugin:diff', + prepack: 'backstage-cli prepack', + postpack: 'backstage-cli postpack', + clean: 'backstage-cli clean', + }, + }), + ).toEqual('plugin-frontend-module'); + }); +}); diff --git a/packages/cli/src/lib/role/packageRoles.ts b/packages/cli/src/lib/role/packageRoles.ts new file mode 100644 index 0000000000..7c855ee0fd --- /dev/null +++ b/packages/cli/src/lib/role/packageRoles.ts @@ -0,0 +1,186 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { z } from 'zod'; +import fs from 'fs-extra'; +import { Command } from 'commander'; +import { paths } from '../paths'; +import { PackageRole, PackageRoleInfo } from './types'; + +const packageRoleInfos: PackageRoleInfo[] = [ + { + role: 'app', + platform: 'web', + output: ['bundle'], + }, + { + role: 'backend', + platform: 'node', + output: ['bundle'], + }, + { + role: 'cli', + platform: 'node', + output: ['cjs'], + }, + { + role: 'web-library', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'node-library', + platform: 'node', + output: ['types', 'cjs'], + }, + { + role: 'common-library', + platform: 'common', + output: ['types', 'esm', 'cjs'], + }, + { + role: 'plugin-frontend', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'plugin-frontend-module', + platform: 'web', + output: ['types', 'esm'], + }, + { + role: 'plugin-backend', + platform: 'node', + output: ['types', 'cjs'], + }, + { + role: 'plugin-backend-module', + platform: 'node', + output: ['types', 'cjs'], + }, +]; + +export function getRoleInfo(role: string): PackageRoleInfo { + const roleInfo = packageRoleInfos.find(r => r.role === role); + if (!roleInfo) { + throw new Error(`Unknown package role '${role}'`); + } + return roleInfo; +} + +const readSchema = z.object({ + name: z.string().optional(), + backstage: z + .object({ + role: z.string().optional(), + }) + .optional(), +}); + +export function getRoleFromPackage(pkgJson: unknown): PackageRole | undefined { + const pkg = readSchema.parse(pkgJson); + + // If there's an explicit role, use that. + if (pkg.backstage) { + const { role } = pkg.backstage; + if (!role) { + throw new Error( + `Package ${pkg.name} must specify a role in the "backstage" field`, + ); + } + + return getRoleInfo(role).role; + } + + return undefined; +} + +export async function findRoleFromCommand(cmd: Command): Promise { + if (cmd.role) { + return getRoleInfo(cmd.role)?.role; + } + + const pkg = await fs.readJson(paths.resolveTarget('package.json')); + const info = getRoleFromPackage(pkg); + if (!info) { + throw new Error(`Target package must have 'backstage.role' set`); + } + return info; +} + +const detectionSchema = z.object({ + name: z.string().optional(), + scripts: z + .object({ + start: z.string().optional(), + build: z.string().optional(), + }) + .optional(), + publishConfig: z + .object({ + main: z.string().optional(), + types: z.string().optional(), + module: z.string().optional(), + }) + .optional(), + main: z.string().optional(), + types: z.string().optional(), + module: z.string().optional(), +}); + +export function detectRoleFromPackage( + pkgJson: unknown, +): PackageRole | undefined { + const pkg = detectionSchema.parse(pkgJson); + + if (pkg.scripts?.start?.includes('app:serve')) { + return 'app'; + } + if (pkg.scripts?.build?.includes('backend:bundle')) { + return 'backend'; + } + if (pkg.name?.includes('plugin-') && pkg.name?.includes('-backend-module-')) { + return 'plugin-backend-module'; + } + if (pkg.name?.includes('plugin-') && pkg.name?.includes('-module-')) { + return 'plugin-frontend-module'; + } + if (pkg.scripts?.start?.includes('plugin:serve')) { + return 'plugin-frontend'; + } + if (pkg.scripts?.start?.includes('backend:dev')) { + return 'plugin-backend'; + } + + const mainEntry = pkg.publishConfig?.main || pkg.main; + const moduleEntry = pkg.publishConfig?.module || pkg.module; + const typesEntry = pkg.publishConfig?.types || pkg.types; + if (typesEntry) { + if (mainEntry && moduleEntry) { + return 'common-library'; + } + if (moduleEntry || mainEntry?.endsWith('.esm.js')) { + return 'web-library'; + } + if (mainEntry) { + return 'node-library'; + } + } else if (mainEntry) { + return 'cli'; + } + + return undefined; +} diff --git a/packages/cli/src/lib/role/types.ts b/packages/cli/src/lib/role/types.ts new file mode 100644 index 0000000000..6a89f7bdaf --- /dev/null +++ b/packages/cli/src/lib/role/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type PackageRole = + | 'app' + | 'backend' + | 'cli' + | 'web-library' + | 'node-library' + | 'common-library' + | 'plugin-frontend' + | 'plugin-frontend-module' + | 'plugin-backend' + | 'plugin-backend-module'; + +export type PackagePlatform = 'node' | 'web' | 'common'; +export type PackageOutputType = 'bundle' | 'types' | 'esm' | 'cjs'; + +export interface PackageRoleInfo { + role: PackageRole; + platform: PackagePlatform; + output: PackageOutputType[]; +} diff --git a/packages/core-components/src/hooks/useSupportConfig.ts b/packages/core-components/src/hooks/useSupportConfig.ts index 5d64e38f3d..546d779e01 100644 --- a/packages/core-components/src/hooks/useSupportConfig.ts +++ b/packages/core-components/src/hooks/useSupportConfig.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useApi, configApiRef } from '@backstage/core-plugin-api'; +import { useApiHolder, configApiRef } from '@backstage/core-plugin-api'; export type SupportItemLink = { url: string; @@ -50,8 +50,9 @@ const DEFAULT_SUPPORT_CONFIG: SupportConfig = { }; export function useSupportConfig(): SupportConfig { - const config = useApi(configApiRef); - const supportConfig = config.getOptionalConfig('app.support'); + const apiHolder = useApiHolder(); + const config = apiHolder.get(configApiRef); + const supportConfig = config?.getOptionalConfig('app.support'); if (!supportConfig) { return DEFAULT_SUPPORT_CONFIG; diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index 662056eff1..c00930d2ce 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -33,6 +33,15 @@ export function defaultUserTransformer( userPhoto?: string, ): Promise; +// @public +export type GroupMember = + | (MicrosoftGraph.Group & { + '@odata.type': '#microsoft.graph.user'; + }) + | (MicrosoftGraph.User & { + '@odata.type': '#microsoft.graph.group'; + }); + // @public export type GroupTransformer = ( group: MicrosoftGraph.Group, @@ -54,7 +63,6 @@ export const MICROSOFT_GRAPH_USER_ID_ANNOTATION = 'graph.microsoft.com/user-id'; export class MicrosoftGraphClient { constructor(baseUrl: string, pca: msal.ConfidentialClientApplication); static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient; - // Warning: (ae-forgotten-export) The symbol "GroupMember" needs to be exported by the entry point index.d.ts getGroupMembers(groupId: string): AsyncIterable; // (undocumented) getGroupPhoto(groupId: string, sizeId?: string): Promise; diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts index b0d53b43a2..61e62f5803 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/index.ts @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { MicrosoftGraphClient } from './client'; -export type { ODataQuery } from './client'; +export type { GroupMember, ODataQuery } from './client'; export { readMicrosoftGraphConfig } from './config'; export type { MicrosoftGraphProviderConfig } from './config'; export { diff --git a/plugins/catalog-graph/api-report.md b/plugins/catalog-graph/api-report.md index e39982d2b5..515a578d3c 100644 --- a/plugins/catalog-graph/api-report.md +++ b/plugins/catalog-graph/api-report.md @@ -95,8 +95,6 @@ export const EntityCatalogGraphCard: ({ // @public export type EntityEdge = DependencyGraphTypes.DependencyEdge; -// Warning: (ae-missing-release-tag) "EntityEdgeData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityEdgeData = { relations: string[]; @@ -106,8 +104,6 @@ export type EntityEdgeData = { // @public export type EntityNode = DependencyGraphTypes.DependencyNode; -// Warning: (ae-missing-release-tag) "EntityNodeData" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public export type EntityNodeData = { name: string; diff --git a/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts b/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts index 830032d527..caf6c8c501 100644 --- a/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts +++ b/plugins/catalog-graph/src/components/EntityRelationsGraph/types.ts @@ -13,11 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { DependencyGraphTypes } from '@backstage/core-components'; import { MouseEventHandler } from 'react'; /** - * Additional Data for entities + * Additional Data for entities. + * + * @public */ export type EntityEdgeData = { /** @@ -40,6 +43,8 @@ export type EntityEdge = DependencyGraphTypes.DependencyEdge; /** * Additional data for Entity Node + * + * @public */ export type EntityNodeData = { /** diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md index a85777c563..164d831c96 100644 --- a/plugins/catalog-import/api-report.md +++ b/plugins/catalog-import/api-report.md @@ -26,9 +26,7 @@ import { UnpackNestedValue } from 'react-hook-form'; import { UseFormProps } from 'react-hook-form'; import { UseFormReturn } from 'react-hook-form'; -// Warning: (ae-missing-release-tag) "AnalyzeResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export type AnalyzeResult = | { type: 'locations'; @@ -45,26 +43,36 @@ export type AnalyzeResult = generatedEntities: PartialEntity[]; }; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "AutocompleteTextField" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const AutocompleteTextField: ({ - name, - options, - required, - errors, - rules, - loading, - loadingText, - helperText, - errorHelperText, - textFieldProps, -}: Props_5) => JSX.Element; +// @public +export const AutocompleteTextField: ( + props: AutocompleteTextFieldProps, +) => JSX.Element; -// Warning: (ae-missing-release-tag) "CatalogImportApi" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export interface AutocompleteTextFieldProps { + // (undocumented) + errorHelperText?: string; + // (undocumented) + errors?: FieldErrors; + // (undocumented) + helperText?: React_2.ReactNode; + // (undocumented) + loading?: boolean; + // (undocumented) + loadingText?: string; + // (undocumented) + name: TFieldValue; + // (undocumented) + options: string[]; + // (undocumented) + required?: boolean; + // (undocumented) + rules?: React_2.ComponentProps['rules']; + // (undocumented) + textFieldProps?: Omit; +} + +// @public export interface CatalogImportApi { // (undocumented) analyzeUrl(url: string): Promise; @@ -85,14 +93,10 @@ export interface CatalogImportApi { }>; } -// Warning: (ae-missing-release-tag) "catalogImportApiRef" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const catalogImportApiRef: ApiRef; -// Warning: (ae-missing-release-tag) "CatalogImportClient" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export class CatalogImportClient implements CatalogImportApi { constructor(options: { discoveryApi: DiscoveryApi; @@ -110,12 +114,7 @@ export class CatalogImportClient implements CatalogImportApi { body: string; }>; // (undocumented) - submitPullRequest({ - repositoryUrl, - fileContent, - title, - body, - }: { + submitPullRequest(options: { repositoryUrl: string; fileContent: string; title: string; @@ -126,14 +125,10 @@ export class CatalogImportClient implements CatalogImportApi { }>; } -// Warning: (ae-missing-release-tag) "CatalogImportPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const CatalogImportPage: () => JSX.Element; -// Warning: (ae-missing-release-tag) "catalogImportPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public const catalogImportPlugin: BackstagePlugin< { importPage: RouteRef; @@ -143,9 +138,7 @@ const catalogImportPlugin: BackstagePlugin< export { catalogImportPlugin }; export { catalogImportPlugin as plugin }; -// Warning: (ae-forgotten-export) The symbol "ImportFlows" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "StepperProvider" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "defaultGenerateStepper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export function defaultGenerateStepper( @@ -153,98 +146,221 @@ export function defaultGenerateStepper( defaults: StepperProvider, ): StepperProvider; -// Warning: (ae-missing-release-tag) "DefaultImportPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const DefaultImportPage: () => JSX.Element; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "EntityListComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const EntityListComponent: ({ - locations, - collapsed, - locationListItemIcon, - onItemClick, - firstListItem, - withLinks, -}: Props) => JSX.Element; - -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "ImportInfoCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const ImportInfoCard: ({ - exampleLocationUrl, - exampleRepositoryUrl, -}: Props_2) => JSX.Element; - -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "ImportStepper" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const ImportStepper: ({ - initialUrl, - generateStepper, - variant, -}: Props_3) => JSX.Element; - -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "PreparePullRequestForm" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public -export const PreparePullRequestForm: < +export const EntityListComponent: ( + props: EntityListComponentProps, +) => JSX.Element; + +// @public +export interface EntityListComponentProps { + // (undocumented) + collapsed?: boolean; + // (undocumented) + firstListItem?: React_2.ReactElement; + // (undocumented) + locationListItemIcon: (target: string) => React_2.ReactElement; + // (undocumented) + locations: Array<{ + target: string; + entities: (Entity | EntityName)[]; + }>; + // (undocumented) + onItemClick?: (target: string) => void; + // (undocumented) + withLinks?: boolean; +} + +// @public +export type ImportFlows = + | 'unknown' + | 'single-location' + | 'multiple-locations' + | 'no-location'; + +// @public +export const ImportInfoCard: (props: ImportInfoCardProps) => JSX.Element; + +// @public +export interface ImportInfoCardProps { + // (undocumented) + exampleLocationUrl?: string; + // (undocumented) + exampleRepositoryUrl?: string; +} + +// Warning: (ae-forgotten-export) The symbol "State" needs to be exported by the entry point index.d.ts +// Warning: (ae-missing-release-tag) "ImportState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export type ImportState = State & { + activeFlow: ImportFlows; + activeStepNumber: number; + analysisUrl?: string; + onGoBack?: () => void; + onReset: () => void; +}; + +// @public +export const ImportStepper: (props: ImportStepperProps) => JSX.Element; + +// @public +export interface ImportStepperProps { + // (undocumented) + generateStepper?: ( + flow: ImportFlows, + defaults: StepperProvider, + ) => StepperProvider; + // (undocumented) + initialUrl?: string; + // (undocumented) + variant?: InfoCardVariants; +} + +// @public +export const PreparePullRequestForm: >( + props: PreparePullRequestFormProps, +) => JSX.Element; + +// @public +export type PreparePullRequestFormProps< TFieldValues extends Record, ->({ - defaultValues, - onSubmit, - render, -}: Props_6) => JSX.Element; +> = Pick, 'defaultValues'> & { + onSubmit: SubmitHandler; + render: ( + props: Pick< + UseFormReturn, + 'formState' | 'register' | 'control' | 'setValue' + > & { + values: UnpackNestedValue; + }, + ) => React_2.ReactNode; +}; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "PreviewCatalogInfoComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const PreviewCatalogInfoComponent: ({ - repositoryUrl, - entities, - classes, -}: Props_7) => JSX.Element; - -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "PreviewPullRequestComponent" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const PreviewPullRequestComponent: ({ - title, - description, - classes, -}: Props_8) => JSX.Element; - -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "StepInitAnalyzeUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public -export const StepInitAnalyzeUrl: ({ - onAnalysis, - analysisUrl, - disablePullRequest, - exampleLocationUrl, -}: Props_4) => JSX.Element; +export type PrepareResult = + | { + type: 'locations'; + locations: Array<{ + exists?: boolean; + target: string; + entities: EntityName[]; + }>; + } + | { + type: 'repository'; + url: string; + integrationType: string; + pullRequest: { + url: string; + }; + locations: Array<{ + target: string; + entities: EntityName[]; + }>; + }; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "StepPrepareCreatePullRequest" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export const StepPrepareCreatePullRequest: ({ - analyzeResult, - onPrepare, - onGoBack, - renderFormFields, -}: Props_9) => JSX.Element; +// @public +export const PreviewCatalogInfoComponent: ( + props: PreviewCatalogInfoComponentProps, +) => JSX.Element; + +// @public +export interface PreviewCatalogInfoComponentProps { + // (undocumented) + classes?: { + card?: string; + cardContent?: string; + }; + // (undocumented) + entities: Entity[]; + // (undocumented) + repositoryUrl: string; +} + +// @public +export const PreviewPullRequestComponent: ( + props: PreviewPullRequestComponentProps, +) => JSX.Element; + +// @public +export interface PreviewPullRequestComponentProps { + // (undocumented) + classes?: { + card?: string; + cardContent?: string; + }; + // (undocumented) + description: string; + // (undocumented) + title: string; +} + +// @public +export const StepInitAnalyzeUrl: ( + props: StepInitAnalyzeUrlProps, +) => JSX.Element; + +// @public +export interface StepInitAnalyzeUrlProps { + // (undocumented) + analysisUrl?: string; + // (undocumented) + disablePullRequest?: boolean; + // (undocumented) + exampleLocationUrl?: string; + // (undocumented) + onAnalysis: ( + flow: ImportFlows, + url: string, + result: AnalyzeResult, + opts?: { + prepareResult?: PrepareResult; + }, + ) => void; +} + +// @public +export const StepPrepareCreatePullRequest: ( + props: StepPrepareCreatePullRequestProps, +) => JSX.Element; + +// @public +export interface StepPrepareCreatePullRequestProps { + // (undocumented) + analyzeResult: Extract< + AnalyzeResult, + { + type: 'repository'; + } + >; + // (undocumented) + onGoBack?: () => void; + // (undocumented) + onPrepare: ( + result: PrepareResult, + opts?: { + notRepeatable?: boolean; + }, + ) => void; + // Warning: (ae-forgotten-export) The symbol "FormData" needs to be exported by the entry point index.d.ts + // + // (undocumented) + renderFormFields: ( + props: Pick< + UseFormReturn, + 'register' | 'setValue' | 'formState' + > & { + values: UnpackNestedValue; + groups: string[]; + groupsLoading: boolean; + }, + ) => React_2.ReactNode; +} // Warnings were encountered during analysis: // -// src/api/CatalogImportApi.d.ts:15:5 - (ae-forgotten-export) The symbol "PartialEntity" needs to be exported by the entry point index.d.ts +// src/api/CatalogImportApi.d.ts:25:5 - (ae-forgotten-export) The symbol "PartialEntity" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/catalog-import/src/api/CatalogImportApi.ts b/plugins/catalog-import/src/api/CatalogImportApi.ts index 5fae076479..0923f4ac98 100644 --- a/plugins/catalog-import/src/api/CatalogImportApi.ts +++ b/plugins/catalog-import/src/api/CatalogImportApi.ts @@ -18,11 +18,20 @@ import { EntityName } from '@backstage/catalog-model'; import { createApiRef } from '@backstage/core-plugin-api'; import { PartialEntity } from '../types'; +/** + * Utility API reference for the {@link CatalogImportApi}. + * + * @public + */ export const catalogImportApiRef = createApiRef({ id: 'plugin.catalog-import.service', }); -// result of the analyze state +/** + * Result of the analysis. + * + * @public + */ export type AnalyzeResult = | { type: 'locations'; @@ -39,6 +48,11 @@ export type AnalyzeResult = generatedEntities: PartialEntity[]; }; +/** + * API for driving catalog imports. + * + * @public + */ export interface CatalogImportApi { analyzeUrl(url: string): Promise; @@ -46,6 +60,7 @@ export interface CatalogImportApi { title: string; body: string; }>; + submitPullRequest(options: { repositoryUrl: string; fileContent: string; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index 60745923b4..b856a4e359 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -34,6 +34,11 @@ import { getGithubIntegrationConfig } from './GitHub'; import { trimEnd } from 'lodash'; import { getBranchName, getCatalogFilename } from '../components/helpers'; +/** + * The default implementation of the {@link CatalogImportApi}. + * + * @public + */ export class CatalogImportClient implements CatalogImportApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; @@ -142,17 +147,14 @@ the component will become available.\n\nFor more information, read an \ }; } - async submitPullRequest({ - repositoryUrl, - fileContent, - title, - body, - }: { + async submitPullRequest(options: { repositoryUrl: string; fileContent: string; title: string; body: string; }): Promise<{ link: string; location: string }> { + const { repositoryUrl, fileContent, title, body } = options; + const ghConfig = getGithubIntegrationConfig( this.scmIntegrationsApi, repositoryUrl, @@ -172,9 +174,7 @@ the component will become available.\n\nFor more information, read an \ } // TODO: this could be part of the catalog api - private async generateEntityDefinitions({ - repo, - }: { + private async generateEntityDefinitions(options: { repo: string; }): Promise { const { token } = await this.identityApi.getCredentials(); @@ -187,7 +187,7 @@ the component will become available.\n\nFor more information, read an \ }, method: 'POST', body: JSON.stringify({ - location: { type: 'url', target: repo }, + location: { type: 'url', target: options.repo }, }), }, ).catch(e => { @@ -204,12 +204,7 @@ the component will become available.\n\nFor more information, read an \ } // TODO: this response should better be part of the analyze-locations response and scm-independent / implemented per scm - private async checkGitHubForExistingCatalogInfo({ - url, - owner, - repo, - githubIntegrationConfig, - }: { + private async checkGitHubForExistingCatalogInfo(options: { url: string; owner: string; repo: string; @@ -220,6 +215,8 @@ the component will become available.\n\nFor more information, read an \ entities: EntityName[]; }> > { + const { url, owner, repo, githubIntegrationConfig } = options; + const { token } = await this.scmAuthApi.getCredentials({ url }); const octo = new Octokit({ auth: token, @@ -269,15 +266,7 @@ the component will become available.\n\nFor more information, read an \ } // TODO: extract this function and implement for non-github - private async submitGitHubPrToRepo({ - owner, - repo, - title, - body, - fileContent, - repositoryUrl, - githubIntegrationConfig, - }: { + private async submitGitHubPrToRepo(options: { owner: string; repo: string; title: string; @@ -286,6 +275,16 @@ the component will become available.\n\nFor more information, read an \ repositoryUrl: string; githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }> { + const { + owner, + repo, + title, + body, + fileContent, + repositoryUrl, + githubIntegrationConfig, + } = options; + const { token } = await this.scmAuthApi.getCredentials({ url: repositoryUrl, additionalScope: { diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx index 8693895ec0..30cce9603a 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -27,6 +27,11 @@ import React from 'react'; import { ImportInfoCard } from '../ImportInfoCard'; import { ImportStepper } from '../ImportStepper'; +/** + * The default catalog import page. + * + * @public + */ export const DefaultImportPage = () => { const configApi = useApi(configApiRef); const appTitle = configApi.getOptional('app.title') || 'Backstage'; diff --git a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx index b8af9942f6..2c59eec18f 100644 --- a/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx +++ b/plugins/catalog-import/src/components/EntityListComponent/EntityListComponent.tsx @@ -47,23 +47,35 @@ function sortEntities(entities: Array) { ); } -type Props = { +/** + * Props for {@link EntityListComponent}. + * + * @public + */ +export interface EntityListComponentProps { locations: Array<{ target: string; entities: (Entity | EntityName)[] }>; locationListItemIcon: (target: string) => React.ReactElement; collapsed?: boolean; firstListItem?: React.ReactElement; onItemClick?: (target: string) => void; withLinks?: boolean; -}; +} + +/** + * Shows a result list of entities. + * + * @public + */ +export const EntityListComponent = (props: EntityListComponentProps) => { + const { + locations, + collapsed = false, + locationListItemIcon, + onItemClick, + firstListItem, + withLinks = false, + } = props; -export const EntityListComponent = ({ - locations, - collapsed = false, - locationListItemIcon, - onItemClick, - firstListItem, - withLinks = false, -}: Props) => { const app = useApp(); const classes = useStyles(); diff --git a/plugins/catalog-import/src/components/EntityListComponent/index.ts b/plugins/catalog-import/src/components/EntityListComponent/index.ts index 06b695240c..7fe0a0d55c 100644 --- a/plugins/catalog-import/src/components/EntityListComponent/index.ts +++ b/plugins/catalog-import/src/components/EntityListComponent/index.ts @@ -15,3 +15,4 @@ */ export { EntityListComponent } from './EntityListComponent'; +export type { EntityListComponentProps } from './EntityListComponent'; diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index 3e66e137eb..c879cbe998 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -21,15 +21,27 @@ import React from 'react'; import { catalogImportApiRef } from '../../api'; import { useCatalogFilename } from '../../hooks'; -type Props = { +/** + * Props for {@link ImportInfoCard}. + * + * @public + */ +export interface ImportInfoCardProps { exampleLocationUrl?: string; exampleRepositoryUrl?: string; -}; +} + +/** + * Shows information about the import process. + * + * @public + */ +export const ImportInfoCard = (props: ImportInfoCardProps) => { + const { + exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + exampleRepositoryUrl = 'https://github.com/backstage/backstage', + } = props; -export const ImportInfoCard = ({ - exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', - exampleRepositoryUrl = 'https://github.com/backstage/backstage', -}: Props) => { const configApi = useApi(configApiRef); const appTitle = configApi.getOptional('app.title') || 'Backstage'; const catalogImportApi = useApi(catalogImportApiRef); diff --git a/plugins/catalog-import/src/components/ImportInfoCard/index.ts b/plugins/catalog-import/src/components/ImportInfoCard/index.ts index c82e88f7e8..10dc8726f6 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/index.ts +++ b/plugins/catalog-import/src/components/ImportInfoCard/index.ts @@ -15,3 +15,4 @@ */ export { ImportInfoCard } from './ImportInfoCard'; +export type { ImportInfoCardProps } from './ImportInfoCard'; diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.tsx index 467bd710f0..129e714952 100644 --- a/plugins/catalog-import/src/components/ImportPage/ImportPage.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.tsx @@ -18,6 +18,11 @@ import React from 'react'; import { useOutlet } from 'react-router'; import { DefaultImportPage } from '../DefaultImportPage'; +/** + * The whole catalog import page. + * + * @public + */ export const ImportPage = () => { const outlet = useOutlet(); diff --git a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx index 4112775c2d..b2638e582e 100644 --- a/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/ImportStepper.tsx @@ -34,20 +34,32 @@ const useStyles = makeStyles(() => ({ }, })); -type Props = { +/** + * Props for {@link ImportStepper}. + * + * @public + */ +export interface ImportStepperProps { initialUrl?: string; generateStepper?: ( flow: ImportFlows, defaults: StepperProvider, ) => StepperProvider; variant?: InfoCardVariants; -}; +} + +/** + * The stepper that holds the different import stages. + * + * @public + */ +export const ImportStepper = (props: ImportStepperProps) => { + const { + initialUrl, + generateStepper = defaultGenerateStepper, + variant, + } = props; -export const ImportStepper = ({ - initialUrl, - generateStepper = defaultGenerateStepper, - variant, -}: Props) => { const catalogImportApi = useApi(catalogImportApiRef); const classes = useStyles(); const state = useImportState({ initialUrl }); diff --git a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx index 553e8977b8..94dff39d0f 100644 --- a/plugins/catalog-import/src/components/ImportStepper/defaults.tsx +++ b/plugins/catalog-import/src/components/ImportStepper/defaults.tsx @@ -42,7 +42,12 @@ export type StepConfiguration = { content: React.ReactElement; }; -export type StepperProvider = { +/** + * Defines the details of the stepper. + * + * @public + */ +export interface StepperProvider { analyze: ( s: Extract, opts: { apis: StepperApis }, @@ -59,7 +64,7 @@ export type StepperProvider = { s: Extract, opts: { apis: StepperApis }, ) => StepConfiguration; -}; +} /** * The default stepper generation function. @@ -69,6 +74,7 @@ export type StepperProvider = { * * @param flow - the name of the active flow * @param defaults - the default steps + * @public */ export function defaultGenerateStepper( flow: ImportFlows, diff --git a/plugins/catalog-import/src/components/ImportStepper/index.ts b/plugins/catalog-import/src/components/ImportStepper/index.ts index 164c6f43f1..db940f5f74 100644 --- a/plugins/catalog-import/src/components/ImportStepper/index.ts +++ b/plugins/catalog-import/src/components/ImportStepper/index.ts @@ -15,4 +15,5 @@ */ export { ImportStepper } from './ImportStepper'; +export type { ImportStepperProps } from './ImportStepper'; export { defaultGenerateStepper } from './defaults'; diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx index 83377d6b2f..3c655ec11b 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/StepInitAnalyzeUrl.tsx @@ -27,7 +27,12 @@ type FormData = { url: string; }; -type Props = { +/** + * Props for {@link StepInitAnalyzeUrl}. + * + * @public + */ +export interface StepInitAnalyzeUrlProps { onAnalysis: ( flow: ImportFlows, url: string, @@ -37,7 +42,7 @@ type Props = { disablePullRequest?: boolean; analysisUrl?: string; exampleLocationUrl?: string; -}; +} /** * A form that lets the user input a url and analyze it for existing locations or potential entities. @@ -45,13 +50,16 @@ type Props = { * @param onAnalysis - is called when the analysis was successful * @param analysisUrl - a url that can be used as a default value * @param disablePullRequest - if true, repositories without entities will abort the wizard + * @public */ -export const StepInitAnalyzeUrl = ({ - onAnalysis, - analysisUrl = '', - disablePullRequest = false, - exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', -}: Props) => { +export const StepInitAnalyzeUrl = (props: StepInitAnalyzeUrlProps) => { + const { + onAnalysis, + analysisUrl = '', + disablePullRequest = false, + exampleLocationUrl = 'https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + } = props; + const errorApi = useApi(errorApiRef); const catalogImportApi = useApi(catalogImportApiRef); diff --git a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts index 2cd1557d75..2a3dffcf99 100644 --- a/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts +++ b/plugins/catalog-import/src/components/StepInitAnalyzeUrl/index.ts @@ -15,3 +15,4 @@ */ export { StepInitAnalyzeUrl } from './StepInitAnalyzeUrl'; +export type { StepInitAnalyzeUrlProps } from './StepInitAnalyzeUrl'; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx index 98d741df15..2e163be2aa 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/AutocompleteTextField.tsx @@ -20,7 +20,12 @@ import { Autocomplete } from '@material-ui/lab'; import React from 'react'; import { Controller, FieldErrors } from 'react-hook-form'; -type Props = { +/** + * Props for {@link AutocompleteTextField}. + * + * @public + */ +export interface AutocompleteTextFieldProps { name: TFieldValue; options: string[]; required?: boolean; @@ -35,20 +40,29 @@ type Props = { errorHelperText?: string; textFieldProps?: Omit; -}; +} + +/** + * An autocompletion text field for the catalog import flows. + * + * @public + */ +export const AutocompleteTextField = ( + props: AutocompleteTextFieldProps, +) => { + const { + name, + options, + required, + errors, + rules, + loading = false, + loadingText, + helperText, + errorHelperText, + textFieldProps = {}, + } = props; -export const AutocompleteTextField = ({ - name, - options, - required, - errors, - rules, - loading = false, - loadingText, - helperText, - errorHelperText, - textFieldProps = {}, -}: Props) => { return ( > = Pick< - UseFormProps, - 'defaultValues' -> & { +/** + * Props for {@link PreparePullRequestForm}. + * + * @public + */ +export type PreparePullRequestFormProps< + TFieldValues extends Record, +> = Pick, 'defaultValues'> & { onSubmit: SubmitHandler; render: ( @@ -48,14 +52,15 @@ type Props> = Pick< * @param onSubmit - a callback that is executed when the form is submitted * (initiated by a button of type="submit") * @param render - render the form elements + * @public */ export const PreparePullRequestForm = < TFieldValues extends Record, ->({ - defaultValues, - onSubmit, - render, -}: Props) => { +>( + props: PreparePullRequestFormProps, +) => { + const { defaultValues, onSubmit, render } = props; + const methods = useForm({ mode: 'onTouched', defaultValues }); const { handleSubmit, watch, control, register, formState, setValue } = methods; diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx index 0f1dc33d2e..a16276f90b 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewCatalogInfoComponent.tsx @@ -22,17 +22,26 @@ import { CodeSnippet } from '@backstage/core-components'; import { trimEnd } from 'lodash'; import { useCatalogFilename } from '../../hooks'; -type Props = { +/** + * Props for {@link PreviewCatalogInfoComponent}. + * + * @public + */ +export interface PreviewCatalogInfoComponentProps { repositoryUrl: string; entities: Entity[]; classes?: { card?: string; cardContent?: string }; -}; +} -export const PreviewCatalogInfoComponent = ({ - repositoryUrl, - entities, - classes, -}: Props) => { +/** + * Previews information about an entity to create. + * + * @public + */ +export const PreviewCatalogInfoComponent = ( + props: PreviewCatalogInfoComponentProps, +) => { + const { repositoryUrl, entities, classes } = props; const catalogFilename = useCatalogFilename(); return ( diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx index 7b6653e4ca..e8a54489a0 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/PreviewPullRequestComponent.tsx @@ -18,17 +18,26 @@ import { Card, CardContent, CardHeader } from '@material-ui/core'; import React from 'react'; import { MarkdownContent } from '@backstage/core-components'; -type Props = { +/** + * Props for {@link PreviewPullRequestComponent}. + * + * @public + */ +export interface PreviewPullRequestComponentProps { title: string; description: string; classes?: { card?: string; cardContent?: string }; -}; +} -export const PreviewPullRequestComponent = ({ - title, - description, - classes, -}: Props) => { +/** + * Previews a pull request. + * + * @public + */ +export const PreviewPullRequestComponent = ( + props: PreviewPullRequestComponentProps, +) => { + const { title, description, classes } = props; return ( diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx index a79caaa0b9..36e8c33a5c 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/StepPrepareCreatePullRequest.tsx @@ -53,7 +53,12 @@ type FormData = { useCodeowners: boolean; }; -type Props = { +/** + * Props for {@link StepPrepareCreatePullRequest}. + * + * @public + */ +export interface StepPrepareCreatePullRequestProps { analyzeResult: Extract; onPrepare: ( result: PrepareResult, @@ -71,7 +76,7 @@ type Props = { groupsLoading: boolean; }, ) => React.ReactNode; -}; +} export function generateEntities( entities: PartialEntity[], @@ -93,12 +98,16 @@ export function generateEntities( })); } -export const StepPrepareCreatePullRequest = ({ - analyzeResult, - onPrepare, - onGoBack, - renderFormFields, -}: Props) => { +/** + * Prepares a pull request. + * + * @public + */ +export const StepPrepareCreatePullRequest = ( + props: StepPrepareCreatePullRequestProps, +) => { + const { analyzeResult, onPrepare, onGoBack, renderFormFields } = props; + const classes = useStyles(); const catalogApi = useApi(catalogApiRef); const catalogImportApi = useApi(catalogImportApiRef); diff --git a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts index 1e8eaea1b9..ce1cd40e19 100644 --- a/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts +++ b/plugins/catalog-import/src/components/StepPrepareCreatePullRequest/index.ts @@ -15,7 +15,12 @@ */ export { AutocompleteTextField } from './AutocompleteTextField'; +export type { AutocompleteTextFieldProps } from './AutocompleteTextField'; export { PreparePullRequestForm } from './PreparePullRequestForm'; +export type { PreparePullRequestFormProps } from './PreparePullRequestForm'; export { PreviewCatalogInfoComponent } from './PreviewCatalogInfoComponent'; +export type { PreviewCatalogInfoComponentProps } from './PreviewCatalogInfoComponent'; export { PreviewPullRequestComponent } from './PreviewPullRequestComponent'; +export type { PreviewPullRequestComponentProps } from './PreviewPullRequestComponent'; export { StepPrepareCreatePullRequest } from './StepPrepareCreatePullRequest'; +export type { StepPrepareCreatePullRequestProps } from './StepPrepareCreatePullRequest'; diff --git a/plugins/catalog-import/src/components/index.ts b/plugins/catalog-import/src/components/index.ts index 886c679d2d..15f57e7219 100644 --- a/plugins/catalog-import/src/components/index.ts +++ b/plugins/catalog-import/src/components/index.ts @@ -20,3 +20,4 @@ export * from './ImportInfoCard'; export * from './ImportStepper'; export * from './StepInitAnalyzeUrl'; export * from './StepPrepareCreatePullRequest'; +export type { ImportFlows, ImportState, PrepareResult } from './useImportState'; diff --git a/plugins/catalog-import/src/components/useImportState.ts b/plugins/catalog-import/src/components/useImportState.ts index a1505b26b5..0cb6b59917 100644 --- a/plugins/catalog-import/src/components/useImportState.ts +++ b/plugins/catalog-import/src/components/useImportState.ts @@ -18,7 +18,11 @@ import { Entity, EntityName } from '@backstage/catalog-model'; import { useReducer } from 'react'; import { AnalyzeResult } from '../api'; -// the configuration of the stepper +/** + * The configuration of the stepper. + * + * @public + */ export type ImportFlows = | 'unknown' | 'single-location' @@ -28,7 +32,11 @@ export type ImportFlows = // the available states of the stepper type ImportStateTypes = 'analyze' | 'prepare' | 'review' | 'finish'; -// result of the prepare state +/** + * Result of the prepare state. + * + * @public + */ export type PrepareResult = | { type: 'locations'; diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index 52c1929dce..d9dd226792 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -34,6 +34,12 @@ export const rootRouteRef = createRouteRef({ id: 'catalog-import', }); +/** + * A plugin that helps the user in importing projects and YAML files into the + * catalog. + * + * @public + */ export const catalogImportPlugin = createPlugin({ id: 'catalog-import', apis: [ @@ -70,6 +76,11 @@ export const catalogImportPlugin = createPlugin({ }, }); +/** + * The page for importing projects and YAML files into the catalog. + * + * @public + */ export const CatalogImportPage = catalogImportPlugin.provide( createRoutableExtension({ name: 'CatalogImportPage', diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 8c3ba0daa3..2c064a504f 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -105,22 +105,14 @@ export type CatalogReactUserListPickerClassKey = // @public @deprecated (undocumented) export const catalogRouteRef: RouteRef; -// Warning: (ae-missing-release-tag) "createDomainColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createDomainColumn(): TableColumn; -// Warning: (ae-missing-release-tag) "createEntityRefColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) -function createEntityRefColumn({ - defaultKind, -}: { +function createEntityRefColumn(options: { defaultKind?: string; }): TableColumn; -// Warning: (ae-missing-release-tag) "createEntityRelationColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createEntityRelationColumn({ title, @@ -136,28 +128,18 @@ function createEntityRelationColumn({ }; }): TableColumn; -// Warning: (ae-missing-release-tag) "createMetadataDescriptionColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createMetadataDescriptionColumn(): TableColumn; -// Warning: (ae-missing-release-tag) "createOwnerColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createOwnerColumn(): TableColumn; -// Warning: (ae-missing-release-tag) "createSpecLifecycleColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createSpecLifecycleColumn(): TableColumn; -// Warning: (ae-missing-release-tag) "createSpecTypeColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createSpecTypeColumn(): TableColumn; -// Warning: (ae-missing-release-tag) "createSystemColumn" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) function createSystemColumn(): TableColumn; @@ -215,14 +197,18 @@ export class EntityKindFilter implements EntityFilter { readonly value: string; } -// Warning: (ae-forgotten-export) The symbol "EntityKindFilterProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "EntityKindPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) -export const EntityKindPicker: ({ - initialFilter, - hidden, -}: EntityKindFilterProps) => JSX.Element | null; +export const EntityKindPicker: ( + props: EntityKindPickerProps, +) => JSX.Element | null; + +// @public +export interface EntityKindPickerProps { + // (undocumented) + hidden: boolean; + // (undocumented) + initialFilter?: string; +} // Warning: (ae-missing-release-tag) "EntityLifecycleFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -237,8 +223,6 @@ export class EntityLifecycleFilter implements EntityFilter { readonly values: string[]; } -// Warning: (ae-missing-release-tag) "EntityLifecyclePicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntityLifecyclePicker: () => JSX.Element | null; @@ -270,8 +254,6 @@ export class EntityOwnerFilter implements EntityFilter { readonly values: string[]; } -// Warning: (ae-missing-release-tag) "EntityOwnerPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntityOwnerPicker: () => JSX.Element | null; @@ -289,10 +271,7 @@ export interface EntityProviderProps { entity?: Entity; } -// Warning: (ae-forgotten-export) The symbol "EntityRefLinkProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "EntityRefLink" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public export const EntityRefLink: React_2.ForwardRefExoticComponent< Pick< EntityRefLinkProps, @@ -581,16 +560,27 @@ export const EntityRefLink: React_2.ForwardRefExoticComponent< React_2.RefAttributes >; -// Warning: (ae-forgotten-export) The symbol "EntityRefLinksProps" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "EntityRefLinks" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) +// @public +export type EntityRefLinkProps = { + entityRef: Entity | EntityName; + defaultKind?: string; + title?: string; + children?: React_2.ReactNode; +} & Omit; + +// @public export const EntityRefLinks: ({ entityRefs, defaultKind, ...linkProps }: EntityRefLinksProps) => JSX.Element; +// @public +export type EntityRefLinksProps = { + entityRefs: (Entity | EntityName)[]; + defaultKind?: string; +} & Omit; + // Warning: (ae-missing-release-tag) "entityRoute" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public @deprecated (undocumented) @@ -618,8 +608,6 @@ export const entityRouteRef: RouteRef<{ namespace: string; }>; -// Warning: (ae-missing-release-tag) "EntitySearchBar" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntitySearchBar: () => JSX.Element; @@ -631,18 +619,12 @@ export type EntitySourceLocation = { integrationType?: string; }; -// Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts -// Warning: (ae-missing-release-tag) "EntityTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // Warning: (ae-missing-release-tag) "EntityTable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) -export function EntityTable({ - entities, - title, - emptyContent, - variant, - columns, -}: Props): JSX.Element; +// @public +export function EntityTable( + props: EntityTableProps, +): JSX.Element; // @public (undocumented) export namespace EntityTable { @@ -656,6 +638,22 @@ export namespace EntityTable { componentEntityColumns: TableColumn[]; } +// Warning: (ae-unresolved-link) The @link reference could not be resolved: The reference is ambiguous because "EntityTable" has more than one declaration; you need to add a TSDoc member reference selector +// +// @public +export interface EntityTableProps { + // (undocumented) + columns: TableColumn[]; + // (undocumented) + emptyContent?: ReactNode; + // (undocumented) + entities: T[]; + // (undocumented) + title: string; + // (undocumented) + variant?: 'gridItem'; +} + // Warning: (ae-missing-release-tag) "EntityTagFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -669,8 +667,6 @@ export class EntityTagFilter implements EntityFilter { readonly values: string[]; } -// Warning: (ae-missing-release-tag) "EntityTagPicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntityTagPicker: () => JSX.Element | null; @@ -700,26 +696,24 @@ export class EntityTypeFilter implements EntityFilter { readonly value: string | string[]; } -// Warning: (ae-missing-release-tag) "EntityTypeFilterProps" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public (undocumented) -export type EntityTypeFilterProps = { - initialFilter?: string; - hidden?: boolean; -}; - -// Warning: (ae-missing-release-tag) "EntityTypePicker" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// // @public (undocumented) export const EntityTypePicker: ( - props: EntityTypeFilterProps, + props: EntityTypePickerProps, ) => JSX.Element | null; +// @public +export interface EntityTypePickerProps { + // (undocumented) + hidden?: boolean; + // (undocumented) + initialFilter?: string; +} + // Warning: (ae-forgotten-export) The symbol "Props" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "FavoriteEntity" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -export const FavoriteEntity: (props: Props_2) => JSX.Element; +export const FavoriteEntity: (props: Props) => JSX.Element; // Warning: (ae-missing-release-tag) "favoriteEntityIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -835,7 +829,7 @@ export const UnregisterEntityDialog: ({ onConfirm, onClose, entity, -}: Props_3) => JSX.Element; +}: Props_2) => JSX.Element; // @public export function useEntity(): { diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 6f9119c404..17d28aad9f 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -19,15 +19,20 @@ import { Alert } from '@material-ui/lab'; import { useEntityListProvider } from '../../hooks'; import { EntityKindFilter } from '../../filters'; -type EntityKindFilterProps = { +/** + * Props for {@link EntityKindPicker}. + * + * @public + */ +export interface EntityKindPickerProps { initialFilter?: string; hidden: boolean; -}; +} + +/** @public */ +export const EntityKindPicker = (props: EntityKindPickerProps) => { + const { initialFilter, hidden } = props; -export const EntityKindPicker = ({ - initialFilter, - hidden, -}: EntityKindFilterProps) => { const { updateFilters, queryParameters } = useEntityListProvider(); const [selectedKind] = useState( [queryParameters.kind].flat()[0] ?? initialFilter, diff --git a/plugins/catalog-react/src/components/EntityKindPicker/index.ts b/plugins/catalog-react/src/components/EntityKindPicker/index.ts index 89dd46230b..a89a96aebc 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/index.ts +++ b/plugins/catalog-react/src/components/EntityKindPicker/index.ts @@ -15,3 +15,4 @@ */ export { EntityKindPicker } from './EntityKindPicker'; +export type { EntityKindPickerProps } from './EntityKindPicker'; diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx index f7d915ef5b..47e85292e2 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -161,4 +161,34 @@ describe('', () => { lifecycles: undefined, }); }); + + it('responds to external queryParameters changes', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: new EntityLifecycleFilter(['experimental']), + }); + rendered.rerender( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: new EntityLifecycleFilter(['production']), + }); + }); }); diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx index 630b1700a0..77691ab7c5 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx @@ -46,20 +46,31 @@ const useStyles = makeStyles( const icon = ; const checkedIcon = ; +/** @public */ export const EntityLifecyclePicker = () => { const classes = useStyles(); const { updateFilters, backendEntities, filters, queryParameters } = useEntityListProvider(); - const queryParamLifecycles = [queryParameters.lifecycles] - .flat() - .filter(Boolean) as string[]; + const queryParamLifecycles = useMemo( + () => [queryParameters.lifecycles].flat().filter(Boolean) as string[], + [queryParameters], + ); + const [selectedLifecycles, setSelectedLifecycles] = useState( queryParamLifecycles.length ? queryParamLifecycles : filters.lifecycles?.values ?? [], ); + // Set selected lifecycles on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamLifecycles.length) { + setSelectedLifecycles(queryParamLifecycles); + } + }, [queryParamLifecycles]); + useEffect(() => { updateFilters({ lifecycles: selectedLifecycles.length diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx index 277a691a84..14abc358db 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx @@ -191,4 +191,34 @@ describe('', () => { owner: undefined, }); }); + + it('responds to external queryParameters changes', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['team-a']), + }); + rendered.rerender( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['team-b']), + }); + }); }); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index ae88858666..acb5ee9279 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -48,18 +48,29 @@ const useStyles = makeStyles( const icon = ; const checkedIcon = ; +/** @public */ export const EntityOwnerPicker = () => { const classes = useStyles(); const { updateFilters, backendEntities, filters, queryParameters } = useEntityListProvider(); - const queryParamOwners = [queryParameters.owners] - .flat() - .filter(Boolean) as string[]; + const queryParamOwners = useMemo( + () => [queryParameters.owners].flat().filter(Boolean) as string[], + [queryParameters], + ); + const [selectedOwners, setSelectedOwners] = useState( queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], ); + // Set selected owners on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamOwners.length) { + setSelectedOwners(queryParamOwners); + } + }, [queryParamOwners]); + useEffect(() => { updateFilters({ owners: selectedOwners.length diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx index 02e63e60e3..aa92fd0818 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLink.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity, EntityName, @@ -25,6 +26,11 @@ import { Link, LinkProps } from '@backstage/core-components'; import { useRouteRef } from '@backstage/core-plugin-api'; import { Tooltip } from '@material-ui/core'; +/** + * Props for {@link EntityRefLink}. + * + * @public + */ export type EntityRefLinkProps = { entityRef: Entity | EntityName; defaultKind?: string; @@ -32,6 +38,11 @@ export type EntityRefLinkProps = { children?: React.ReactNode; } & Omit; +/** + * Shows a clickable link to an entity. + * + * @public + */ export const EntityRefLink = forwardRef( (props, ref) => { const { entityRef, defaultKind, title, children, ...linkProps } = props; diff --git a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx index 4a990f4028..9be970aa56 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx +++ b/plugins/catalog-react/src/components/EntityRefLink/EntityRefLinks.tsx @@ -13,16 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Entity, EntityName } from '@backstage/catalog-model'; import React from 'react'; import { EntityRefLink } from './EntityRefLink'; import { LinkProps } from '@backstage/core-components'; +/** + * Props for {@link EntityRefLink}. + * + * @public + */ export type EntityRefLinksProps = { entityRefs: (Entity | EntityName)[]; defaultKind?: string; } & Omit; +/** + * Shows a list of clickable links to entities. + * + * @public + */ export const EntityRefLinks = ({ entityRefs, defaultKind, diff --git a/plugins/catalog-react/src/components/EntityRefLink/index.ts b/plugins/catalog-react/src/components/EntityRefLink/index.ts index 1c5297e2f2..d49993feb6 100644 --- a/plugins/catalog-react/src/components/EntityRefLink/index.ts +++ b/plugins/catalog-react/src/components/EntityRefLink/index.ts @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { EntityRefLink } from './EntityRefLink'; +export type { EntityRefLinkProps } from './EntityRefLink'; export { EntityRefLinks } from './EntityRefLinks'; +export type { EntityRefLinksProps } from './EntityRefLinks'; export { formatEntityRefTitle } from './format'; diff --git a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx index 79b2ee36bf..ec977fada4 100644 --- a/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx +++ b/plugins/catalog-react/src/components/EntitySearchBar/EntitySearchBar.tsx @@ -45,6 +45,7 @@ const useStyles = makeStyles( }, ); +/** @public */ export const EntitySearchBar = () => { const classes = useStyles(); diff --git a/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx index 43da2b3612..62c33701b4 100644 --- a/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx +++ b/plugins/catalog-react/src/components/EntityTable/EntityTable.tsx @@ -21,13 +21,18 @@ import * as columnFactories from './columns'; import { componentEntityColumns, systemEntityColumns } from './presets'; import { Table, TableColumn } from '@backstage/core-components'; -type Props = { +/** + * Props for {@link EntityTable}. + * + * @public + */ +export interface EntityTableProps { title: string; variant?: 'gridItem'; entities: T[]; emptyContent?: ReactNode; columns: TableColumn[]; -}; +} const useStyles = makeStyles(theme => ({ empty: { @@ -37,13 +42,21 @@ const useStyles = makeStyles(theme => ({ }, })); -export function EntityTable({ - entities, - title, - emptyContent, - variant = 'gridItem', - columns, -}: Props) { +/** + * A general entity table component, that can be used for composing more + * specific entity tables. + * + * @public + */ +export function EntityTable(props: EntityTableProps) { + const { + entities, + title, + emptyContent, + variant = 'gridItem', + columns, + } = props; + const classes = useStyles(); const tableStyle: React.CSSProperties = { minWidth: '0', diff --git a/plugins/catalog-react/src/components/EntityTable/columns.tsx b/plugins/catalog-react/src/components/EntityTable/columns.tsx index 1f4f5ee0d0..f6198fa1dc 100644 --- a/plugins/catalog-react/src/components/EntityTable/columns.tsx +++ b/plugins/catalog-react/src/components/EntityTable/columns.tsx @@ -29,11 +29,11 @@ import { formatEntityRefTitle, } from '../EntityRefLink'; -export function createEntityRefColumn({ - defaultKind, -}: { +/** @public */ +export function createEntityRefColumn(options: { defaultKind?: string; }): TableColumn { + const { defaultKind } = options; function formatContent(entity: T): string { return ( entity.metadata?.title || @@ -49,7 +49,7 @@ export function createEntityRefColumn({ customFilterAndSearch(filter, entity) { // TODO: We could implement this more efficiently, like searching over // each field that is displayed individually (kind, namespace, name). - // but that migth confuse the user as it will behave different than a + // but that might confuse the user as it will behave different than a // simple text search. // Another alternative would be to cache the values. But writing them // into the entity feels bad too. @@ -70,6 +70,7 @@ export function createEntityRefColumn({ }; } +/** @public */ export function createEntityRelationColumn({ title, relation, @@ -110,6 +111,7 @@ export function createEntityRelationColumn({ }; } +/** @public */ export function createOwnerColumn(): TableColumn { return createEntityRelationColumn({ title: 'Owner', @@ -118,6 +120,7 @@ export function createOwnerColumn(): TableColumn { }); } +/** @public */ export function createDomainColumn(): TableColumn { return createEntityRelationColumn({ title: 'Domain', @@ -129,6 +132,7 @@ export function createDomainColumn(): TableColumn { }); } +/** @public */ export function createSystemColumn(): TableColumn { return createEntityRelationColumn({ title: 'System', @@ -140,6 +144,7 @@ export function createSystemColumn(): TableColumn { }); } +/** @public */ export function createMetadataDescriptionColumn< T extends Entity, >(): TableColumn { @@ -157,6 +162,7 @@ export function createMetadataDescriptionColumn< }; } +/** @public */ export function createSpecLifecycleColumn(): TableColumn { return { title: 'Lifecycle', @@ -164,6 +170,7 @@ export function createSpecLifecycleColumn(): TableColumn { }; } +/** @public */ export function createSpecTypeColumn(): TableColumn { return { title: 'Type', diff --git a/plugins/catalog-react/src/components/EntityTable/index.ts b/plugins/catalog-react/src/components/EntityTable/index.ts index 36203e7929..2ed07f9823 100644 --- a/plugins/catalog-react/src/components/EntityTable/index.ts +++ b/plugins/catalog-react/src/components/EntityTable/index.ts @@ -13,4 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export { EntityTable } from './EntityTable'; +export type { EntityTableProps } from './EntityTable'; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index 770c187b4e..c6b985bc8b 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -149,4 +149,34 @@ describe('', () => { tags: undefined, }); }); + + it('responds to external queryParameters changes', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }); + rendered.rerender( + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag2']), + }); + }); }); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index a25c50c0a9..a91389a653 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -46,18 +46,29 @@ const useStyles = makeStyles( const icon = ; const checkedIcon = ; +/** @public */ export const EntityTagPicker = () => { const classes = useStyles(); const { updateFilters, backendEntities, filters, queryParameters } = useEntityListProvider(); - const queryParamTags = [queryParameters.tags] - .flat() - .filter(Boolean) as string[]; + const queryParamTags = useMemo( + () => [queryParameters.tags].flat().filter(Boolean) as string[], + [queryParameters], + ); + const [selectedTags, setSelectedTags] = useState( queryParamTags.length ? queryParamTags : filters.tags?.values ?? [], ); + // Set selected tags on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamTags.length) { + setSelectedTags(queryParamTags); + } + }, [queryParamTags]); + useEffect(() => { updateFilters({ tags: selectedTags.length ? new EntityTagFilter(selectedTags) : undefined, diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index f6e9d296df..9ed27bbef8 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -151,4 +151,38 @@ describe('', () => { type: new EntityTypeFilter(['tool']), }); }); + + it('responds to external queryParameters changes', async () => { + const updateFilters = jest.fn(); + const rendered = await renderWithEffects( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + type: new EntityTypeFilter(['service']), + }); + rendered.rerender( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + type: new EntityTypeFilter(['tool']), + }); + }); }); diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx index 225193dc93..a0770ef30a 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.tsx @@ -22,12 +22,18 @@ import { useEntityTypeFilter } from '../../hooks/useEntityTypeFilter'; import { alertApiRef, useApi } from '@backstage/core-plugin-api'; import { Select } from '@backstage/core-components'; -export type EntityTypeFilterProps = { +/** + * Props for {@link EntityTypePicker}. + * + * @public + */ +export interface EntityTypePickerProps { initialFilter?: string; hidden?: boolean; -}; +} -export const EntityTypePicker = (props: EntityTypeFilterProps) => { +/** @public */ +export const EntityTypePicker = (props: EntityTypePickerProps) => { const { hidden, initialFilter } = props; const alertApi = useApi(alertApiRef); const { error, availableTypes, selectedTypes, setSelectedTypes } = diff --git a/plugins/catalog-react/src/components/EntityTypePicker/index.ts b/plugins/catalog-react/src/components/EntityTypePicker/index.ts index 1124b5c30c..03351337b7 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/index.ts +++ b/plugins/catalog-react/src/components/EntityTypePicker/index.ts @@ -15,4 +15,4 @@ */ export { EntityTypePicker } from './EntityTypePicker'; -export type { EntityTypeFilterProps } from './EntityTypePicker'; +export type { EntityTypePickerProps } from './EntityTypePicker'; diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx index b776f26926..d4cfedecbf 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx @@ -253,6 +253,42 @@ describe('', () => { }); }); + it('responds to external queryParameters changes', () => { + const updateFilters = jest.fn(); + const rendered = render( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity), + }); + rendered.rerender( + + + + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity), + }); + }); + describe.each` type | filterFn ${'owned'} | ${mockIsOwnedEntity} diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx index d731a185fc..cbf6089f2e 100644 --- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx +++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx @@ -161,8 +161,13 @@ export const UserListPicker = ({ [isOwnedEntity, isStarredEntity], ); + const queryParamUserFilter = useMemo( + () => [queryParameters.user].flat()[0], + [queryParameters], + ); + const [selectedUserFilter, setSelectedUserFilter] = useState( - [queryParameters.user].flat()[0] ?? initialFilter, + queryParamUserFilter ?? initialFilter, ); // To show proper counts for each section, apply all other frontend filters _except_ the user @@ -190,6 +195,14 @@ export const UserListPicker = ({ [entitiesWithoutUserFilter, starredFilter, ownedFilter], ); + // Set selected user filter on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamUserFilter) { + setSelectedUserFilter(queryParamUserFilter as UserListFilterKind); + } + }, [queryParamUserFilter]); + useEffect(() => { if ( !loading && diff --git a/plugins/catalog-react/src/components/index.ts b/plugins/catalog-react/src/components/index.ts index 0977e86d58..b80472a81d 100644 --- a/plugins/catalog-react/src/components/index.ts +++ b/plugins/catalog-react/src/components/index.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export * from './EntityKindPicker'; export * from './EntityLifecyclePicker'; export * from './EntityOwnerPicker'; diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index a1e722bc6d..548561108d 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -27,6 +27,7 @@ import { MockStorageApi, TestApiProvider } from '@backstage/test-utils'; import { act, renderHook } from '@testing-library/react-hooks'; import qs from 'qs'; import React, { PropsWithChildren } from 'react'; +import { MemoryRouter } from 'react-router'; import { catalogApiRef } from '../api'; import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis'; import { EntityKindPicker, UserListPicker } from '../components'; @@ -82,31 +83,35 @@ const mockCatalogApi: Partial = { const wrapper = ({ userFilter, + location, children, }: PropsWithChildren<{ userFilter?: UserListFilterKind; + location?: string; }>) => { return ( - - - - + + + + + + ); }; @@ -168,10 +173,11 @@ describe('', () => { const query = qs.stringify({ filters: { kind: 'component', type: 'service' }, }); - delete (window as any).location; - (window as any).location = new URL(`http://localhost/catalog?${query}`); const { result, waitFor } = renderHook(() => useEntityListProvider(), { wrapper, + initialProps: { + location: `/catalog?${query}`, + }, }); await waitFor(() => !!result.current.queryParameters); expect(result.current.queryParameters).toEqual({ diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index fb8eb7279f..9b4d850208 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -25,6 +25,7 @@ import React, { useMemo, useState, } from 'react'; +import { useLocation } from 'react-router'; import useAsyncFn from 'react-use/lib/useAsyncFn'; import useDebounce from 'react-use/lib/useDebounce'; import useMountedState from 'react-use/lib/useMountedState'; @@ -98,7 +99,6 @@ type OutputState = { appliedFilters: EntityFilters; entities: Entity[]; backendEntities: Entity[]; - queryParameters: Record; }; export const EntityListProvider = ({ @@ -109,19 +109,26 @@ export const EntityListProvider = ({ const [requestedFilters, setRequestedFilters] = useState( {} as EntityFilters, ); + + // We use react-router's useLocation hook so updates from external sources trigger an update to + // the queryParameters in outputState. Updates from this hook use replaceState below and won't + // trigger a useLocation change; this would instead come from an external source, such as a manual + // update of the URL or two catalog sidebar links with different catalog filters. + const location = useLocation(); + const queryParameters = useMemo( + () => + (qs.parse(location.search, { + ignoreQueryPrefix: true, + }).filters ?? {}) as Record, + [location], + ); + const [outputState, setOutputState] = useState>( () => { - const query = qs.parse(window.location.search, { - ignoreQueryPrefix: true, - }); return { appliedFilters: {} as EntityFilters, entities: [], backendEntities: [], - queryParameters: (query.filters ?? {}) as Record< - string, - string | string[] - >, }; }, ); @@ -163,19 +170,17 @@ export const EntityListProvider = ({ appliedFilters: requestedFilters, backendEntities: response.items, entities: response.items.filter(entityFilter), - queryParameters: queryParams, }); } else { setOutputState({ appliedFilters: requestedFilters, backendEntities: outputState.backendEntities, entities: outputState.backendEntities.filter(entityFilter), - queryParameters: queryParams, }); } if (isMounted()) { - const oldParams = qs.parse(window.location.search, { + const oldParams = qs.parse(location.search, { ignoreQueryPrefix: true, }); const newParams = qs.stringify( @@ -191,7 +196,7 @@ export const EntityListProvider = ({ window.history?.replaceState(null, document.title, newUrl); } }, - [catalogApi, requestedFilters, outputState], + [catalogApi, queryParameters, requestedFilters, outputState], { loading: true }, ); @@ -220,11 +225,11 @@ export const EntityListProvider = ({ entities: outputState.entities, backendEntities: outputState.backendEntities, updateFilters, - queryParameters: outputState.queryParameters, + queryParameters, loading, error, }), - [outputState, updateFilters, loading, error], + [outputState, updateFilters, queryParameters, loading, error], ); return ( diff --git a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx index 5c0139dd26..c4531d9117 100644 --- a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx +++ b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx @@ -42,13 +42,23 @@ export function useEntityTypeFilter(): EntityTypeReturn { updateFilters, } = useEntityListProvider(); - const queryParamTypes = [queryParameters.type] - .flat() - .filter(Boolean) as string[]; + const queryParamTypes = useMemo( + () => [queryParameters.type].flat().filter(Boolean) as string[], + [queryParameters], + ); + const [selectedTypes, setSelectedTypes] = useState( queryParamTypes.length ? queryParamTypes : typeFilter?.getTypes() ?? [], ); + // Set selected types on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamTypes.length) { + setSelectedTypes(queryParamTypes); + } + }, [queryParamTypes]); + const [availableTypes, setAvailableTypes] = useState([]); const kind = useMemo(() => kindFilter?.value, [kindFilter]); diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx index 2172f6c5a9..3fb295298c 100644 --- a/plugins/catalog-react/src/testUtils/providers.tsx +++ b/plugins/catalog-react/src/testUtils/providers.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import React, { PropsWithChildren, useCallback, useState } from 'react'; +import React, { + PropsWithChildren, + useCallback, + useMemo, + useState, +} from 'react'; import { DefaultEntityFilters, EntityListContext, @@ -32,6 +37,7 @@ export const MockEntityListContextProvider = ({ const [filters, setFilters] = useState( value?.filters ?? {}, ); + const updateFilters = useCallback( ( update: @@ -49,23 +55,32 @@ export const MockEntityListContextProvider = ({ [], ); - const defaultContext: EntityListContextProps = { - entities: [], - backendEntities: [], - updateFilters, - filters, - loading: false, - queryParameters: {}, - }; + // Memoize the default values since pickers have useEffect triggers on these; naively defaulting + // below with `?? ` breaks referential equality on subsequent updates. + const defaultValues = useMemo( + () => ({ + entities: [], + backendEntities: [], + queryParameters: {}, + }), + [], + ); - // Extract value.filters to avoid overwriting it; some tests exercise filter updates. The value - // provided is used as the initial seed in useState above. - const { filters: _, ...otherContextFields } = value ?? {}; + const resolvedValue: EntityListContextProps = useMemo( + () => ({ + entities: value?.entities ?? defaultValues.entities, + backendEntities: value?.backendEntities ?? defaultValues.backendEntities, + updateFilters: value?.updateFilters ?? updateFilters, + filters, + loading: value?.loading ?? false, + queryParameters: value?.queryParameters ?? defaultValues.queryParameters, + error: value?.error, + }), + [value, defaultValues, filters, updateFilters], + ); return ( - + {children} ); diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index fab2b05bb6..264189ee07 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -113,6 +113,13 @@ export const HomePageRandomJoke: ( }, ) => JSX.Element; +// @public +export const HomePageStarredEntities: ( + props: ComponentRenderer & { + title?: string | undefined; + }, +) => JSX.Element; + // Warning: (ae-forgotten-export) The symbol "ToolkitContentProps" needs to be exported by the entry point index.d.ts // // @public diff --git a/plugins/home/package.json b/plugins/home/package.json index a1a09329f8..b1d4fc362e 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -21,10 +21,12 @@ "clean": "backstage-cli clean" }, "dependencies": { + "@backstage/catalog-model": "^0.9.10", "@backstage/core-components": "^0.8.7", "@backstage/core-plugin-api": "^0.6.0", - "@backstage/theme": "^0.2.14", + "@backstage/plugin-catalog-react": "^0.6.13", "@backstage/plugin-search": "^0.6.1", + "@backstage/theme": "^0.2.14", "@material-ui/core": "^4.12.2", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", diff --git a/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx new file mode 100644 index 0000000000..ccc36f6964 --- /dev/null +++ b/plugins/home/src/homePageComponents/StarredEntities/Content.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { + renderInTestApp, + TestApiProvider, + MockStorageApi, +} from '@backstage/test-utils'; +import { + starredEntitiesApiRef, + entityRouteRef, + DefaultStarredEntitiesApi, +} from '@backstage/plugin-catalog-react'; +import React from 'react'; +import { Content } from './Content'; + +describe('StarredEntitiesContent', () => { + it('should render list of tools', async () => { + const mockStorageApi = MockStorageApi.create(); + await mockStorageApi + .forBucket('starredEntities') + .set('entityRefs', [ + 'component:default/mock-starred-entity', + 'component:default/mock-starred-entity-2', + ]); + + const { getByText } = await renderInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(getByText('mock-starred-entity')).toBeInTheDocument(); + expect(getByText('mock-starred-entity-2')).toBeInTheDocument(); + expect(getByText('mock-starred-entity').closest('a')).toHaveAttribute( + 'href', + '/catalog/default/component/mock-starred-entity', + ); + expect(getByText('mock-starred-entity-2').closest('a')).toHaveAttribute( + 'href', + '/catalog/default/component/mock-starred-entity-2', + ); + }); +}); diff --git a/plugins/home/src/homePageComponents/StarredEntities/Content.tsx b/plugins/home/src/homePageComponents/StarredEntities/Content.tsx new file mode 100644 index 0000000000..6c9775c842 --- /dev/null +++ b/plugins/home/src/homePageComponents/StarredEntities/Content.tsx @@ -0,0 +1,75 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { + useStarredEntities, + entityRouteRef, +} from '@backstage/plugin-catalog-react'; +import { parseEntityRef } from '@backstage/catalog-model'; +import { useRouteRef } from '@backstage/core-plugin-api'; +import { Link } from '@backstage/core-components'; +import { + List, + ListItem, + ListItemSecondaryAction, + IconButton, + ListItemText, + Tooltip, + Typography, +} from '@material-ui/core'; +import StarIcon from '@material-ui/icons/Star'; +import React from 'react'; + +/** + * A component to display a list of starred entities for the user. + * + * @public + */ + +export const Content = () => { + const catalogEntityRoute = useRouteRef(entityRouteRef); + const { starredEntities, toggleStarredEntity } = useStarredEntities(); + + if (starredEntities.size === 0) + return ( + + You do not have any starred entities yet! + + ); + + return ( + + {Array.from(starredEntities).map(entity => ( + + + + + + + toggleStarredEntity(entity)} + > + + + + + + ))} + + ); +}; diff --git a/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx b/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx new file mode 100644 index 0000000000..2e63762369 --- /dev/null +++ b/plugins/home/src/homePageComponents/StarredEntities/StarredEntities.stories.tsx @@ -0,0 +1,73 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { HomePageStarredEntities } from '../../plugin'; +import { + wrapInTestApp, + TestApiProvider, + MockStorageApi, +} from '@backstage/test-utils'; +import { + starredEntitiesApiRef, + entityRouteRef, + DefaultStarredEntitiesApi, +} from '@backstage/plugin-catalog-react'; +import { Grid } from '@material-ui/core'; +import React, { ComponentType } from 'react'; + +const mockStorageApi = MockStorageApi.create(); +mockStorageApi + .forBucket('starredEntities') + .set('entityRefs', [ + 'component:default/example-starred-entity', + 'component:default/example-starred-entity-2', + 'component:default/example-starred-entity-3', + 'component:default/example-starred-entity-4', + ]); + +export default { + title: 'Plugins/Home/Components/StarredEntities', + decorators: [ + (Story: ComponentType<{}>) => + wrapInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ), + ], +}; + +export const Default = () => { + return ( + + + + ); +}; diff --git a/plugins/home/src/homePageComponents/StarredEntities/index.ts b/plugins/home/src/homePageComponents/StarredEntities/index.ts new file mode 100644 index 0000000000..1faa9a2426 --- /dev/null +++ b/plugins/home/src/homePageComponents/StarredEntities/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { Content } from './Content'; diff --git a/plugins/home/src/index.ts b/plugins/home/src/index.ts index dcd61f1fa3..4871323677 100644 --- a/plugins/home/src/index.ts +++ b/plugins/home/src/index.ts @@ -26,6 +26,7 @@ export { HomePageRandomJoke, HomePageToolkit, HomePageCompanyLogo, + HomePageStarredEntities, ComponentAccordion, ComponentTabs, ComponentTab, diff --git a/plugins/home/src/plugin.ts b/plugins/home/src/plugin.ts index 135ac97af0..32005a9064 100644 --- a/plugins/home/src/plugin.ts +++ b/plugins/home/src/plugin.ts @@ -115,3 +115,16 @@ export const HomePageToolkit = homePlugin.provide( components: () => import('./homePageComponents/Toolkit'), }), ); + +/** + * A component to display a list of starred entities for the user. + * + * @public + */ +export const HomePageStarredEntities = homePlugin.provide( + createCardExtension({ + name: 'HomePageStarredEntities', + title: 'Your Starred Entities', + components: () => import('./homePageComponents/StarredEntities'), + }), +); diff --git a/plugins/home/src/templates/DefaultTemplate.stories.tsx b/plugins/home/src/templates/DefaultTemplate.stories.tsx index fb62591fbd..ba5e1f33fd 100644 --- a/plugins/home/src/templates/DefaultTemplate.stories.tsx +++ b/plugins/home/src/templates/DefaultTemplate.stories.tsx @@ -14,20 +14,38 @@ * limitations under the License. */ -import {TemplateBackstageLogo} from './TemplateBackstageLogo'; -import {TemplateBackstageLogoIcon} from './TemplateBackstageLogoIcon'; -import { HomePageToolkit, HomePageCompanyLogo } from '../plugin'; -import { wrapInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { TemplateBackstageLogo } from './TemplateBackstageLogo'; +import { TemplateBackstageLogoIcon } from './TemplateBackstageLogoIcon'; +import { + HomePageToolkit, + HomePageCompanyLogo, + HomePageStarredEntities, +} from '../plugin'; +import { wrapInTestApp, TestApiProvider, MockStorageApi} from '@backstage/test-utils'; import { Content, Page, InfoCard } from '@backstage/core-components'; +import { + starredEntitiesApiRef, + entityRouteRef, + DefaultStarredEntitiesApi +} from '@backstage/plugin-catalog-react'; import { HomePageSearchBar, SearchContextProvider, searchApiRef, - searchPlugin + searchPlugin, } from '@backstage/plugin-search'; import { Grid, makeStyles } from '@material-ui/core'; -import React, { ComponentType} from 'react'; +import React, { ComponentType } from 'react'; +const mockStorageApi = MockStorageApi.create(); +mockStorageApi + .forBucket('starredEntities') + .set('entityRefs', [ + 'component:default/example-starred-entity', + 'component:default/example-starred-entity-2', + 'component:default/example-starred-entity-3', + 'component:default/example-starred-entity-4' + ]); export default { title: 'Plugins/Home/Templates', @@ -35,13 +53,26 @@ export default { (Story: ComponentType<{}>) => wrapInTestApp( <> - Promise.resolve({results: []})}]]}> - + Promise.resolve({ results: [] }) }], + ]} + > + , { - mountedRoutes: {'/hello-company': searchPlugin.routes.root } - } + mountedRoutes: { + '/hello-company': searchPlugin.routes.root, + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, ), ], }; @@ -82,20 +113,17 @@ export const DefaultTemplate = () => { } + logo={} /> - - {/* placeholder for content */} -
- + JSX.Element; @@ -79,5 +80,10 @@ export type TodoListResult = { }; // @public -export const todoPlugin: BackstagePlugin<{}, {}>; +export const todoPlugin: BackstagePlugin< + { + entityContent: RouteRef; + }, + {} +>; ``` diff --git a/plugins/todo/package.json b/plugins/todo/package.json index fc9893fcd9..fb433992a1 100644 --- a/plugins/todo/package.json +++ b/plugins/todo/package.json @@ -52,6 +52,7 @@ "@types/jest": "^26.0.7", "@types/node": "^14.14.32", "cross-fetch": "^3.0.6", + "react-router": "6.0.0-beta.0", "msw": "^0.35.0" }, "files": [ diff --git a/plugins/todo/src/plugin.test.tsx b/plugins/todo/src/plugin.test.tsx new file mode 100644 index 0000000000..57b9be291d --- /dev/null +++ b/plugins/todo/src/plugin.test.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2021 The Backstage Authors + * + * 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 { Route } from 'react-router'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { todoPlugin, EntityTodoContent } from './plugin'; +import { todoApiRef } from './api'; + +describe('todo', () => { + it('should export plugin', () => { + expect(todoPlugin).toBeDefined(); + }); + + it('should render EntityTodoContent', async () => { + const rendered = await renderInTestApp( + ({ + items: [ + { + tag: 'FIXME', + text: 'Make sure this test works', + }, + ], + limit: 10, + offset: 0, + totalCount: 1, + }), + }, + ], + ]} + > + } /> + , + ); + + await expect(rendered.findByText('FIXME')).resolves.toBeInTheDocument(); + }); +}); diff --git a/plugins/todo/src/plugin.ts b/plugins/todo/src/plugin.ts index a7d3702f2c..d74acfb02c 100644 --- a/plugins/todo/src/plugin.ts +++ b/plugins/todo/src/plugin.ts @@ -17,10 +17,11 @@ import { todoApiRef, TodoClient } from './api'; import { createApiFactory, createPlugin, - createComponentExtension, + createRoutableExtension, discoveryApiRef, identityApiRef, } from '@backstage/core-plugin-api'; +import { rootRouteRef } from './routes'; /** * The Todo plugin instance. @@ -42,7 +43,7 @@ export const todoPlugin = createPlugin({ }), ], routes: { - // root: rootRouteRef, + entityContent: rootRouteRef, }, }); @@ -52,12 +53,9 @@ export const todoPlugin = createPlugin({ * @public */ export const EntityTodoContent = todoPlugin.provide( - createComponentExtension({ + createRoutableExtension({ name: 'EntityTodoContent', - component: { - lazy: () => import('./components/TodoList').then(m => m.TodoList), - }, - // TODO(Rugvip): Switch back to routable extension once apps are migrated - // mountPoint: rootRouteRef, + component: () => import('./components/TodoList').then(m => m.TodoList), + mountPoint: rootRouteRef, }), ); diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index dfbd91f76b..1ac43d242d 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -218,7 +218,9 @@ const NO_WARNING_PACKAGES = [ 'packages/types', 'packages/version-bridge', 'plugins/catalog-backend-module-ldap', + 'plugins/catalog-backend-module-msgraph', 'plugins/catalog-common', + 'plugins/catalog-graph', 'plugins/permission-backend', 'plugins/permission-common', 'plugins/permission-node', diff --git a/yarn.lock b/yarn.lock index 37bd2c331a..ddfec88ac7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5147,10 +5147,10 @@ dependencies: "@types/express" "*" -"@types/concat-stream@^1.6.0": - version "1.6.1" - resolved "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.1.tgz#24bcfc101ecf68e886aaedce60dfd74b632a1b74" - integrity sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA== +"@types/concat-stream@^2.0.0": + version "2.0.0" + resolved "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-2.0.0.tgz#a716f0ba9015014e643addb351da05a73bef425c" + integrity sha512-t3YCerNM7NTVjLuICZo5gYAXYoDvpuuTceCcFQWcDQz26kxUR5uIWolxbIR5jRNIXpMqhOpW/b8imCR1LEmuJw== dependencies: "@types/node" "*" @@ -13488,10 +13488,10 @@ humanize-ms@^1.2.1: dependencies: ms "^2.0.0" -husky@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/husky/-/husky-6.0.0.tgz#810f11869adf51604c32ea577edbc377d7f9319e" - integrity sha512-SQS2gDTB7tBN486QSoKPKQItZw97BMOd+Kdb6ghfpBc0yXyzrddI0oDV5MkDAbuB4X2mO3/nj60TRMcYxwzZeQ== +husky@^7.0.4: + version "7.0.4" + resolved "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz#242048245dc49c8fb1bf0cc7cfb98dd722531535" + integrity sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ== hyphenate-style-name@^1.0.2, hyphenate-style-name@^1.0.3: version "1.0.3" @@ -15830,9 +15830,9 @@ linkify-it@^3.0.1: uc.micro "^1.0.1" lint-staged@^12.2.0: - version "12.2.2" - resolved "https://registry.npmjs.org/lint-staged/-/lint-staged-12.2.2.tgz#e03d93b41092316e0f38b37c9630da807aae3cca" - integrity sha512-bcHEoM1M/f+K1BYdHcEuIn8K+zMOSJR3mkny6PAuQiTgcSUcRbUWaUD6porAYypxF4k1vYZZ2HutZt1p94Z1jQ== + version "12.3.3" + resolved "https://registry.npmjs.org/lint-staged/-/lint-staged-12.3.3.tgz#0a465962fe53baa2b4b9da50801ead49a910e03b" + integrity sha512-OqcLsqcPOqzvsfkxjeBpZylgJ3SRG1RYqc9LxC6tkt6tNsq1bNVkAixBwX09f6CobcHswzqVOCBpFR1Fck0+ag== dependencies: cli-truncate "^3.1.0" colorette "^2.0.16" @@ -15840,10 +15840,10 @@ lint-staged@^12.2.0: debug "^4.3.3" execa "^5.1.1" lilconfig "2.0.4" - listr2 "^3.13.5" + listr2 "^4.0.1" micromatch "^4.0.4" normalize-path "^3.0.0" - object-inspect "^1.11.1" + object-inspect "^1.12.0" string-argv "^0.3.1" supports-color "^9.2.1" yaml "^1.10.2" @@ -15882,17 +15882,17 @@ listr-verbose-renderer@^0.5.0: date-fns "^1.27.2" figures "^2.0.0" -listr2@^3.13.5: - version "3.14.0" - resolved "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz#23101cc62e1375fd5836b248276d1d2b51fdbe9e" - integrity sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g== +listr2@^4.0.1: + version "4.0.2" + resolved "https://registry.npmjs.org/listr2/-/listr2-4.0.2.tgz#04d66f8c8694a14920d7df08ebe01568948fb500" + integrity sha512-YcgwfCWpvPbj9FLUGqvdFvd3hrFWKpOeuXznRgfWEJ7RNr8b/IKKIKZABHx3aU+4CWN/iSAFFSReziQG6vTeIA== dependencies: cli-truncate "^2.1.0" colorette "^2.0.16" log-update "^4.0.0" p-map "^4.0.0" rfdc "^1.3.0" - rxjs "^7.5.1" + rxjs "^7.5.2" through "^2.3.8" wrap-ansi "^7.0.0" @@ -18154,7 +18154,7 @@ object-hash@^2.0.1, object-hash@^2.1.1, object-hash@^2.2.0: resolved "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== -object-inspect@^1.11.0, object-inspect@^1.11.1, object-inspect@^1.9.0: +object-inspect@^1.11.0, object-inspect@^1.12.0, object-inspect@^1.9.0: version "1.12.0" resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz#6e2c120e868fd1fd18cb4f18c31741d0d6e776f0" integrity sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g== @@ -21343,7 +21343,7 @@ rxjs@^6.3.3, rxjs@^6.4.0, rxjs@^6.6.0, rxjs@^6.6.3: dependencies: tslib "^1.9.0" -rxjs@^7.1.0, rxjs@^7.2.0, rxjs@^7.5.1: +rxjs@^7.1.0, rxjs@^7.2.0, rxjs@^7.5.2: version "7.5.2" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.5.2.tgz#11e4a3a1dfad85dbf7fb6e33cbba17668497490b" integrity sha512-PwDt186XaL3QN5qXj/H9DGyHhP3/RYYgZZwqBv9Tv8rsAaiwFH1IsJJlcgD37J7UW5a6O67qX0KWKS3/pu0m4w==