diff --git a/.changeset/brave-books-pretend.md b/.changeset/brave-books-pretend.md new file mode 100644 index 0000000000..ee80aedb18 --- /dev/null +++ b/.changeset/brave-books-pretend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-playlist': patch +--- + +change color of delete icon to secondary in playlist table diff --git a/.changeset/early-trains.draw.md b/.changeset/early-trains.draw.md new file mode 100644 index 0000000000..30aad1a08d --- /dev/null +++ b/.changeset/early-trains.draw.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-tech-radar': patch +--- + +Fixed the width of the tech radar dialog so that the content fits diff --git a/.changeset/four-masks-fry.md b/.changeset/four-masks-fry.md new file mode 100644 index 0000000000..94bd929016 --- /dev/null +++ b/.changeset/four-masks-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Update OpenAPI schema to relax the encoding validation of all request parameters. diff --git a/.changeset/healthy-spiders-arrive.md b/.changeset/healthy-spiders-arrive.md new file mode 100644 index 0000000000..e8d110ccaa --- /dev/null +++ b/.changeset/healthy-spiders-arrive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Ensure direct relations are shown for User entities while keeping support for aggregating closest parent group ownership diff --git a/.changeset/many-tips-attend.md b/.changeset/many-tips-attend.md new file mode 100644 index 0000000000..34b5898803 --- /dev/null +++ b/.changeset/many-tips-attend.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +Optionally initializes the search context with default settings for search queries only when the config is defined, rather than always overriding it. diff --git a/.changeset/polite-forks-grin.md b/.changeset/polite-forks-grin.md new file mode 100644 index 0000000000..8f6570ccbc --- /dev/null +++ b/.changeset/polite-forks-grin.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fixed validation of the `fullTextFilterFields` query parameter. diff --git a/.changeset/rude-dolphins-reply.md b/.changeset/rude-dolphins-reply.md new file mode 100644 index 0000000000..89545cde1f --- /dev/null +++ b/.changeset/rude-dolphins-reply.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs-module-addons-contrib': minor +--- + +Enable zoom icon for techdocs images inside lightbox diff --git a/.changeset/silly-emus-remain.md b/.changeset/silly-emus-remain.md new file mode 100644 index 0000000000..4fe605080a --- /dev/null +++ b/.changeset/silly-emus-remain.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add examples for `github:actions:dispatch` scaffolder actions. diff --git a/.changeset/tall-peas-visit.md b/.changeset/tall-peas-visit.md new file mode 100644 index 0000000000..3e3ca76e46 --- /dev/null +++ b/.changeset/tall-peas-visit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Fix issue with `catalogFileName` not being a required property for `/analyze-location` diff --git a/.changeset/twelve-elephants-pay.md b/.changeset/twelve-elephants-pay.md new file mode 100644 index 0000000000..08fb983b6f --- /dev/null +++ b/.changeset/twelve-elephants-pay.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-techdocs-backend': minor +'@backstage/plugin-techdocs-node': minor +'@techdocs/cli': minor +--- + +Add optional config and cli option for techdocs to specify default mkdocs plugins. diff --git a/.changeset/witty-ants-cover.md b/.changeset/witty-ants-cover.md new file mode 100644 index 0000000000..7f499a88ae --- /dev/null +++ b/.changeset/witty-ants-cover.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Allow specifying static widgets to custom home page diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f58291912d..922f7588a5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -58,8 +58,8 @@ yarn.lock @backstage/maintainers @backst /plugins/home-* @backstage/discoverability-maintainers /plugins/kafka @backstage/maintainers @backstage/reviewers @andrewthauer /plugins/kafka-backend @backstage/maintainers @backstage/reviewers @andrewthauer -/plugins/kubernetes @backstage/maintainers @backstage/reviewers @backstage/kubernetes-maintainers -/plugins/kubernetes-* @backstage/maintainers @backstage/reviewers @backstage/kubernetes-maintainers +/plugins/kubernetes @backstage/kubernetes-maintainers +/plugins/kubernetes-* @backstage/kubernetes-maintainers /plugins/linguist @backstage/maintainers @backstage/reviewers @awanlin /plugins/linguist-backend @backstage/maintainers @backstage/reviewers @awanlin /plugins/linguist-common @backstage/maintainers @backstage/reviewers @awanlin diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml index 6fdfd5d5a4..231b81598e 100644 --- a/.github/workflows/deploy_docker-image.yml +++ b/.github/workflows/deploy_docker-image.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16.x] + node-version: [18.x] steps: - name: checkout diff --git a/OWNERS.md b/OWNERS.md index 85f7fe545a..6c26078f8c 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -94,11 +94,13 @@ Scope: The TechDocs plugin and related tooling | ------------------------ | ------------ | ------- | ------------------------------------------------- | ------------------- | | Alex Lorenzi | Spotify | ProTean | [AlexLorenzi](https://github.com/AlexLorenzi) | `alexlorenzi` | | Bond Yan | Spotify | ProTean | [byan1197](https://github.com/byan1197) | `bondify` | +| Daniel Lochrie | Spotify | ProTean | [dlochrie](https://github.com/dlochrie) | - | | Jackson Chen | Spotify | ProTean | [PeaWarrior](https://github.com/PeaWarrior) | `jacksonc#3322` | | John Philip | Spotify | ProTean | [johnphilip283](https://github.com/johnphilip283) | `john_philip#2399` | | Morgan Bentell | Spotify | ProTean | [agentbellnorm](http://github.com/agentbellnorm) | `morganbentell` | | Raghunandan Balachandran | Spotify | BUX | [soapraj](http://github.com/soapraj) | `raghunandanb#1114` | | Sydney Achinger | Spotify | ProTean | [squid-ney](https://github.com/squid-ney) | - | +| Vojta Burian | Spotify | ProTean | [sankaritan](https://github.com/sankaritan) | `vojtabu` | ## Incubating Project Areas diff --git a/contrib/.devcontainer/devcontainer.json b/contrib/.devcontainer/devcontainer.json index b312102784..14e7d7f0ae 100644 --- a/contrib/.devcontainer/devcontainer.json +++ b/contrib/.devcontainer/devcontainer.json @@ -6,7 +6,7 @@ "features": { "ghcr.io/devcontainers/features/common-utils:1": {}, "ghcr.io/devcontainers/features/docker-from-docker:1": {}, - "ghcr.io/devcontainers-contrib/features/mkdocs:1": {} + "ghcr.io/devcontainers-contrib/features/mkdocs:2": {} }, // Use 'forwardPorts' to make a list of ports inside the container available locally. diff --git a/contrib/.devcontainer/postCreate.sh b/contrib/.devcontainer/postCreate.sh index f0631a4af2..88f403de5e 100755 --- a/contrib/.devcontainer/postCreate.sh +++ b/contrib/.devcontainer/postCreate.sh @@ -1,3 +1,6 @@ #!/bin/bash yarn install -pip install mkdocs-techdocs-core \ No newline at end of file +export VIRTUAL_ENV=$HOME/venv +python3 -m venv $VIRTUAL_ENV +export PATH="$VIRTUAL_ENV/bin:$PATH" +python3 -m pip install mkdocs-techdocs-core \ No newline at end of file diff --git a/docs/features/software-catalog/configuration.md b/docs/features/software-catalog/configuration.md index 5abd266099..2f5edc3696 100644 --- a/docs/features/software-catalog/configuration.md +++ b/docs/features/software-catalog/configuration.md @@ -123,3 +123,14 @@ source that should be mirrored into Backstage. To make Backstage a mirror of this remote source, users cannot also register new entities with e.g. the [catalog-import](https://github.com/backstage/backstage/tree/master/plugins/catalog-import) plugin. + +## Clean up orphaned entities + +In short entities can become orphaned through multiple means, such as when a catalog-info YAML file is moved from one place to another in the version control system without updating the registration in the catalog. For safety reasons the default behavior is to just tag the orphaned entities, and keep them around. You can read more about orphaned entities [here](life-of-an-entity.md#orphaning). + +However, if you do with to automatically remove the orphaned entities, you can use the following configuration, and everything with an orphaned entity tag will be eventually deleted. + +``` +catalog: + orphanStrategy: delete +``` diff --git a/docs/features/software-catalog/life-of-an-entity.md b/docs/features/software-catalog/life-of-an-entity.md index 618923289e..583be7df20 100644 --- a/docs/features/software-catalog/life-of-an-entity.md +++ b/docs/features/software-catalog/life-of-an-entity.md @@ -222,7 +222,8 @@ either, it becomes _orphaned_. The end result is as follows: - The stitching process injects a `backstage.io/orphan: 'true'` annotation on the child entity. - The child entity is _not_ removed from the catalog, but stays around until - explicitly deleted via the catalog API, or "reclaimed" by the original parent + explicitly deleted via the catalog API, implicitly if `orphanStrategy: delete` + configuration is set, or until it is "reclaimed" by the original parent or another parent starting to reference it. - The catalog page in Backstage for the child entity detects the new annotation and informs users about the orphan status. @@ -259,9 +260,13 @@ entities without explicit owner consent. The catalog therefore takes the stance that entities that often were added by direct user action should also be deleted only by direct user action. -It is possible to use the catalog API to build automated "reaper" systems that -finally delete entities that are orphaned. This is however not something that's -provided out of the box. +However, if you want to delete orphaned entities automatically anyway, you can +enable the automated clean up with the following app-config option. + +``` +catalog: + orphanStrategy: delete +``` ## Implicit Deletion diff --git a/docs/features/techdocs/addons.md b/docs/features/techdocs/addons.md index bd709ee15f..a41995c260 100644 --- a/docs/features/techdocs/addons.md +++ b/docs/features/techdocs/addons.md @@ -122,12 +122,12 @@ page header, TechDocs Addons whose location is `Header` will not be rendered. Addons can, in principle, be provided by any plugin! To make it easier to discover available Addons, we've compiled a list of them here: -| Addon | Package/Plugin | Description | -| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.expandablenavigation) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | -| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.reportissue) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | -| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.textsize) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | -| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.lightbox) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. | +| Addon | Package/Plugin | Description | +| ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.expandablenavigation) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to expand or collapse the entire TechDocs main navigation, and keeps the user's preferred state between documentation sites. | +| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.reportissue) | `@backstage/plugin-techdocs-module-addons-contrib` | Allows TechDocs users to select a portion of text on a TechDocs page and open an issue against the repository that contains the documentation, populating the issue description with the selected text according to a configurable template. | +| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.textsize) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to customize text size on documentation pages, they can select how much they want to increase or decrease the font size via slider or buttons. The default value for font size is 100% and this setting is kept in the browser's local storage whenever it is changed. | +| [``](https://backstage.io/docs/reference/plugin-techdocs-module-addons-contrib.lightbox) | `@backstage/plugin-techdocs-module-addons-contrib` | This TechDocs addon allows users to open images in a light-box on documentation pages, they can navigate between images if there are several on one page. The image size of the light-box image is the same as the image size on the document page. When clicking on the zoom icon it zooms the image to fit in the screen (similar to `background-size: contain`). | Got an Addon to contribute? Feel free to add a row above! diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md index 76589de5a8..87eb67d04a 100644 --- a/docs/features/techdocs/cli.md +++ b/docs/features/techdocs/cli.md @@ -141,6 +141,7 @@ Options: if not found. --etag A unique identifier for the prepared tree e.g. commit SHA. If provided it will be stored in techdocs_metadata.json. + --defaultPlugin Plugins which should be added automatically to the mkdocs.yaml file. (default: []) --omitTechdocsCoreMkdocsPlugin An option to disable automatic addition of techdocs-core plugin to the mkdocs.yaml files. Defaults to false, which means that the techdocs-core plugin is always added to the mkdocs file. --legacyCopyReadmeMdToIndexMd Attempt to ensure an index.md exists falling back to using /README.md or README.md diff --git a/docs/features/techdocs/configuration.md b/docs/features/techdocs/configuration.md index 687ad3c61e..a861a50c87 100644 --- a/docs/features/techdocs/configuration.md +++ b/docs/features/techdocs/configuration.md @@ -49,6 +49,15 @@ techdocs: # will be broken in these scenarios. legacyCopyReadmeMdToIndexMd: false + # (Optional) Configures the default plugins which should be added + # automatically to every mkdocs.yaml file. This simplifies the usage as + # e.g. styling plugins can be added once for all. + # Make sure that the defined plugins are installed locally / in the Docker + # image. + # By default, only the techdocs-core plugin will be added (except if + # omitTechdocsCorePlugin: true). + defaultPlugins: ['techdocs-core'] + # techdocs.builder can be either 'local' or 'external'. # Using the default build strategy, if builder is set to 'local' and you open a TechDocs page, # techdocs-backend will try to generate the docs, publish to storage and show the generated docs afterwards. diff --git a/docs/publishing.md b/docs/publishing.md index b2224e76b7..be94406a40 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -13,6 +13,34 @@ npm. Releases are handled by changesets and trigger whenever the "Version Packages" PR is merged. This is typically done every Tuesday around noon CET. +## Next Line Release Process + +- PR Checks: Ensure there are no outstanding PRs pending to be merged for this version. If there are any, reach out to maintainers and relevant owners of the affected code reminding them of the deadline for the release. +- [optional] Lock main branch + - Lock the main branch to prevent any new merges. + - Note: Admin rights are required to lock the branch. If you lack the necessary permissions, contact a core maintainer to perform this action on your behalf. +- Check [`Version Packages (next)` Pull Request](https://github.com/backstage/backstage/pulls?q=is%3Aopen+is%3Apr+in%3Atitle+%22Version+Packages+%28next%29%22) + - Verify the version we are shipping is correct, by looking at the version packages PR title. It should be "Version Packages (next)" + - Check [`.changeset/pre.json`](https://github.com/backstage/backstage/blob/master/.changeset/pre.json) if the `mode` is set to `pre`. If you encounter `mode: "exit"` or if it's not defined, it indicates a mainline release. +- Verify that there are no active/unfinished `sync_version-packages` actions running (https://github.com/backstage/backstage/actions/workflows/sync_version-packages.yml) + - Locking the main branch will prevent new ones to be created, but be sure to check for running actions again after unlocking, since it may cause pending auto-merge PRs to be merged. +- Check [`Version Packages (next)` Pull Request](https://github.com/backstage/backstage/pulls?q=is%3Aopen+is%3Apr+in%3Atitle+%22Version+Packages+%28next%29%22) for sufficient approval to be merged + - Check generated `changelog` in the changed files of the pull request under `docs/releases` to see if there are any unexpected major bumps e.g. by searching the file + - Review & approve changes + - Reach out to core maintainer to merge the pull request + - Heads-up: The microsite building step can be skipped as long as the `prettier` task passes & everything else looks green + +Merging the `Version Packages (next)` Pull Request will trigger the deployment workflows. Follow along the [deployment workflow](https://github.com/backstage/backstage/actions/workflows/deploy_packages.yml). If you notice flakiness (e.g. if the build is flaky or if the release step runs into an error with releasing to npm) just restart the workflow. + +Congratulations on the release! There should be now a post in the [`#announcements` channel](https://discord.com/channels/687207715902193673/705123584468582400) in Discord linking to the release tag - check if links & tag look as expected. Finally unlock the main branch again. Merging PRs in master directly after release should be done with caution as it potential complicates fixing issues introduced in the release. + +## Switching Release Modes + +- To enter pre-release mode: `yarn changeset pre enter next` & create PR + merge changes +- To exit pre-release mode: `yarn changeset pre exit` & create PR + merge changes + - Has to be done before the mainline release +- It's not time critical; Affects the next release happening + ## Emergency Release Process **This emergency release process is intended only for the Backstage diff --git a/microsite/data/plugins/flux.yaml b/microsite/data/plugins/flux.yaml new file mode 100644 index 0000000000..91156f7ed3 --- /dev/null +++ b/microsite/data/plugins/flux.yaml @@ -0,0 +1,10 @@ +--- +title: Flux Plugins +author: Weaveworks +authorUrl: https://weave.works +category: GitOps +description: View the status of fluxcd.io resources +documentation: https://github.com/weaveworks/weaveworks-backstage/tree/main/plugins/backstage-plugin-flux#readme +iconUrl: https://raw.githubusercontent.com/fluxcd/website/main/assets/icons/logo.svg +npmPackageName: '@weaveworksoss/backstage-plugin-flux' +addedDate: '2023-08-23' diff --git a/packages/app-next/src/examples/graphiqlPlugin.tsx b/packages/app-next/src/examples/graphiqlPlugin.tsx index 87f2bb242c..c40b78fc97 100644 --- a/packages/app-next/src/examples/graphiqlPlugin.tsx +++ b/packages/app-next/src/examples/graphiqlPlugin.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import React from 'react'; import { createExtension, createPlugin, coreExtensionData, + createSchemaFromZod, } from '@backstage/frontend-plugin-api'; import { Router as GraphiQLPage } from '@backstage/plugin-graphiql'; @@ -27,12 +27,12 @@ export const GraphiqlPageExtension = createExtension({ component: coreExtensionData.reactComponent, path: coreExtensionData.routePath, }, + configSchema: createSchemaFromZod(z => + z.object({ path: z.string().default('/graphiql') }), + ), factory({ bind, config }) { - bind.component(() => { - return ; - }); - // TODO: In need of schemas and type safety - bind.path((config as { path: string }).path); + bind.component(GraphiQLPage); + bind.path(config.path); }, }); @@ -43,7 +43,6 @@ export const graphiqlPlugin = createPlugin({ id: 'graphiql.page', at: 'core.router/routes', extension: GraphiqlPageExtension, - config: { path: '/graphiql' }, }, ], }); diff --git a/packages/app/src/components/home/HomePage.tsx b/packages/app/src/components/home/HomePage.tsx index aa3739728e..a8ddc0e0e2 100644 --- a/packages/app/src/components/home/HomePage.tsx +++ b/packages/app/src/components/home/HomePage.tsx @@ -15,14 +15,14 @@ */ import { - HomePageRandomJoke, - WelcomeTitle, - HeaderWorldClock, ClockConfig, - HomePageStarredEntities, CustomHomepageGrid, - HomePageToolkit, + HeaderWorldClock, HomePageCompanyLogo, + HomePageRandomJoke, + HomePageStarredEntities, + HomePageToolkit, + WelcomeTitle, } from '@backstage/plugin-home'; import { Content, Header, Page } from '@backstage/core-components'; import { HomePageSearchBar } from '@backstage/plugin-search'; @@ -64,6 +64,9 @@ const defaultConfig = [ y: 0, width: 12, height: 1, + movable: false, + resizable: false, + deletable: false, }, { component: 'WelcomeTitle', @@ -77,7 +80,7 @@ const defaultConfig = [ x: 0, y: 2, width: 12, - height: 1, + height: 2, }, ]; diff --git a/packages/frontend-app-api/README.md b/packages/frontend-app-api/README.md index 5cc5073f61..8b2fe5dc23 100644 --- a/packages/frontend-app-api/README.md +++ b/packages/frontend-app-api/README.md @@ -1,12 +1,9 @@ # @backstage/frontend-app-api -_This package was created through the Backstage CLI_. +**This package is EXPERIMENTAL, we recommend against using it for production deployments** -## Installation +This package provides the core API used by Backstage frontend apps. It implements the design outlined in [RFC: Frontend System Evolution](https://github.com/backstage/backstage/issues/18372). -Install the package via Yarn: +## Documentation -```sh -cd # if within a monorepo -yarn add @backstage/frontend-app-api -``` +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index 46cd0a7aed..ecc86a5ed7 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -15,9 +15,9 @@ */ import React from 'react'; -import { Config, ConfigReader } from '@backstage/config'; +import { ConfigReader } from '@backstage/config'; import { - ExtensionInstanceConfig, + ExtensionInstanceParameters, BackstagePlugin, coreExtensionData, } from '@backstage/frontend-plugin-api'; @@ -26,65 +26,10 @@ import { createExtensionInstance, ExtensionInstance, } from './createExtensionInstance'; - -// Since we'll never merge arrays in config the config reader context -// isn't too much of a help. Fall back to manual config reading logic -// as the Config interface makes it quite hard for us otherwise. -function readAppExtensionConfigs( - rootConfig: Config, -): Partial[] { - const arr = rootConfig.getOptional('app.extensions'); - if (!Array.isArray(arr)) { - if (arr === undefined) { - return []; - } - // This will throw, and show which part of config had the wrong type - rootConfig.getConfigArray('app.extensions'); - return []; - } - - return arr.map((value, index) => { - function errorMsg(msg: string, key?: string, prop?: string) { - return `Invalid extension configuration at app.extensions[${index}]${ - key ? `[${key}]` : '' - }${prop ? `.${prop}` : ''}, ${msg}`; - } - - if (typeof value === 'string') { - return { id: value }; - } else if ( - typeof value !== 'object' || - value === null || - Array.isArray(value) - ) { - throw new Error(errorMsg('must be a string or an object')); - } - - const keys = Object.keys(value); - if (keys.length !== 1) { - const joinedKeys = `"${keys.join('", "')}"`; - throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`)); - } - - const key = keys[0]; - const obj = value[key]; - if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) { - throw new Error(errorMsg('must be an object', key)); - } - const at = obj.at; - if (at !== undefined && typeof at !== 'string') { - throw new Error(errorMsg('must be a string', key, 'at')); - } - const extension = obj.extension; - if (extension !== undefined && typeof extension !== 'string') { - throw new Error(errorMsg('must be a string', key, 'extension')); - } - if (extension) { - throw new Error('TODO: implement extension resolution'); - } - return { id: key, at, config: obj.config /* validate later */ }; - }); -} +import { + mergeExtensionParameters, + readAppExtensionParameters, +} from './wiring/parameters'; /** @public */ export function createApp(options: { plugins: BackstagePlugin[] }): { @@ -92,10 +37,7 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { } { const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any); - // pull in default extension instance from discovered packages - // apply config to adjust default extension instances and add more - const extensionInstanceConfigs = [ - ...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances), + const builtinExtensionInstanceParams = [ { id: 'core.router', at: 'root/default', @@ -104,40 +46,23 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { }, ]; - const appExtensionConfigs = readAppExtensionConfigs(appConfig); - for (const appExtensionConfig of appExtensionConfigs) { - const existingConfig = extensionInstanceConfigs.find( - e => e.id === appExtensionConfig.id, - ); - if (existingConfig) { - if (appExtensionConfig.at) { - existingConfig.at = appExtensionConfig.at; - } - if (appExtensionConfig.extension) { - // TODO: do we want to reset config here? it might be completely - // unrelated to the previous one - existingConfig.extension = appExtensionConfig.extension; - } - if (appExtensionConfig.config) { - // TODO: merge config? - existingConfig.config = appExtensionConfig.config; - } - } else if (appExtensionConfig.id) { - const { id, at, extension, config } = appExtensionConfig; - if (!at || !extension) { - throw new Error(`Extension ${appExtensionConfig.id} is incomplete`); - } - extensionInstanceConfigs.push({ id, at, extension, config }); - } - } + // pull in default extension instance from discovered packages + // apply config to adjust default extension instances and add more + const extensionInstanceParams = mergeExtensionParameters( + [ + ...options.plugins.flatMap(plugin => plugin.defaultExtensionInstances), + ...builtinExtensionInstanceParams, + ], + readAppExtensionParameters(appConfig), + ); // Create attachment map so that we can look attachments up during instance creation const attachmentMap = new Map< string, - Map + Map >(); - for (const instanceConfig of extensionInstanceConfigs) { - const [extensionId, pointId = 'default'] = instanceConfig.at.split('/'); + for (const instanceParams of extensionInstanceParams) { + const [extensionId, pointId = 'default'] = instanceParams.at.split('/'); let pointMap = attachmentMap.get(extensionId); if (!pointMap) { @@ -151,21 +76,21 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { pointMap.set(pointId, instances); } - instances.push(instanceConfig); + instances.push(instanceParams); } const instances = new Map(); function createInstance( - instanceConfig: ExtensionInstanceConfig, + instanceParams: ExtensionInstanceParameters, ): ExtensionInstance { - const existingInstance = instances.get(instanceConfig.id); + const existingInstance = instances.get(instanceParams.id); if (existingInstance) { return existingInstance; } const attachments = Object.fromEntries( - Array.from(attachmentMap.get(instanceConfig.id)?.entries() ?? []).map( + Array.from(attachmentMap.get(instanceParams.id)?.entries() ?? []).map( ([inputName, attachmentConfigs]) => [ inputName, attachmentConfigs.map(createInstance), @@ -174,16 +99,16 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { ); return createExtensionInstance({ - id: instanceConfig.id, - config: instanceConfig.config, - extension: instanceConfig.extension, + id: instanceParams.id, + config: instanceParams.config, + extension: instanceParams.extension, attachments, }); } const rootConfigs = attachmentMap.get('root')?.get('default') ?? []; - const rootInstances = rootConfigs.map(instanceConfig => - createInstance(instanceConfig), + const rootInstances = rootConfigs.map(instanceParams => + createInstance(instanceParams), ); return { diff --git a/packages/frontend-app-api/src/createExtensionInstance.ts b/packages/frontend-app-api/src/createExtensionInstance.ts index 0b62df74ae..de9a41f49f 100644 --- a/packages/frontend-app-api/src/createExtensionInstance.ts +++ b/packages/frontend-app-api/src/createExtensionInstance.ts @@ -27,26 +27,45 @@ export interface ExtensionInstance { /** @internal */ export function createExtensionInstance(options: { id: string; - extension: Extension; + extension: Extension; config: unknown; attachments: Record; }): ExtensionInstance { - const { extension, config, attachments } = options; + const { id, extension, config, attachments } = options; const extensionData = new Map(); - extension.factory({ - config, - bind: mapValues(extension.output, ref => { - return (value: unknown) => extensionData.set(ref.id, value); - }), - inputs: mapValues( - extension.inputs, - ({ extensionData: pointData }, inputName) => { - // TODO: validation - return (attachments[inputName] ?? []).map(attachment => - mapValues(pointData, ref => attachment.data.get(ref.id)), - ); - }, - ), - }); - return { id: options.id, data: extensionData, $$type: 'extension-instance' }; + + let parsedConfig: unknown; + try { + parsedConfig = extension.configSchema?.parse(config ?? {}); + } catch (e) { + throw new Error( + `Invalid configuration for extension instance '${id}', ${e}`, + ); + } + + try { + extension.factory({ + config: parsedConfig, + bind: mapValues(extension.output, ref => { + return (value: unknown) => extensionData.set(ref.id, value); + }), + inputs: mapValues( + extension.inputs, + ({ extensionData: pointData }, inputName) => { + // TODO: validation + return (attachments[inputName] ?? []).map(attachment => + mapValues(pointData, ref => attachment.data.get(ref.id)), + ); + }, + ), + }); + } catch (e) { + throw new Error(`Failed to instantiate extension instance '${id}', ${e}`); + } + + return { + id: options.id, + data: extensionData, + $$type: 'extension-instance', + }; } diff --git a/packages/frontend-app-api/src/wiring/parameters.test.ts b/packages/frontend-app-api/src/wiring/parameters.test.ts new file mode 100644 index 0000000000..ac50c555c3 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/parameters.test.ts @@ -0,0 +1,259 @@ +/* + * Copyright 2023 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 { ConfigReader } from '@backstage/config'; +import { Extension } from '@backstage/frontend-plugin-api'; +import { + mergeExtensionParameters, + readAppExtensionParameters, +} from './parameters'; + +function makeExt(id: string, status: 'disabled' | 'enabled' = 'enabled') { + return { + id, + at: 'foo/bar', + extension: {} as Extension, + disabled: status === 'disabled', + }; +} + +describe('mergeExtensionParameters', () => { + it('should filter out disabled extension instances', () => { + expect(mergeExtensionParameters([makeExt('a', 'disabled')], [])).toEqual( + [], + ); + }); + + it('should pass through extension instances', () => { + expect(mergeExtensionParameters([makeExt('a'), makeExt('b')], [])).toEqual([ + makeExt('a'), + makeExt('b'), + ]); + }); + + it('should override extension instances', () => { + expect( + mergeExtensionParameters( + [makeExt('a'), makeExt('b')], + [ + { + id: 'b', + extension: { ext: 'other' } as unknown as Extension, + }, + ], + ), + ).toEqual([makeExt('a'), { ...makeExt('b'), extension: { ext: 'other' } }]); + }); + + it('should override attachment points', () => { + expect( + mergeExtensionParameters( + [makeExt('a'), makeExt('b')], + [ + { + id: 'b', + at: 'derp', + }, + ], + ), + ).toEqual([makeExt('a'), { ...makeExt('b'), at: 'derp' }]); + }); + + it('should fully override configuration', () => { + expect( + mergeExtensionParameters( + [ + { ...makeExt('a'), config: { foo: { bar: 1 } } }, + { ...makeExt('b'), config: { foo: { bar: 2 } } }, + ], + [ + { + id: 'b', + config: { foo: { qux: 3 } }, + }, + ], + ), + ).toEqual([ + { ...makeExt('a'), config: { foo: { bar: 1 } } }, + { ...makeExt('b'), config: { foo: { qux: 3 } } }, + ]); + }); + + it('should place enabled instances in the order that they were enabled', () => { + expect( + mergeExtensionParameters( + [makeExt('a', 'disabled'), makeExt('b', 'disabled')], + [ + { + id: 'b', + disabled: false, + }, + { + id: 'a', + disabled: false, + }, + ], + ), + ).toEqual([makeExt('b'), makeExt('a')]); + }); +}); + +describe('readAppExtensionParameters', () => { + it('should disable extension with shorthand notation', () => { + expect( + readAppExtensionParameters( + new ConfigReader({ app: { extensions: [{ 'core.router': false }] } }), + ), + ).toEqual([ + { + id: 'core.router', + disabled: true, + }, + ]); + expect( + readAppExtensionParameters( + new ConfigReader({ + app: { extensions: [{ 'core.router': { disabled: true } }] }, + }), + ), + ).toEqual([ + { + at: undefined, + config: undefined, + disabled: true, + id: 'core.router', + }, + ]); + }); + + it('should enable extension with shorthand notation', () => { + expect( + readAppExtensionParameters( + new ConfigReader({ app: { extensions: ['core.router'] } }), + ), + ).toEqual([ + { + id: 'core.router', + }, + ]); + expect( + readAppExtensionParameters( + new ConfigReader({ app: { extensions: [{ 'core.router': true }] } }), + ), + ).toEqual([ + { + id: 'core.router', + disabled: false, + }, + ]); + expect( + readAppExtensionParameters( + new ConfigReader({ + app: { extensions: [{ 'core.router': { disabled: false } }] }, + }), + ), + ).toEqual([ + { + id: 'core.router', + disabled: false, + }, + ]); + }); + + it('should support extension implementation shorthand', () => { + expect( + readAppExtensionParameters( + new ConfigReader({ + app: { + extensions: [{ 'core.router': 'example-package#CustomRouter' }], + }, + }), + ref => ({ ref } as unknown as Extension), + ), + ).toEqual([ + { + id: 'core.router', + extension: { ref: 'example-package#CustomRouter' }, + }, + ]); + }); + + it('should support attachment shorthand', () => { + expect( + readAppExtensionParameters( + new ConfigReader({ + app: { + extensions: [ + { + 'core.router/routes': { + extension: 'example-package#MyPage', + config: { foo: 'bar' }, + }, + }, + ], + }, + }), + ref => ({ ref } as unknown as Extension), + ), + ).toEqual([ + { + id: 'generated.1', + at: 'core.router/routes', + extension: { ref: 'example-package#MyPage' }, + config: { foo: 'bar' }, + }, + ]); + }); + + it('should support attachment with extension shorthand', () => { + expect( + readAppExtensionParameters( + new ConfigReader({ + app: { + extensions: [{ 'core.router/routes': 'example-package#MyPage' }], + }, + }), + ref => ({ ref } as unknown as Extension), + ), + ).toEqual([ + { + id: 'generated.1', + at: 'core.router/routes', + extension: { ref: 'example-package#MyPage' }, + }, + ]); + }); + + it('should reject attachment shorthand with explicit attachment', () => { + expect(() => + readAppExtensionParameters( + new ConfigReader({ + app: { + extensions: [ + { + 'core.router/routes': { + at: 'other/input', + }, + }, + ], + }, + }), + ), + ).toThrow( + `Invalid extension configuration at app.extensions[0][core.router/routes], must not specify 'at' when using attachment shorthand form`, + ); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/parameters.ts b/packages/frontend-app-api/src/wiring/parameters.ts new file mode 100644 index 0000000000..140dcd0367 --- /dev/null +++ b/packages/frontend-app-api/src/wiring/parameters.ts @@ -0,0 +1,175 @@ +/* + * Copyright 2023 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 { Config } from '@backstage/config'; +import { + Extension, + ExtensionInstanceParameters, +} from '@backstage/frontend-plugin-api'; + +// Since we'll never merge arrays in config the config reader context +// isn't too much of a help. Fall back to manual config reading logic +// as the Config interface makes it quite hard for us otherwise. +/** @internal */ +export function readAppExtensionParameters( + rootConfig: Config, + resolveExtensionRef: (ref: string) => Extension = () => { + throw new Error('Not Implemented'); + }, +): Partial[] { + const arr = rootConfig.getOptional('app.extensions'); + if (!Array.isArray(arr)) { + if (arr === undefined) { + return []; + } + // This will throw, and show which part of config had the wrong type + rootConfig.getConfigArray('app.extensions'); + return []; + } + + const generateIndex = (() => { + let generatedIndex = 1; + return () => `generated.${generatedIndex++}`; + })(); + + return arr.map((arrayEntry, index) => { + function errorMsg(msg: string, key?: string, prop?: string) { + return `Invalid extension configuration at app.extensions[${index}]${ + key ? `[${key}]` : '' + }${prop ? `.${prop}` : ''}, ${msg}`; + } + + // Example YAML: + // - entity.card.about + if (typeof arrayEntry === 'string') { + return { id: arrayEntry }; + } + + // All other forms are single-key objects + if ( + typeof arrayEntry !== 'object' || + arrayEntry === null || + Array.isArray(arrayEntry) + ) { + throw new Error(errorMsg('must be a string or an object')); + } + const keys = Object.keys(arrayEntry); + if (keys.length !== 1) { + const joinedKeys = `"${keys.join('", "')}"`; + throw new Error(errorMsg(`must have exactly one key, got ${joinedKeys}`)); + } + + const key = keys[0]; + let value = arrayEntry[key]; + + let at = key.includes('/') ? key : undefined; + const id = at ? generateIndex() : key; + if (typeof value === 'boolean') { + if (at) { + throw new Error( + errorMsg('cannot be applied to an instance input', key), + ); + } + value = { disabled: !value }; + } else if (typeof value === 'string') { + value = { extension: value }; + } else if ( + typeof value !== 'object' || + value === null || + Array.isArray(value) + ) { + throw new Error(errorMsg('must be an object', key)); + } + + const atRef = value.at; + if (atRef !== undefined && typeof atRef !== 'string') { + throw new Error(errorMsg('must be a string', key, 'at')); + } else if (atRef !== undefined) { + if (at) { + throw new Error( + errorMsg( + `must not specify 'at' when using attachment shorthand form`, + key, + ), + ); + } + at = atRef; + } + const disabled = value.disabled; + if (disabled !== undefined && typeof disabled !== 'boolean') { + throw new Error(errorMsg('must be a boolean', key, 'disabled')); + } + const extensionRef = value.extension; + if (extensionRef !== undefined && typeof extensionRef !== 'string') { + throw new Error(errorMsg('must be a string', key, 'extension')); + } + const extension = extensionRef + ? resolveExtensionRef(extensionRef) + : undefined; + return { + id, + at, + disabled, + extension, + config: value.config /* validate later */, + }; + }); +} + +/** @internal */ +export function mergeExtensionParameters( + base: ExtensionInstanceParameters[], + overrides: Array>, +): ExtensionInstanceParameters[] { + const extensionInstanceParams = base.slice(); + + for (const overrideParam of overrides) { + const existingParamIndex = extensionInstanceParams.findIndex( + e => e.id === overrideParam.id, + ); + if (existingParamIndex !== -1) { + const existingParam = extensionInstanceParams[existingParamIndex]; + if (overrideParam.at) { + existingParam.at = overrideParam.at; + } + if (overrideParam.extension) { + // TODO: do we want to reset config here? it might be completely + // unrelated to the previous one + existingParam.extension = overrideParam.extension; + } + if (overrideParam.config) { + // TODO: merge config? + existingParam.config = overrideParam.config; + } + if (Boolean(existingParam.disabled) !== Boolean(overrideParam.disabled)) { + existingParam.disabled = Boolean(overrideParam.disabled); + if (!existingParam.disabled) { + // bump + extensionInstanceParams.splice(existingParamIndex, 1); + extensionInstanceParams.push(existingParam); + } + } + } else if (overrideParam.id) { + const { id, at, extension, config } = overrideParam; + if (!at || !extension) { + throw new Error(`Extension ${overrideParam.id} is incomplete`); + } + extensionInstanceParams.push({ id, at, extension, config }); + } + } + + return extensionInstanceParams.filter(param => !param.disabled); +} diff --git a/packages/frontend-plugin-api/README.md b/packages/frontend-plugin-api/README.md index 43cb4082dc..1fd3da79d8 100644 --- a/packages/frontend-plugin-api/README.md +++ b/packages/frontend-plugin-api/README.md @@ -1,12 +1,9 @@ # @backstage/frontend-plugin-api -_This package was created through the Backstage CLI_. +**This package is EXPERIMENTAL, we recommend against using it for production deployments** -## Installation +This package provides the core API used by Backstage frontend plugins. It implements the design outlined in [RFC: Frontend System Evolution](https://github.com/backstage/backstage/issues/18372). -Install the package via Yarn: +## Documentation -```sh -cd # if within a monorepo -yarn add @backstage/frontend-plugin-api -``` +- [Backstage Documentation](https://backstage.io/docs) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 4337b0b8c2..a86597d7bd 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -4,6 +4,10 @@ ```ts import { ComponentType } from 'react'; +import { JsonObject } from '@backstage/types'; +import { z } from 'zod'; +import { ZodSchema } from 'zod'; +import { ZodTypeDef } from 'zod'; // @public (undocumented) export type AnyExtensionDataMap = Record>; @@ -13,7 +17,7 @@ export interface BackstagePlugin { // (undocumented) $$type: 'backstage-plugin'; // (undocumented) - defaultExtensionInstances: ExtensionInstanceConfig[]; + defaultExtensionInstances: ExtensionInstanceParameters[]; // (undocumented) id: string; } @@ -21,7 +25,7 @@ export interface BackstagePlugin { // @public (undocumented) export interface BackstagePluginOptions { // (undocumented) - defaultExtensionInstances?: ExtensionInstanceConfig[]; + defaultExtensionInstances?: ExtensionInstanceParameters[]; // (undocumented) id: string; } @@ -41,7 +45,8 @@ export function createExtension< extensionData: AnyExtensionDataMap; } >, ->(options: CreateExtensionOptions): Extension; + TConfig = never, +>(options: CreateExtensionOptions): Extension; // @public (undocumented) export interface CreateExtensionOptions< @@ -52,11 +57,14 @@ export interface CreateExtensionOptions< extensionData: AnyExtensionDataMap; } >, + TConfig, > { + // (undocumented) + configSchema?: PortableSchema; // (undocumented) factory(options: { bind: ExtensionDataBind; - config?: unknown; + config: TConfig; inputs: { [pointName in keyof TPoint]: ExtensionDataValue< TPoint[pointName]['extensionData'] @@ -73,13 +81,20 @@ export interface CreateExtensionOptions< export function createPlugin(options: BackstagePluginOptions): BackstagePlugin; // @public (undocumented) -export interface Extension { +export function createSchemaFromZod( + schemaCreator: (zImpl: typeof z) => ZodSchema, +): PortableSchema; + +// @public (undocumented) +export interface Extension { // (undocumented) $$type: 'extension'; // (undocumented) + configSchema?: PortableSchema; + // (undocumented) factory(options: { bind: ExtensionDataBind; - config?: unknown; + config: TConfig; inputs: Record>>; }): void; // (undocumented) @@ -111,14 +126,22 @@ export type ExtensionDataValue = { }; // @public (undocumented) -export interface ExtensionInstanceConfig { +export interface ExtensionInstanceParameters { // (undocumented) at: string; // (undocumented) - config: unknown; + config?: unknown; // (undocumented) - extension: Extension; + disabled?: boolean; + // (undocumented) + extension: Extension; // (undocumented) id: string; } + +// @public (undocumented) +export type PortableSchema = { + parse: (input: unknown) => TOutput; + schema: JsonObject; +}; ``` diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 731e66fed0..cf353e55a6 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -33,6 +33,9 @@ "react": "^16.13.1 || ^17.0.0" }, "dependencies": { - "lodash": "^4.17.21" + "@backstage/types": "workspace:^", + "lodash": "^4.17.21", + "zod": "^3.21.4", + "zod-to-json-schema": "^3.21.4" } } diff --git a/packages/frontend-plugin-api/src/createSchemaFromZod.test.ts b/packages/frontend-plugin-api/src/createSchemaFromZod.test.ts new file mode 100644 index 0000000000..90eba44249 --- /dev/null +++ b/packages/frontend-plugin-api/src/createSchemaFromZod.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2023 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 { createSchemaFromZod } from './createSchemaFromZod'; + +describe('createSchemaFromZod', () => { + it('should provide nice parse errors', () => { + const { parse } = createSchemaFromZod(z => + z.object({ + foo: z.union([z.string(), z.number()]), + derp: z.object({ bar: z.number() }), + }), + ); + + expect(() => parse({ derp: { bar: 'derp' } })).toThrow( + `Missing required value at 'foo'; Expected number, received string at 'derp.bar'`, + ); + expect(() => parse(undefined)).toThrow(`Missing required value`); + expect(() => parse('derp')).toThrow(`Expected object, received string`); + }); +}); diff --git a/packages/frontend-plugin-api/src/createSchemaFromZod.ts b/packages/frontend-plugin-api/src/createSchemaFromZod.ts new file mode 100644 index 0000000000..447cea8bbd --- /dev/null +++ b/packages/frontend-plugin-api/src/createSchemaFromZod.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2023 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 { JsonObject } from '@backstage/types'; +import { z, ZodSchema, ZodTypeDef } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; + +/** @public */ +export type PortableSchema = { + parse: (input: unknown) => TOutput; + schema: JsonObject; +}; + +/** @public */ +export function createSchemaFromZod( + schemaCreator: (zImpl: typeof z) => ZodSchema, +): PortableSchema { + const schema = schemaCreator(z); + return { + // TODO: Types allow z.array etc here but it will break stuff + parse: input => { + const result = schema.safeParse(input); + if (result.success) { + return result.data; + } + + throw new Error(result.error.issues.map(formatIssue).join('; ')); + }, + // TODO: Verify why we are not compatible with the latest zodToJsonSchema. + schema: zodToJsonSchema(schema) as JsonObject, + }; +} + +function formatIssue(issue: z.ZodIssue): string { + if (issue.code === 'invalid_union') { + return formatIssue(issue.unionErrors[0].issues[0]); + } + let message = issue.message; + if (message === 'Required') { + message = `Missing required value`; + } + if (issue.path.length) { + message += ` at '${issue.path.join('.')}'`; + } + return message; +} diff --git a/packages/frontend-plugin-api/src/index.ts b/packages/frontend-plugin-api/src/index.ts index e36391131d..f70c77abcc 100644 --- a/packages/frontend-plugin-api/src/index.ts +++ b/packages/frontend-plugin-api/src/index.ts @@ -24,7 +24,7 @@ export { createExtension, coreExtensionData, createPlugin, - type ExtensionInstanceConfig, + type ExtensionInstanceParameters, type BackstagePlugin, type Extension, type AnyExtensionDataMap, @@ -34,3 +34,7 @@ export { type ExtensionDataRef, type ExtensionDataValue, } from './types'; +export { + createSchemaFromZod, + type PortableSchema, +} from './createSchemaFromZod'; diff --git a/packages/frontend-plugin-api/src/types.ts b/packages/frontend-plugin-api/src/types.ts index b2d131c62f..8822cf044d 100644 --- a/packages/frontend-plugin-api/src/types.ts +++ b/packages/frontend-plugin-api/src/types.ts @@ -15,6 +15,7 @@ */ import { ComponentType } from 'react'; +import { PortableSchema } from './createSchemaFromZod'; /** @public */ export type ExtensionDataRef = { @@ -51,12 +52,14 @@ export type ExtensionDataValue = { export interface CreateExtensionOptions< TData extends AnyExtensionDataMap, TPoint extends Record, + TConfig, > { inputs?: TPoint; output: TData; + configSchema?: PortableSchema; factory(options: { bind: ExtensionDataBind; - config?: unknown; + config: TConfig; inputs: { [pointName in keyof TPoint]: ExtensionDataValue< TPoint[pointName]['extensionData'] @@ -66,13 +69,15 @@ export interface CreateExtensionOptions< } /** @public */ -export interface Extension { +export interface Extension { $$type: 'extension'; + // TODO: will extensions have a default "at" as part of their contract, making it optional in the instance config? inputs: Record; output: AnyExtensionDataMap; + configSchema?: PortableSchema; factory(options: { bind: ExtensionDataBind; - config?: unknown; + config: TConfig; inputs: Record>>; }): void; } @@ -81,29 +86,31 @@ export interface Extension { export function createExtension< TData extends AnyExtensionDataMap, TPoint extends Record, ->(options: CreateExtensionOptions): Extension { + TConfig = never, +>(options: CreateExtensionOptions): Extension { return { ...options, $$type: 'extension', inputs: options.inputs ?? {} }; } /** @public */ -export interface ExtensionInstanceConfig { +export interface ExtensionInstanceParameters { id: string; at: string; - extension: Extension; - config: unknown; + extension: Extension; + config?: unknown; + disabled?: boolean; } /** @public */ export interface BackstagePluginOptions { id: string; - defaultExtensionInstances?: ExtensionInstanceConfig[]; + defaultExtensionInstances?: ExtensionInstanceParameters[]; } /** @public */ export interface BackstagePlugin { $$type: 'backstage-plugin'; id: string; - defaultExtensionInstances: ExtensionInstanceConfig[]; + defaultExtensionInstances: ExtensionInstanceParameters[]; } /** @public */ diff --git a/packages/techdocs-cli/cli-report.md b/packages/techdocs-cli/cli-report.md index 556068de85..5f148daf24 100644 --- a/packages/techdocs-cli/cli-report.md +++ b/packages/techdocs-cli/cli-report.md @@ -37,6 +37,7 @@ Options: -v --verbose --omitTechdocsCoreMkdocsPlugin --legacyCopyReadmeMdToIndexMd + --defaultPlugin [defaultPlugins...] -h, --help ``` diff --git a/packages/techdocs-cli/src/commands/generate/generate.ts b/packages/techdocs-cli/src/commands/generate/generate.ts index 5d80beb38a..934eaf1efd 100644 --- a/packages/techdocs-cli/src/commands/generate/generate.ts +++ b/packages/techdocs-cli/src/commands/generate/generate.ts @@ -47,6 +47,7 @@ export default async function generate(opts: OptionValues) { const dockerImage = opts.dockerImage; const pullImage = opts.pull; const legacyCopyReadmeMdToIndexMd = opts.legacyCopyReadmeMdToIndexMd; + const defaultPlugins = opts.defaultPlugin; logger.info(`Using source dir ${sourceDir}`); logger.info(`Will output generated files in ${outputDir}`); @@ -68,6 +69,7 @@ export default async function generate(opts: OptionValues) { mkdocs: { legacyCopyReadmeMdToIndexMd, omitTechdocsCorePlugin, + defaultPlugins, }, }, }, diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts index 98816a6b32..3321bb7f43 100644 --- a/packages/techdocs-cli/src/commands/index.ts +++ b/packages/techdocs-cli/src/commands/index.ts @@ -70,6 +70,11 @@ export function registerCommands(program: Command) { 'Attempt to ensure an index.md exists falling back to using /README.md or README.md in case a default /index.md is not provided.', false, ) + .option( + '--defaultPlugin [defaultPlugins...]', + 'Plugins which should be added automatically to the mkdocs.yaml file', + [], + ) .alias('build') .action(lazy(() => import('./generate/generate').then(m => m.default))); diff --git a/plugins/catalog-backend/src/schema/openapi.generated.ts b/plugins/catalog-backend/src/schema/openapi.generated.ts index 3a56893b48..5c9b2773e9 100644 --- a/plugins/catalog-backend/src/schema/openapi.generated.ts +++ b/plugins/catalog-backend/src/schema/openapi.generated.ts @@ -45,6 +45,7 @@ export const spec = { name: 'kind', in: 'path', required: true, + allowReserved: true, schema: { type: 'string', }, @@ -53,6 +54,7 @@ export const spec = { name: 'namespace', in: 'path', required: true, + allowReserved: true, schema: { type: 'string', }, @@ -61,6 +63,7 @@ export const spec = { name: 'name', in: 'path', required: true, + allowReserved: true, schema: { type: 'string', }, @@ -69,6 +72,7 @@ export const spec = { name: 'uid', in: 'path', required: true, + allowReserved: true, schema: { type: 'string', }, @@ -78,6 +82,7 @@ export const spec = { in: 'query', description: 'Cursor to a set page of results.', required: false, + allowReserved: true, schema: { type: 'string', minLength: 1, @@ -88,6 +93,7 @@ export const spec = { in: 'query', description: 'Pointer to the previous page of results.', required: false, + allowReserved: true, schema: { type: 'string', minLength: 1, @@ -111,6 +117,7 @@ export const spec = { in: 'query', description: 'Filter for just the entities defined by this filter.', required: false, + allowReserved: true, schema: { type: 'array', items: { @@ -123,6 +130,7 @@ export const spec = { in: 'query', description: 'Number of records to skip in the query page.', required: false, + allowReserved: true, schema: { type: 'integer', minimum: 0, @@ -133,6 +141,7 @@ export const spec = { in: 'query', description: 'Number of records to return in the response.', required: false, + allowReserved: true, schema: { type: 'integer', minimum: 0, @@ -1037,6 +1046,7 @@ export const spec = { in: 'query', description: 'Text search term.', required: false, + allowReserved: true, schema: { type: 'string', }, @@ -1047,6 +1057,7 @@ export const spec = { description: 'A comma separated list of fields to sort returned results by.', required: false, + allowReserved: true, schema: { type: 'array', items: { @@ -1086,6 +1097,7 @@ export const spec = { in: 'query', name: 'facet', required: true, + allowReserved: true, schema: { type: 'array', items: { @@ -1141,6 +1153,7 @@ export const spec = { in: 'query', name: 'dryRun', required: false, + allowReserved: true, schema: { type: 'string', }, @@ -1226,6 +1239,7 @@ export const spec = { in: 'path', name: 'id', required: true, + allowReserved: true, schema: { type: 'string', }, @@ -1251,6 +1265,7 @@ export const spec = { in: 'path', name: 'id', required: true, + allowReserved: true, schema: { type: 'string', }, @@ -1295,7 +1310,7 @@ export const spec = { $ref: '#/components/schemas/LocationInput', }, }, - required: ['catalogFileName', 'location'], + required: ['location'], }, }, }, diff --git a/plugins/catalog-backend/src/schema/openapi.yaml b/plugins/catalog-backend/src/schema/openapi.yaml index e0b60b1a98..52321399c6 100644 --- a/plugins/catalog-backend/src/schema/openapi.yaml +++ b/plugins/catalog-backend/src/schema/openapi.yaml @@ -20,24 +20,28 @@ components: name: kind in: path required: true + allowReserved: true schema: type: string namespace: name: namespace in: path required: true + allowReserved: true schema: type: string name: name: name in: path required: true + allowReserved: true schema: type: string uid: name: uid in: path required: true + allowReserved: true schema: type: string cursor: @@ -45,6 +49,7 @@ components: in: query description: Cursor to a set page of results. required: false + allowReserved: true schema: type: string minLength: 1 @@ -53,6 +58,7 @@ components: in: query description: Pointer to the previous page of results. required: false + allowReserved: true schema: type: string minLength: 1 @@ -71,6 +77,7 @@ components: in: query description: Filter for just the entities defined by this filter. required: false + allowReserved: true schema: type: array items: @@ -80,6 +87,7 @@ components: in: query description: Number of records to skip in the query page. required: false + allowReserved: true schema: type: integer minimum: 0 @@ -88,6 +96,7 @@ components: in: query description: Number of records to return in the response. required: false + allowReserved: true schema: type: integer minimum: 0 @@ -788,12 +797,14 @@ paths: in: query description: Text search term. required: false + allowReserved: true schema: type: string - name: fullTextFilterFields in: query description: A comma separated list of fields to sort returned results by. required: false + allowReserved: true schema: type: array items: @@ -818,6 +829,7 @@ paths: - in: query name: facet required: true + allowReserved: true schema: type: array items: @@ -853,6 +865,7 @@ paths: - in: query name: dryRun required: false + allowReserved: true schema: type: string requestBody: @@ -908,6 +921,7 @@ paths: - in: path name: id required: true + allowReserved: true schema: type: string delete: @@ -923,6 +937,7 @@ paths: - in: path name: id required: true + allowReserved: true schema: type: string /analyze-location: @@ -952,7 +967,6 @@ paths: location: $ref: '#/components/schemas/LocationInput' required: - - catalogFileName - location /validate-entity: post: diff --git a/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.test.ts b/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.test.ts index dfcfd58c7e..e11656681a 100644 --- a/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.test.ts +++ b/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.test.ts @@ -28,11 +28,11 @@ describe('parseQueryEntitiesParams', () => { const validRequest = { authorizationToken: 'to_not_be_returned', fields: ['kind'], - limit: '3', + limit: 3, filter: ['a=1', 'b=2'], orderField: ['metadata.name,desc'], fullTextFilterTerm: 'query', - fullTextFilterFields: 'metadata.name,metadata.namespace', + fullTextFilterFields: ['metadata.name', 'metadata.namespace'], }; const parsedObj = parseQueryEntitiesParams( validRequest, @@ -65,9 +65,7 @@ describe('parseQueryEntitiesParams', () => { { filter: 3 }, { orderField: ['metadata.uid,diagonal'] }, { fields: [4] }, - { fullTextFilterTerm: [] }, - { fullTextFilterFields: 3 }, - ])('should throw if some parameter is not valid %p', params => { + ])('should throw if some parameter is not valid %p', (params: any) => { expect(() => parseQueryEntitiesParams(params)).toThrow(); }); }); @@ -83,7 +81,7 @@ describe('parseQueryEntitiesParams', () => { const validRequest = { authorizationToken: 'to_not_be_returned', fields: ['kind'], - limit: '3', + limit: 3, cursor: encodeCursor(cursor), }; const parsedObj = parseQueryEntitiesParams( @@ -103,10 +101,10 @@ describe('parseQueryEntitiesParams', () => { const validRequest = { authorizationToken: 'to_not_be_returned', fields: ['kind'], - limit: '3', + limit: 3, cursor: encodeCursor(cursor), filter: ['a=1', 'b=2'], - orderField: 'orderField,desc', + orderField: ['orderField,desc'], query: 'query', }; const parsedObj = parseQueryEntitiesParams( @@ -128,7 +126,7 @@ describe('parseQueryEntitiesParams', () => { it.each([{ cursor: [] }, { fields: [4] }])( 'should throw if some parameter is not valid %p', - params => { + (params: any) => { expect(() => parseQueryEntitiesParams(params)).toThrow(); }, ); diff --git a/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts b/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts index 9b55f193b9..b1d5e8dd1f 100644 --- a/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts +++ b/plugins/catalog-backend/src/service/request/parseQueryEntitiesParams.ts @@ -20,19 +20,19 @@ import { QueryEntitiesRequest, } from '../../catalog/types'; import { decodeCursor } from '../util'; -import { parseStringParam } from './common'; import { parseEntityFilterParams } from './parseEntityFilterParams'; import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams'; import { parseEntityTransformParams } from './parseEntityTransformParams'; -import { parseFullTextFilterFields } from './parseFullTextFilterFields'; +import { spec } from '../../schema/openapi.generated'; +import { internal } from '@backstage/backend-openapi-utils'; export function parseQueryEntitiesParams( - params: Record, + params: internal.QuerySchema, ): Omit { const fields = parseEntityTransformParams(params); - const cursor = parseStringParam(params.cursor, 'cursor'); - if (cursor) { - const decodedCursor = decodeCursor(cursor); + + if (params.cursor) { + const decodedCursor = decodeCursor(params.cursor); const response: Omit = { cursor: decodedCursor, fields, @@ -41,12 +41,6 @@ export function parseQueryEntitiesParams( } const filter = parseEntityFilterParams(params); - const fullTextFilterTerm = parseStringParam( - params.fullTextFilterTerm, - 'fullTextFilterTerm', - ); - const fullTextFilterFields = parseFullTextFilterFields(params); - const orderFields = parseEntityOrderFieldParams(params); const response: Omit = { @@ -54,8 +48,8 @@ export function parseQueryEntitiesParams( filter, orderFields, fullTextFilter: { - term: fullTextFilterTerm || '', - fields: fullTextFilterFields, + term: params.fullTextFilterTerm || '', + fields: params.fullTextFilterFields, }, }; diff --git a/plugins/home/README.md b/plugins/home/README.md index 57c2a4d243..e147cdfbdf 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -233,6 +233,9 @@ const defaultConfig = [ y: 0, width: 12, height: 1, + movable: true, + resizable: false, + deletable: false, }, ]; diff --git a/plugins/home/api-report.md b/plugins/home/api-report.md index c5e458f78f..450bc054fa 100644 --- a/plugins/home/api-report.md +++ b/plugins/home/api-report.md @@ -148,6 +148,9 @@ export type LayoutConfiguration = { y: number; width: number; height: number; + movable?: boolean; + deletable?: boolean; + resizable?: boolean; }; // @public @deprecated (undocumented) diff --git a/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx b/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx index f5ee8f3ddc..88c3273ff3 100644 --- a/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx +++ b/plugins/home/src/components/CustomHomepage/CustomHomepageGrid.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { ReactNode, useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout'; import { ElementCollection, @@ -40,6 +40,7 @@ import { WidgetSettingsOverlay } from './WidgetSettingsOverlay'; import { AddWidgetDialog } from './AddWidgetDialog'; import { CustomHomepageButtons } from './CustomHomepageButtons'; import { + CustomHomepageGridProps, CustomHomepageGridStateV1, CustomHomepageGridStateV1Schema, GridWidget, @@ -158,6 +159,9 @@ const convertConfigToDefaultWidgets = ( isResizable: false, }, settings: {}, + movable: conf.movable, + deletable: conf.deletable, + resizable: conf.resizable, }; }); return compact(ret); @@ -190,82 +194,6 @@ const availableWidgetsFilter = (elements: ElementCollection) => { }); }; -/** - * Breakpoint options for - * - * @public - */ -export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; - -/** - * Props customizing the component. - * - * @public - */ -export type CustomHomepageGridProps = { - /** - * Children contain all widgets user can configure on their own homepage. - */ - children?: ReactNode; - /** - * Default layout for the homepage before users have modified it. - */ - config?: LayoutConfiguration[]; - /** - * Height of grid row in pixels. - * @defaultValue 60 - */ - rowHeight?: number; - /** - * Screen width in pixels for different breakpoints. - * @defaultValue theme breakpoints - */ - breakpoints?: Record; - /** - * Number of grid columns for different breakpoints. - * @defaultValue \{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 \} - */ - cols?: Record; - /** - * Grid container padding (x, y) in pixels for all or specific breakpoints. - * @defaultValue [0, 0] - * @example [10, 10] - * @example \{ lg: [10, 10] \} - */ - containerPadding?: [number, number] | Record; - /** - * Grid container margin (x, y) in pixels for all or specific breakpoints. - * @defaultValue [0, 0] - * @example [10, 10] - * @example \{ lg: [10, 10] \} - */ - containerMargin?: [number, number] | Record; - /** - * Maximum number of rows user can have in the grid. - * @defaultValue unlimited - */ - maxRows?: number; - /** - * Custom style for grid. - */ - style?: React.CSSProperties; - /** - * Compaction type of widgets in the grid. This controls where widgets are moved in case - * they are overlapping in the grid. - */ - compactType?: 'vertical' | 'horizontal' | null; - /** - * Controls if widgets can overlap in the grid. If true, grid can be placed one over the other. - * @defaultValue false - */ - allowOverlap?: boolean; - /** - * Controls if widgets can collide with each other. If true, grid items won't change position when being dragged over. - * @defaultValue false - */ - preventCollision?: boolean; -}; - /** * A component that allows customizing components in home grid layout. * @@ -318,6 +246,9 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { isDraggable: editMode, }, settings: {}, + movable: widget.movable, + deletable: widget.deletable, + resizable: widget.resizable, }, ]); setAddWidgetDialogOpen(false); @@ -348,9 +279,11 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { setEditMode(mode); setWidgets( widgets.map(w => { + const resizable = w.resizable === false ? false : mode; + const movable = w.movable === false ? false : mode; return { ...w, - layout: { ...w.layout, isDraggable: mode, isResizable: mode }, + layout: { ...w.layout, isDraggable: movable, isResizable: resizable }, }; }), ); @@ -370,7 +303,20 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { }; const handleRestoreDefaultConfig = () => { - setWidgets(defaultLayout); + setWidgets( + defaultLayout.map(w => { + const resizable = w.resizable === false ? false : editMode; + const movable = w.movable === false ? false : editMode; + return { + ...w, + layout: { + ...w.layout, + isDraggable: movable, + isResizable: resizable, + }, + }; + }), + ); }; return ( @@ -404,7 +350,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { style={props.style} allowOverlap={props.allowOverlap} preventCollision={props.preventCollision} - draggableCancel=".overlayGridItem,.widgetSettingsDialog" + draggableCancel=".overlayGridItem,.widgetSettingsDialog,.disabled" containerPadding={props.containerPadding} margin={props.containerMargin} breakpoints={ @@ -435,7 +381,9 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { return (
@@ -447,6 +395,7 @@ export const CustomHomepageGrid = (props: CustomHomepageGridProps) => { handleRemove={handleRemove} handleSettingsSave={handleSettingsSave} settings={w.settings} + deletable={w.deletable} /> )}
diff --git a/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx b/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx index 261dabb833..2c3a1d9ab9 100644 --- a/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx +++ b/plugins/home/src/components/CustomHomepage/WidgetSettingsOverlay.tsx @@ -58,10 +58,12 @@ interface WidgetSettingsOverlayProps { handleRemove: (id: string) => void; handleSettingsSave: (id: string, settings: Record) => void; settings?: Record; + deletable?: boolean; } export const WidgetSettingsOverlay = (props: WidgetSettingsOverlayProps) => { - const { id, widget, settings, handleRemove, handleSettingsSave } = props; + const { id, widget, settings, handleRemove, handleSettingsSave, deletable } = + props; const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false); const styles = useStyles(); @@ -110,13 +112,15 @@ export const WidgetSettingsOverlay = (props: WidgetSettingsOverlayProps) => { )} - - - handleRemove(id)}> - - - - + {deletable !== false && ( + + + handleRemove(id)}> + + + + + )} ); diff --git a/plugins/home/src/components/CustomHomepage/index.ts b/plugins/home/src/components/CustomHomepage/index.ts index eed597cca3..380fdb4b25 100644 --- a/plugins/home/src/components/CustomHomepage/index.ts +++ b/plugins/home/src/components/CustomHomepage/index.ts @@ -14,5 +14,8 @@ * limitations under the License. */ export { CustomHomepageGrid } from './CustomHomepageGrid'; -export type { CustomHomepageGridProps, Breakpoint } from './CustomHomepageGrid'; -export type { LayoutConfiguration } from './types'; +export type { + CustomHomepageGridProps, + Breakpoint, + LayoutConfiguration, +} from './types'; diff --git a/plugins/home/src/components/CustomHomepage/types.ts b/plugins/home/src/components/CustomHomepage/types.ts index 8e9c6f403e..79e94c5f03 100644 --- a/plugins/home/src/components/CustomHomepage/types.ts +++ b/plugins/home/src/components/CustomHomepage/types.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ReactElement } from 'react'; +import React, { ReactElement, ReactNode } from 'react'; import { Layout } from 'react-grid-layout'; import { z } from 'zod'; import { RJSFSchema, UiSchema } from '@rjsf/utils'; @@ -24,12 +24,91 @@ const RSJFTypeUiSchema: z.ZodType = z.any(); const ReactElementSchema: z.ZodType = z.any(); const LayoutSchema: z.ZodType = z.any(); +/** + * Breakpoint options for + * + * @public + */ +export type Breakpoint = 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +/** + * Props customizing the component. + * + * @public + */ +export type CustomHomepageGridProps = { + /** + * Children contain all widgets user can configure on their own homepage. + */ + children?: ReactNode; + /** + * Default layout for the homepage before users have modified it. + */ + config?: LayoutConfiguration[]; + /** + * Height of grid row in pixels. + * @defaultValue 60 + */ + rowHeight?: number; + /** + * Screen width in pixels for different breakpoints. + * @defaultValue theme breakpoints + */ + breakpoints?: Record; + /** + * Number of grid columns for different breakpoints. + * @defaultValue \{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 \} + */ + cols?: Record; + /** + * Grid container padding (x, y) in pixels for all or specific breakpoints. + * @defaultValue [0, 0] + * @example [10, 10] + * @example \{ lg: [10, 10] \} + */ + containerPadding?: [number, number] | Record; + /** + * Grid container margin (x, y) in pixels for all or specific breakpoints. + * @defaultValue [0, 0] + * @example [10, 10] + * @example \{ lg: [10, 10] \} + */ + containerMargin?: [number, number] | Record; + /** + * Maximum number of rows user can have in the grid. + * @defaultValue unlimited + */ + maxRows?: number; + /** + * Custom style for grid. + */ + style?: React.CSSProperties; + /** + * Compaction type of widgets in the grid. This controls where widgets are moved in case + * they are overlapping in the grid. + */ + compactType?: 'vertical' | 'horizontal' | null; + /** + * Controls if widgets can overlap in the grid. If true, grid can be placed one over the other. + * @defaultValue false + */ + allowOverlap?: boolean; + /** + * Controls if widgets can collide with each other. If true, grid items won't change position when being dragged over. + * @defaultValue false + */ + preventCollision?: boolean; +}; + export const LayoutConfigurationSchema = z.object({ component: ReactElementSchema, x: z.number().nonnegative('x must be positive number'), y: z.number().nonnegative('y must be positive number'), width: z.number().positive('width must be positive number'), height: z.number().positive('height must be positive number'), + movable: z.boolean().optional(), + deletable: z.boolean().optional(), + resizable: z.boolean().optional(), }); /** @@ -43,6 +122,9 @@ export type LayoutConfiguration = { y: number; width: number; height: number; + movable?: boolean; + deletable?: boolean; + resizable?: boolean; }; export const WidgetSchema = z.object({ @@ -64,6 +146,9 @@ export const WidgetSchema = z.object({ .optional(), settingsSchema: RSJFTypeSchema.optional(), uiSchema: RSJFTypeUiSchema.optional(), + movable: z.boolean().optional(), + deletable: z.boolean().optional(), + resizable: z.boolean().optional(), }); export type Widget = z.infer; @@ -72,6 +157,9 @@ const GridWidgetSchema = z.object({ id: z.string(), layout: LayoutSchema, settings: z.record(z.string(), z.any()), + movable: z.boolean().optional(), + deletable: z.boolean().optional(), + resizable: z.boolean().optional(), }); export type GridWidget = z.infer; diff --git a/plugins/opencost/src/components/SelectWindow.jsx b/plugins/opencost/src/components/SelectWindow.jsx index e374571c00..470770f35c 100644 --- a/plugins/opencost/src/components/SelectWindow.jsx +++ b/plugins/opencost/src/components/SelectWindow.jsx @@ -16,7 +16,6 @@ import React, { useEffect, useState } from 'react'; import { makeStyles } from '@material-ui/styles'; -import { endOfDay, startOfDay } from '@date-io/luxon'; import { MuiPickersUtilsProvider, KeyboardDatePicker, @@ -67,14 +66,14 @@ const SelectWindow = ({ windowOptions, window, setWindow }) => { }; const handleStartDateChange = date => { - if (isValid(date)) { - setStartDate(startOfDay(date)); + if (isValid(date.toJSDate())) { + setStartDate(date.startOf('day').toJSDate()); } }; const handleEndDateChange = date => { - if (isValid(date)) { - setEndDate(endOfDay(date)); + if (isValid(date.toJSDate())) { + setEndDate(date.endOf('day').toJSDate()); } }; diff --git a/plugins/org/api-report.md b/plugins/org/api-report.md index 704401e8d4..d85df7125c 100644 --- a/plugins/org/api-report.md +++ b/plugins/org/api-report.md @@ -30,10 +30,13 @@ export const EntityOwnershipCard: (props: { variant?: InfoCardVariants | undefined; entityFilterKind?: string[] | undefined; hideRelationsToggle?: boolean | undefined; - relationsType?: string | undefined; + relationsType?: EntityRelationAggregation | undefined; entityLimit?: number | undefined; }) => JSX_2.Element; +// @public (undocumented) +export type EntityRelationAggregation = 'direct' | 'aggregated'; + // @public (undocumented) export const EntityUserProfileCard: (props: { variant?: InfoCardVariants | undefined; @@ -77,7 +80,7 @@ export const OwnershipCard: (props: { variant?: InfoCardVariants; entityFilterKind?: string[]; hideRelationsToggle?: boolean; - relationsType?: string; + relationsType?: EntityRelationAggregation; entityLimit?: number; }) => React_2.JSX.Element; diff --git a/plugins/org/package.json b/plugins/org/package.json index 0193a1592b..81e5a2aa6f 100644 --- a/plugins/org/package.json +++ b/plugins/org/package.json @@ -60,6 +60,7 @@ "@testing-library/dom": "^8.0.0", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^14.0.0", "@types/node": "^16.11.26", "msw": "^1.0.0" diff --git a/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx index 8dab5c4290..2725991d0d 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/ComponentsGrid.tsx @@ -34,6 +34,7 @@ import React from 'react'; import pluralize from 'pluralize'; import { catalogIndexRouteRef } from '../../../routes'; import { useGetEntities } from './useGetEntities'; +import { EntityRelationAggregation } from './types'; const useStyles = makeStyles((theme: BackstageTheme) => createStyles({ @@ -109,13 +110,11 @@ const EntityCountTile = ({ export const ComponentsGrid = ({ entity, relationsType, - isGroup, entityFilterKind, entityLimit = 6, }: { entity: Entity; - relationsType: string; - isGroup: boolean; + relationsType: EntityRelationAggregation; entityFilterKind?: string[]; entityLimit?: number; }) => { @@ -123,7 +122,6 @@ export const ComponentsGrid = ({ const { componentsWithCounters, loading, error } = useGetEntities( entity, relationsType, - isGroup, entityFilterKind, entityLimit, ); diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx index 30b7dee2de..faba9b5627 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.test.tsx @@ -263,10 +263,11 @@ describe('OwnershipCard', () => { }, ); - expect(getByText('OPENAPI').closest('a')).toHaveAttribute( - 'href', - '/create/?filters%5Bkind%5D=api&filters%5Btype%5D=openapi&filters%5Bowners%5D=my-team&filters%5Buser%5D=all', - ); + const href = getByText('OPENAPI').closest('a')?.href ?? ''; + // This env does not support URLSearchParams + const queryParams = decodeURIComponent(href); + + expect(queryParams).toContain('filters[owners]=my-team'); }); it('links to the catalog with the user and groups filters from an user profile', async () => { @@ -299,7 +300,7 @@ describe('OwnershipCard', () => { const { getByText } = await renderInTestApp( - + , { @@ -309,9 +310,11 @@ describe('OwnershipCard', () => { }, ); - expect(getByText('OPENAPI').closest('a')).toHaveAttribute( - 'href', - '/create/?filters%5Bkind%5D=api&filters%5Btype%5D=openapi&filters%5Bowners%5D=user%3Athe-user&filters%5Bowners%5D=my-team&filters%5Bowners%5D=custom%2Fsome-team&filters%5Buser%5D=all', + const href = getByText('OPENAPI').closest('a')?.href ?? ''; + // This env does not support URLSearchParams + const queryParams = decodeURIComponent(href); + expect(queryParams).toMatch( + /filters\[owners\]=custom\/some\-team.*filters\[owners\]=user:the-user/, ); }); diff --git a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx index d104eb032d..2fb7826e52 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx +++ b/plugins/org/src/components/Cards/OwnershipCard/OwnershipCard.tsx @@ -27,6 +27,7 @@ import { } from '@material-ui/core'; import React, { useState } from 'react'; import { ComponentsGrid } from './ComponentsGrid'; +import { EntityRelationAggregation } from './types'; const useStyles = makeStyles(theme => ({ list: { @@ -56,7 +57,7 @@ export const OwnershipCard = (props: { variant?: InfoCardVariants; entityFilterKind?: string[]; hideRelationsToggle?: boolean; - relationsType?: string; + relationsType?: EntityRelationAggregation; entityLimit?: number; }) => { const { @@ -70,7 +71,6 @@ export const OwnershipCard = (props: { hideRelationsToggle === undefined ? false : hideRelationsToggle; const classes = useStyles(); const { entity } = useEntity(); - const isGroup = entity.kind === 'Group'; const [getRelationsType, setRelationsType] = useState( relationsType || 'direct', ); @@ -102,7 +102,6 @@ export const OwnershipCard = (props: { } name="pin" inputProps={{ 'aria-label': 'Ownership Type Switch' }} - disabled={!isGroup} /> Aggregated Relations @@ -114,7 +113,6 @@ export const OwnershipCard = (props: { entity={entity} entityLimit={entityLimit} relationsType={getRelationsType} - isGroup={isGroup} entityFilterKind={entityFilterKind} /> diff --git a/plugins/org/src/components/Cards/OwnershipCard/index.ts b/plugins/org/src/components/Cards/OwnershipCard/index.ts index dc0cef0226..a4798cc4bf 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/index.ts +++ b/plugins/org/src/components/Cards/OwnershipCard/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './OwnershipCard'; +export * from './types'; diff --git a/plugins/catalog-backend/src/service/request/parseFullTextFilterFields.ts b/plugins/org/src/components/Cards/OwnershipCard/types.ts similarity index 64% rename from plugins/catalog-backend/src/service/request/parseFullTextFilterFields.ts rename to plugins/org/src/components/Cards/OwnershipCard/types.ts index 02ba5864fc..c1e95ee94e 100644 --- a/plugins/catalog-backend/src/service/request/parseFullTextFilterFields.ts +++ b/plugins/org/src/components/Cards/OwnershipCard/types.ts @@ -1,5 +1,3 @@ -import { parseStringParam } from './common'; - /* * Copyright 2023 The Backstage Authors * @@ -15,14 +13,6 @@ import { parseStringParam } from './common'; * See the License for the specific language governing permissions and * limitations under the License. */ -export function parseFullTextFilterFields(params: Record) { - const fullTextFilterFields = parseStringParam( - params.fullTextFilterFields, - 'fullTextFilterFields', - ); - if (!fullTextFilterFields) { - return undefined; - } - return fullTextFilterFields.split(','); -} +/** @public */ +export type EntityRelationAggregation = 'direct' | 'aggregated'; diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts new file mode 100644 index 0000000000..191bd10284 --- /dev/null +++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright 2023 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 { CompoundEntityRef, Entity } from '@backstage/catalog-model'; +import { useGetEntities } from './useGetEntities'; +import { CatalogApi } from '@backstage/catalog-client'; +import { renderHook } from '@testing-library/react-hooks'; +import { getEntityRelations } from '@backstage/plugin-catalog-react'; + +const givenParentGroup = 'team.squad1'; +const givenLeafGroup = 'team.squad2'; +const givenUser = 'user.john'; +const givenParentGroupEntity = { + kind: 'Group', + metadata: { + name: givenParentGroup, + }, +} as Partial as Entity; +const givenLeafGroupEntity = { + kind: 'Group', + metadata: { + name: givenLeafGroup, + }, +} as Partial as Entity; +const givenUserEntity = { + kind: 'User', + metadata: { + name: givenUser, + }, +} as Partial as Entity; + +const catalogApiMock: Pick = { + getEntities: jest.fn(async () => Promise.resolve({ items: [] })), + getEntitiesByRefs: jest.fn(async ({ entityRefs: [ref] }) => + ref.includes(givenParentGroup) + ? { items: [givenParentGroupEntity] } + : { items: [givenLeafGroupEntity] }, + ), +}; + +jest.mock('@backstage/core-plugin-api', () => ({ + useApi: jest.fn(() => catalogApiMock), +})); +jest.mock('@backstage/plugin-catalog-react', () => ({ + catalogApiRef: {}, + getEntityRelations: jest.fn(entity => { + if (entity?.metadata.name === givenParentGroup) { + return [ + { + kind: 'Group', + namespace: 'default', + name: givenLeafGroup, + } as CompoundEntityRef, + ]; + } else if (entity?.kind === 'User') { + return [ + { + kind: 'Group', + namespace: 'default', + name: givenLeafGroup, + } as CompoundEntityRef, + ]; + } + + return []; + }) as typeof getEntityRelations, +})); + +describe('useGetEntities', () => { + const ownersFilter = (...owners: string[]) => + expect.objectContaining({ + filter: expect.arrayContaining([ + expect.objectContaining({ + 'relations.ownedBy': owners, + }), + ]), + }); + + describe('given aggregated relationsType', () => { + const whenHookIsCalledWith = async (_entity: Entity) => { + const hook = renderHook( + ({ entity }) => useGetEntities(entity, 'aggregated'), + { + initialProps: { entity: _entity }, + }, + ); + + await hook.waitForNextUpdate(); + }; + + it('given group entity should aggregate child ownership', async () => { + await whenHookIsCalledWith(givenParentGroupEntity); + expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + ownersFilter( + `group:default/${givenParentGroup}`, + `group:default/${givenLeafGroup}`, + ), + ); + }); + + it('given user entity should aggregate parent ownership and direct', async () => { + await whenHookIsCalledWith(givenUserEntity); + expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + ownersFilter( + `group:default/${givenLeafGroup}`, + `user:default/${givenUser}`, + ), + ); + }); + }); + + describe('given direct relationsType', () => { + const whenHookIsCalledWith = async (_entity: Entity) => { + const hook = renderHook( + ({ entity }) => useGetEntities(entity, 'direct'), + { + initialProps: { entity: _entity }, + }, + ); + + await hook.waitForNextUpdate(); + }; + + it('given group entity should return directly owned entities', async () => { + await whenHookIsCalledWith(givenLeafGroupEntity); + expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + ownersFilter(`group:default/${givenLeafGroup}`), + ); + }); + + it('given user entity should return directly owned entities', async () => { + await whenHookIsCalledWith(givenUserEntity); + expect(catalogApiMock.getEntities).toHaveBeenCalledWith( + ownersFilter(`user:default/${givenUser}`), + ); + }); + }); +}); diff --git a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts index a59f534300..1ff01cd8e1 100644 --- a/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts +++ b/plugins/org/src/components/Cards/OwnershipCard/useGetEntities.ts @@ -31,6 +31,7 @@ import limiterFactory from 'p-limit'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/lib/useAsync'; import qs from 'qs'; +import { EntityRelationAggregation as EntityRelationsAggregation } from './types'; const limiter = limiterFactory(10); @@ -57,78 +58,109 @@ const getQueryParams = ( return qs.stringify({ filters }, { arrayFormat: 'repeat' }); }; -const getOwnersEntityRef = (owner: Entity): string[] => { - let owners = [stringifyEntityRef(owner)]; - if (owner.kind === 'User') { - const ownerGroups = getEntityRelations(owner, RELATION_MEMBER_OF, { - kind: 'Group', +const getMemberOfEntityRefs = (owner: Entity): string[] => { + const parentGroups = getEntityRelations(owner, RELATION_MEMBER_OF, { + kind: 'Group', + }); + + const ownerGroupsNames = parentGroups.map(({ kind, namespace, name }) => + stringifyEntityRef({ + kind, + namespace, + name, + }), + ); + + return [...ownerGroupsNames, stringifyEntityRef(owner)]; +}; + +const isEntity = (entity: Entity | undefined): entity is Entity => + entity !== undefined; + +const getChildOwnershipEntityRefs = async ( + entity: Entity, + catalogApi: CatalogApi, +): Promise => { + const childGroups = getEntityRelations(entity, RELATION_PARENT_OF, { + kind: 'Group', + }); + + const hasChildGroups = childGroups.length > 0; + + if (hasChildGroups) { + const entityRefs = childGroups.map(r => stringifyEntityRef(r)); + const childGroupResponse = await catalogApi.getEntitiesByRefs({ + fields: ['kind', 'metadata.namespace', 'metadata.name'], + entityRefs, }); - const ownerGroupsName = ownerGroups.map(ownerGroup => - stringifyEntityRef({ - kind: ownerGroup.kind, - namespace: ownerGroup.namespace, - name: ownerGroup.name, - }), - ); - owners = [...owners, ...ownerGroupsName]; + const childGroupEntities = childGroupResponse.items.filter(isEntity); + + return ( + await Promise.all( + childGroupEntities.map(childGroupEntity => + limiter(() => + getChildOwnershipEntityRefs(childGroupEntity, catalogApi), + ), + ), + ) + ).flatMap(aggregated => aggregated); } + + return [stringifyEntityRef(entity)]; +}; + +const getOwners = async ( + entity: Entity, + relations: EntityRelationsAggregation, + catalogApi: CatalogApi, +): Promise => { + const isGroup = entity.kind === 'Group'; + const isAggregated = relations === 'aggregated'; + const isUserEntity = entity.kind === 'User'; + + const owners: string[] = []; + + if (isAggregated && isGroup) { + const childEntityRefs = await getChildOwnershipEntityRefs( + entity, + catalogApi, + ); + owners.push(stringifyEntityRef(entity)); + owners.push.apply(owners, childEntityRefs); + } else if (isAggregated && isUserEntity) { + const parentEntityRefs = getMemberOfEntityRefs(entity); + owners.push.apply(owners, parentEntityRefs); + } else { + owners.push(stringifyEntityRef(entity)); + } + return owners; }; -const getAggregatedOwnersEntityRef = async ( - parentGroup: Entity, +const getOwnedEntitiesByOwners = ( + owners: string[], + kinds: string[], catalogApi: CatalogApi, -): Promise => { - const requestedEntities: Entity[] = []; - const outstandingEntities = new Map>(); - const processedEntities = new Set(); - requestedEntities.push(parentGroup); - let currentEntity = parentGroup; - - while (requestedEntities.length > 0) { - const childRelations = getEntityRelations( - currentEntity, - RELATION_PARENT_OF, +) => + catalogApi.getEntities({ + filter: [ { - kind: 'Group', + kind: kinds, + 'relations.ownedBy': owners, }, - ); - - await Promise.all( - childRelations.map(childGroup => - limiter(async () => { - const promise = catalogApi.getEntityByRef(childGroup); - outstandingEntities.set(childGroup.name, promise); - try { - const processedEntity = await promise; - if (processedEntity) { - requestedEntities.push(processedEntity); - } - } finally { - outstandingEntities.delete(childGroup.name); - } - }), - ), - ); - requestedEntities.shift(); - processedEntities.add( - stringifyEntityRef({ - kind: currentEntity.kind, - namespace: currentEntity.metadata.namespace, - name: currentEntity.metadata.name, - }), - ); - // always set currentEntity to the first element of array requestedEntities - currentEntity = requestedEntities[0]; - } - - return Array.from(processedEntities); -}; + ], + fields: [ + 'kind', + 'metadata.name', + 'metadata.namespace', + 'spec.type', + 'relations', + ], + }); export function useGetEntities( entity: Entity, - relationsType: string, - isGroup: boolean, + relations: EntityRelationsAggregation, entityFilterKind?: string[], entityLimit = 6, ): { @@ -151,25 +183,13 @@ export function useGetEntities( error, value: componentsWithCounters, } = useAsync(async () => { - const owners = - relationsType === 'aggregated' && isGroup - ? await getAggregatedOwnersEntityRef(entity, catalogApi) - : getOwnersEntityRef(entity); - const ownedEntitiesList = await catalogApi.getEntities({ - filter: [ - { - kind: kinds, - 'relations.ownedBy': owners, - }, - ], - fields: [ - 'kind', - 'metadata.name', - 'metadata.namespace', - 'spec.type', - 'relations', - ], - }); + const owners = await getOwners(entity, relations, catalogApi); + + const ownedEntitiesList = await getOwnedEntitiesByOwners( + owners, + kinds, + catalogApi, + ); const counts = ownedEntitiesList.items.reduce( (acc: EntityTypeProps[], ownedEntity) => { @@ -204,7 +224,7 @@ export function useGetEntities( kind: string; queryParams: string; }>; - }, [catalogApi, entity, relationsType]); + }, [catalogApi, entity, relations]); return { componentsWithCounters, diff --git a/plugins/org/src/setupTests.ts b/plugins/org/src/setupTests.ts index 28a35d2b06..09cb508b22 100644 --- a/plugins/org/src/setupTests.ts +++ b/plugins/org/src/setupTests.ts @@ -14,3 +14,7 @@ * limitations under the License. */ import '@testing-library/jest-dom'; + +beforeEach(() => { + jest.clearAllMocks(); +}); diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx index 3d6ffc5f8c..c74859ce20 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx @@ -108,7 +108,7 @@ export const PlaylistEntitiesTable = ({ const actions = editAllowed ? [ { - icon: DeleteIcon, + icon: () => , tooltip: `Remove from ${singularTitleLowerCase}`, onClick: removeEntity, }, diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx index f892e8de7f..3a31dcaade 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistHeader.tsx @@ -166,7 +166,7 @@ export const PlaylistHeader = ({ playlist, onUpdate }: PlaylistHeaderProps) => { }, { label: `Delete ${singularTitle}`, - icon: , + icon: , disabled: !deleteAllowed, onClick: () => setOpenDeleteDialog(true), }, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.test.ts new file mode 100644 index 0000000000..fe04142a98 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.test.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2023 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 { + ScmIntegrations, + DefaultGithubCredentialsProvider, + GithubCredentialsProvider, +} from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { PassThrough } from 'stream'; +import { createGithubActionsDispatchAction } from './githubActionsDispatch'; +import yaml from 'yaml'; +import { examples } from './githubActionsDispatch.examples'; + +const mockOctokit = { + rest: { + actions: { + createWorkflowDispatch: jest.fn(), + }, + }, +}; +jest.mock('octokit', () => ({ + Octokit: class { + constructor() { + return mockOctokit; + } + }, +})); + +describe('github:actions:dispatch', () => { + const config = new ConfigReader({ + integrations: { + github: [ + { host: 'github.com', token: 'tokenlols' }, + { host: 'ghe.github.com' }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + let githubCredentialsProvider: GithubCredentialsProvider; + let action: TemplateAction; + + const mockContext = { + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + workflowId: 'a-workflow-id', + branchOrTagName: 'main', + }, + workspacePath: 'lol', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + + beforeEach(() => { + jest.resetAllMocks(); + githubCredentialsProvider = + DefaultGithubCredentialsProvider.fromIntegrations(integrations); + action = createGithubActionsDispatchAction({ + integrations, + githubCredentialsProvider, + }); + }); + + it('should call the githubApis for creating WorkflowDispatch without an input object', async () => { + mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({ + data: { + foo: 'bar', + }, + }); + + const repoUrl = 'github.com?repo=repo&owner=owner'; + const workflowId = 'dispatch_workflow'; + const branchOrTagName = 'main'; + const ctx = Object.assign({}, mockContext, { + input: { repoUrl, workflowId, branchOrTagName }, + }); + await action.handler(ctx); + + expect( + mockOctokit.rest.actions.createWorkflowDispatch, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + workflow_id: workflowId, + ref: branchOrTagName, + }); + }); + + it('should call the githubApis for creating WorkflowDispatch with an input object', async () => { + mockOctokit.rest.actions.createWorkflowDispatch.mockResolvedValue({ + data: { + foo: 'bar', + }, + }); + + const repoUrl = 'github.com?repo=repo&owner=owner'; + const workflowId = 'dispatch_workflow'; + const branchOrTagName = 'main'; + const workflowInputs = yaml.parse(examples[1].example).steps[0].input + .workflowInputs; + const ctx = Object.assign({}, mockContext, { + input: { repoUrl, workflowId, branchOrTagName, workflowInputs }, + }); + await action.handler(ctx); + expect( + mockOctokit.rest.actions.createWorkflowDispatch, + ).toHaveBeenCalledWith({ + owner: 'owner', + repo: 'repo', + workflow_id: workflowId, + ref: branchOrTagName, + inputs: workflowInputs, + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.ts new file mode 100644 index 0000000000..33ad86dd89 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.examples.ts @@ -0,0 +1,74 @@ +/* + * Copyright 2023 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'GitHub Action Workflow Without Inputs.', + example: yaml.stringify({ + steps: [ + { + action: 'github:actions:dispatch', + name: 'Dispatch Github Action Workflow', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + workflowId: 'WORKFLOW_ID', + branchOrTagName: 'main', + }, + }, + ], + }), + }, + { + description: 'GitHub Action Workflow With Inputs', + example: yaml.stringify({ + steps: [ + { + action: 'github:actions:dispatch', + name: 'Dispatch Github Action Workflow with inputs', + input: { + repoUrl: 'github.com?repo=repo&owner=owner', + workflowId: 'WORKFLOW_ID', + branchOrTagName: 'main', + workflowInputs: { + input1: 'value1', + input2: 'value2', + }, + }, + }, + ], + }), + }, + { + description: 'GitHub Action Workflow With Custom Token', + example: yaml.stringify({ + steps: [ + { + action: 'github:actions:dispatch', + name: 'Dispatch GitHub Action Workflow (custom token)', + input: { + repoUrl: 'github.com?repo=reponame&owner=owner', + workflowId: 'WORKFLOW_ID', + branchOrTagName: 'release-1.0', + token: '${{ secrets.MY_CUSTOM_TOKEN }}', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts index cd1593ee9f..6623ee1f27 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/github/githubActionsDispatch.ts @@ -23,6 +23,7 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { Octokit } from 'octokit'; import { parseRepoUrl } from '../publish/util'; import { getOctokitOptions } from './helpers'; +import { examples } from './githubActionsDispatch.examples'; /** * Creates a new action that dispatches a GitHub Action workflow for a given branch or tag. @@ -44,6 +45,7 @@ export function createGithubActionsDispatchAction(options: { id: 'github:actions:dispatch', description: 'Dispatches a GitHub Action workflow for a given branch or tag', + examples, schema: { input: { type: 'object', diff --git a/plugins/search-react/src/context/SearchContext.tsx b/plugins/search-react/src/context/SearchContext.tsx index add62b39df..f6fe08e866 100644 --- a/plugins/search-react/src/context/SearchContext.tsx +++ b/plugins/search-react/src/context/SearchContext.tsx @@ -102,7 +102,7 @@ export const useSearchContextCheck = () => { * The initial state of `SearchContextProvider`. * */ -const searchInitialState: SearchContextState = { +const defaultInitialSearchState: SearchContextState = { term: '', types: [], filters: {}, @@ -111,7 +111,7 @@ const searchInitialState: SearchContextState = { }; const useSearchContextValue = ( - initialValue: SearchContextState = searchInitialState, + initialValue: SearchContextState = defaultInitialSearchState, ) => { const searchApi = useApi(searchApiRef); @@ -245,12 +245,16 @@ export const SearchContextProvider = (props: SearchContextProviderProps) => { const configApi = useApi(configApiRef); + const propsInitialSearchState = initialState ?? {}; + + const configInitialSearchState = configApi.has('search.query.pageLimit') + ? { pageLimit: configApi.getNumber('search.query.pageLimit') } + : {}; + const searchContextInitialState = { - ...searchInitialState, - ...(initialState || {}), - pageLimit: - configApi.getOptionalNumber('search.query.pageLimit') || - initialState?.pageLimit, + ...defaultInitialSearchState, + ...propsInitialSearchState, + ...configInitialSearchState, }; return hasParentContext && inheritParentContextIfAvailable ? ( diff --git a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx index df39c2ce55..7e2dabc3d4 100644 --- a/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx +++ b/plugins/tech-radar/src/components/RadarDescription/RadarDescription.tsx @@ -45,7 +45,13 @@ const RadarDescription = (props: Props): JSX.Element => { const { open, onClose, title, description, timeline, url, links } = props; return ( - + {title} diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index d00aabd025..abc74f796a 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -57,6 +57,11 @@ export interface Config { * will be broken in these scenarios. */ legacyCopyReadmeMdToIndexMd?: boolean; + + /** + * List of mkdocs plugins which should be added as default to all mkdocs.yml files. + */ + defaultPlugins?: string[]; }; }; diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx b/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx index b74c2f9659..38269e6245 100644 --- a/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx +++ b/plugins/techdocs-module-addons-contrib/src/LigthBox/LightBox.tsx @@ -17,7 +17,7 @@ import { useEffect } from 'react'; import { useShadowRootElements } from '@backstage/plugin-techdocs-react'; // @ts-ignore -import PhotoSwipeLightbox, { DataSource } from 'photoswipe/lightbox'; +import PhotoSwipeLightbox, { DataSource, ZoomLevel } from 'photoswipe/lightbox'; import PhotoSwipe from 'photoswipe'; import 'photoswipe/style.css'; import './lightbox.css'; @@ -30,6 +30,21 @@ export const LightBoxAddon = () => { let lightbox = new PhotoSwipeLightbox({ pswpModule: PhotoSwipe, + initialZoomLevel: 1, + secondaryZoomLevel: (zoomLevelObject: ZoomLevel) => { + // photoswipe/lightbox won't zoom the image further then the given width and height. + // therefore we need to calculate the zoom factor needed to fit the complete image in the viewport manually. + const imageWidth = zoomLevelObject.elementSize.x; + const imageHeight = zoomLevelObject.elementSize.y; + const viewportWidth = zoomLevelObject.panAreaSize.x; + const viewportHeight = zoomLevelObject.panAreaSize.y; + + const widthScale = viewportWidth / imageWidth; + const heightScale = viewportHeight / imageHeight; + + const scaleFactor = Math.min(widthScale, heightScale); + return scaleFactor; + }, wheelToZoom: true, arrowPrevSVG: '', @@ -37,8 +52,9 @@ export const LightBoxAddon = () => { '', closeSVG: '', - zoomSVG: - '', + zoomSVG: ``, }); images.forEach((image, index) => { diff --git a/plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css b/plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css index 3371d0fa53..c864699bed 100644 --- a/plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css +++ b/plugins/techdocs-module-addons-contrib/src/LigthBox/lightbox.css @@ -9,3 +9,13 @@ .pswp__img { cursor: pointer !important; } + +/* hide "-" icon path */ +.pswp--zoomed-allowed #photoswipe-zoom-icon-zoomout-path { + display: none; +} + +/* hide "+" path and show "-" */ +.pswp--zoomed-in #photoswipe-zoom-icon-zoomin-path { + display: none; +} diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 6bcdf0d984..e20e20ea5b 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -33,7 +33,7 @@ import { } from './helpers'; import { patchMkdocsYmlPreBuild, - pathMkdocsYmlWithTechdocsPlugin, + patchMkdocsYmlWithPlugins, } from './mkdocsPatchers'; import yaml from 'js-yaml'; @@ -310,7 +310,7 @@ describe('helpers', () => { }); }); - describe('pathMkdocsYmlWithTechdocsPlugin', () => { + describe('patchMkdocsYmlWithPlugins', () => { beforeEach(() => { mockFs({ '/mkdocs_with_techdocs_plugin.yml': mkdocsYmlWithTechdocsPlugins, @@ -319,7 +319,7 @@ describe('helpers', () => { }); }); it('should not add additional plugins if techdocs exists already in mkdocs file', async () => { - await pathMkdocsYmlWithTechdocsPlugin( + await patchMkdocsYmlWithPlugins( '/mkdocs_with_techdocs_plugin.yml', mockLogger, ); @@ -334,7 +334,7 @@ describe('helpers', () => { expect(parsedYml.plugins).toContain('techdocs-core'); }); it("should add the needed plugin if it doesn't exist in mkdocs file", async () => { - await pathMkdocsYmlWithTechdocsPlugin( + await patchMkdocsYmlWithPlugins( '/mkdocs_without_plugins.yml', mockLogger, ); @@ -347,7 +347,7 @@ describe('helpers', () => { expect(parsedYml.plugins).toContain('techdocs-core'); }); it('should not override existing plugins', async () => { - await pathMkdocsYmlWithTechdocsPlugin( + await patchMkdocsYmlWithPlugins( '/mkdocs_with_additional_plugins.yml', mockLogger, ); @@ -362,6 +362,23 @@ describe('helpers', () => { expect(parsedYml.plugins).toContain('not-techdocs-core'); expect(parsedYml.plugins).toContain('also-not-techdocs-core'); }); + it('should add all provided default plugins', async () => { + await patchMkdocsYmlWithPlugins( + '/mkdocs_with_additional_plugins.yml', + mockLogger, + ['techdocs-core', 'custom-plugin'], + ); + + const updatedMkdocsYml = await fs.readFile( + '/mkdocs_with_additional_plugins.yml', + ); + const parsedYml = yaml.load(updatedMkdocsYml.toString()) as { + plugins: string[]; + }; + expect(parsedYml.plugins).toHaveLength(4); + expect(parsedYml.plugins).toContain('techdocs-core'); + expect(parsedYml.plugins).toContain('custom-plugin'); + }); }); describe('patchIndexPreBuild', () => { diff --git a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts index d03b5d83c2..945aab3884 100644 --- a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts +++ b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts @@ -138,27 +138,34 @@ export const patchMkdocsYmlPreBuild = async ( * Update the mkdocs.yml file before TechDocs generator uses it to generate docs site. * * List of tasks: - * - Add techdocs-core plugin to mkdocs file if it doesn't exist + * - Add all provided default plugins * * This function will not throw an error since this is not critical to the whole TechDocs pipeline. * Instead it will log warnings if there are any errors in reading, parsing or writing YAML. * * @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site * @param logger - A logger instance + * @param defaultPlugins - List of default mkdocs plugins */ -export const pathMkdocsYmlWithTechdocsPlugin = async ( +export const patchMkdocsYmlWithPlugins = async ( mkdocsYmlPath: string, logger: Logger, + defaultPlugins: string[] = ['techdocs-core'], ) => { await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => { - // Modify mkdocs.yaml to contain the needed techdocs-core plugin if it is not there + // Modify mkdocs.yaml to contain the required default plugins if (!('plugins' in mkdocsYml)) { - mkdocsYml.plugins = ['techdocs-core']; + mkdocsYml.plugins = defaultPlugins; return true; } - if (mkdocsYml.plugins && !mkdocsYml.plugins.includes('techdocs-core')) { - mkdocsYml.plugins.push('techdocs-core'); + if ( + mkdocsYml.plugins && + !defaultPlugins.every(plugin => mkdocsYml.plugins!.includes(plugin)) + ) { + mkdocsYml.plugins = [ + ...new Set([...mkdocsYml.plugins, ...defaultPlugins]), + ]; return true; } return false; diff --git a/plugins/techdocs-node/src/stages/generate/techdocs.test.ts b/plugins/techdocs-node/src/stages/generate/techdocs.test.ts index 11c9ee0135..82bfcfff55 100644 --- a/plugins/techdocs-node/src/stages/generate/techdocs.test.ts +++ b/plugins/techdocs-node/src/stages/generate/techdocs.test.ts @@ -157,4 +157,24 @@ describe('readGeneratorConfig', () => { legacyCopyReadmeMdToIndexMd: true, }); }); + + it('should read the default plugins config', () => { + const config = new ConfigReader({ + techdocs: { + generator: { + runIn: 'docker', + dockerImage: 'my-org/techdocs', + pullImage: false, + mkdocs: { defaultPlugins: ['mkdocs-custom-plugin'] }, + }, + }, + }); + + expect(readGeneratorConfig(config, logger)).toEqual({ + runIn: 'docker', + dockerImage: 'my-org/techdocs', + pullImage: false, + defaultPlugins: ['mkdocs-custom-plugin'], + }); + }); }); diff --git a/plugins/techdocs-node/src/stages/generate/techdocs.ts b/plugins/techdocs-node/src/stages/generate/techdocs.ts index 06160b85c9..b1baf214b3 100644 --- a/plugins/techdocs-node/src/stages/generate/techdocs.ts +++ b/plugins/techdocs-node/src/stages/generate/techdocs.ts @@ -33,7 +33,7 @@ import { import { patchMkdocsYmlPreBuild, - pathMkdocsYmlWithTechdocsPlugin, + patchMkdocsYmlWithPlugins, } from './mkdocsPatchers'; import { GeneratorBase, @@ -121,10 +121,18 @@ export class TechdocsGenerator implements GeneratorBase { await patchIndexPreBuild({ inputDir, logger: childLogger, docsDir }); } - if (!this.options.omitTechdocsCoreMkdocsPlugin) { - await pathMkdocsYmlWithTechdocsPlugin(mkdocsYmlPath, childLogger); + // patch the list of mkdocs plugins + const defaultPlugins = this.options.defaultPlugins ?? []; + + if ( + !this.options.omitTechdocsCoreMkdocsPlugin && + !defaultPlugins.includes('techdocs-core') + ) { + defaultPlugins.push('techdocs-core'); } + await patchMkdocsYmlWithPlugins(mkdocsYmlPath, childLogger, defaultPlugins); + // Directories to bind on container const mountDirs = { [inputDir]: '/input', @@ -233,5 +241,8 @@ export function readGeneratorConfig( legacyCopyReadmeMdToIndexMd: config.getOptionalBoolean( 'techdocs.generator.mkdocs.legacyCopyReadmeMdToIndexMd', ), + defaultPlugins: config.getOptionalStringArray( + 'techdocs.generator.mkdocs.defaultPlugins', + ), }; } diff --git a/plugins/techdocs-node/src/stages/generate/types.ts b/plugins/techdocs-node/src/stages/generate/types.ts index 70c332344a..eca1057914 100644 --- a/plugins/techdocs-node/src/stages/generate/types.ts +++ b/plugins/techdocs-node/src/stages/generate/types.ts @@ -41,6 +41,7 @@ export type GeneratorConfig = { pullImage?: boolean; omitTechdocsCoreMkdocsPlugin?: boolean; legacyCopyReadmeMdToIndexMd?: boolean; + defaultPlugins?: string[]; }; /** diff --git a/yarn.lock b/yarn.lock index 40bb6fcaa8..8e3a735523 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3197,12 +3197,12 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": - version: 7.21.0 - resolution: "@babel/runtime@npm:7.21.0" +"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.1.2, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.6, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.2.0, @babel/runtime@npm:^7.20.1, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.10, @babel/runtime@npm:^7.3.1, @babel/runtime@npm:^7.4.4, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.6.0, @babel/runtime@npm:^7.7.6, @babel/runtime@npm:^7.8.3, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.8.7, @babel/runtime@npm:^7.9.2": + version: 7.22.11 + resolution: "@babel/runtime@npm:7.22.11" dependencies: - regenerator-runtime: ^0.13.11 - checksum: 7b33e25bfa9e0e1b9e8828bb61b2d32bdd46b41b07ba7cb43319ad08efc6fda8eb89445193e67d6541814627df0ca59122c0ea795e412b99c5183a0540d338ab + regenerator-runtime: ^0.14.0 + checksum: a5cd6683a8fcdb8065cb1677f221e22f6c67ec8f15ad1d273b180b93ab3bd86c66da2c48f500d4e72d8d2cfa85ff4872a3f350e5aa3855630036af5da765c001 languageName: node linkType: hard @@ -4153,8 +4153,11 @@ __metadata: resolution: "@backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api" dependencies: "@backstage/cli": "workspace:^" + "@backstage/types": "workspace:^" "@testing-library/jest-dom": ^5.10.1 lodash: ^4.17.21 + zod: ^3.21.4 + zod-to-json-schema: ^3.21.4 peerDependencies: react: ^16.13.1 || ^17.0.0 languageName: unknown @@ -7918,6 +7921,7 @@ __metadata: "@testing-library/dom": ^8.0.0 "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 + "@testing-library/react-hooks": ^8.0.1 "@testing-library/user-event": ^14.0.0 "@types/node": ^16.11.26 "@types/react": ^16.13.1 || ^17.0.0 @@ -10427,16 +10431,16 @@ __metadata: languageName: node linkType: hard -"@emotion/cache@npm:^11.10.5, @emotion/cache@npm:^11.10.7": - version: 11.10.7 - resolution: "@emotion/cache@npm:11.10.7" +"@emotion/cache@npm:^11.10.5, @emotion/cache@npm:^11.11.0": + version: 11.11.0 + resolution: "@emotion/cache@npm:11.11.0" dependencies: - "@emotion/memoize": ^0.8.0 - "@emotion/sheet": ^1.2.1 - "@emotion/utils": ^1.2.0 - "@emotion/weak-memoize": ^0.3.0 - stylis: 4.1.3 - checksum: 6b1efed2dffc93dac419409d91f6d57a200d858ec5ffa4b7c30080fdbd93db431ff86bb779c5b8830b8373f3c5dd754d9beb386604ed2667c7d55608ff653dfc + "@emotion/memoize": ^0.8.1 + "@emotion/sheet": ^1.2.2 + "@emotion/utils": ^1.2.1 + "@emotion/weak-memoize": ^0.3.1 + stylis: 4.2.0 + checksum: 8eb1dc22beaa20c21a2e04c284d5a2630a018a9d51fb190e52de348c8d27f4e8ca4bbab003d68b4f6cd9cc1c569ca747a997797e0f76d6c734a660dc29decf08 languageName: node linkType: hard @@ -10454,19 +10458,19 @@ __metadata: languageName: node linkType: hard -"@emotion/is-prop-valid@npm:^1.1.0, @emotion/is-prop-valid@npm:^1.2.0": - version: 1.2.0 - resolution: "@emotion/is-prop-valid@npm:1.2.0" +"@emotion/is-prop-valid@npm:^1.1.0, @emotion/is-prop-valid@npm:^1.2.0, @emotion/is-prop-valid@npm:^1.2.1": + version: 1.2.1 + resolution: "@emotion/is-prop-valid@npm:1.2.1" dependencies: - "@emotion/memoize": ^0.8.0 - checksum: cc7a19850a4c5b24f1514665289442c8c641709e6f7711067ad550e05df331da0692a16148e85eda6f47e31b3261b64d74c5e25194d053223be16231f969d633 + "@emotion/memoize": ^0.8.1 + checksum: 8f42dc573a3fad79b021479becb639b8fe3b60bdd1081a775d32388bca418ee53074c7602a4c845c5f75fa6831eb1cbdc4d208cc0299f57014ed3a02abcad16a languageName: node linkType: hard -"@emotion/memoize@npm:^0.8.0": - version: 0.8.0 - resolution: "@emotion/memoize@npm:0.8.0" - checksum: c87bb110b829edd8e1c13b90a6bc37cebc39af29c7599a1e66a48e06f9bec43e8e53495ba86278cc52e7589549492c8dfdc81d19f4fdec0cee6ba13d2ad2c928 +"@emotion/memoize@npm:^0.8.0, @emotion/memoize@npm:^0.8.1": + version: 0.8.1 + resolution: "@emotion/memoize@npm:0.8.1" + checksum: a19cc01a29fcc97514948eaab4dc34d8272e934466ed87c07f157887406bc318000c69ae6f813a9001c6a225364df04249842a50e692ef7a9873335fbcc141b0 languageName: node linkType: hard @@ -10507,10 +10511,10 @@ __metadata: languageName: node linkType: hard -"@emotion/sheet@npm:^1.2.1": - version: 1.2.1 - resolution: "@emotion/sheet@npm:1.2.1" - checksum: ce78763588ea522438156344d9f592203e2da582d8d67b32e1b0b98eaba26994c6c270f8c7ad46442fc9c0a9f048685d819cd73ca87e544520fd06f0e24a1562 +"@emotion/sheet@npm:^1.2.2": + version: 1.2.2 + resolution: "@emotion/sheet@npm:1.2.2" + checksum: d973273c9c15f1c291ca2269728bf044bd3e92a67bca87943fa9ec6c3cd2b034f9a6bfe95ef1b5d983351d128c75b547b43ff196a00a3875f7e1d269793cecfe languageName: node linkType: hard @@ -10567,17 +10571,17 @@ __metadata: languageName: node linkType: hard -"@emotion/utils@npm:^1.2.0": - version: 1.2.0 - resolution: "@emotion/utils@npm:1.2.0" - checksum: 55457a49ddd4db6a014ea0454dc09eaa23eedfb837095c8ff90470cb26a303f7ceb5fcc1e2190ef64683e64cfd33d3ba3ca3109cd87d12bc9e379e4195c9a4dd +"@emotion/utils@npm:^1.2.0, @emotion/utils@npm:^1.2.1": + version: 1.2.1 + resolution: "@emotion/utils@npm:1.2.1" + checksum: e0b44be0705b56b079c55faff93952150be69e79b660ae70ddd5b6e09fc40eb1319654315a9f34bb479d7f4ec94be6068c061abbb9e18b9778ae180ad5d97c73 languageName: node linkType: hard -"@emotion/weak-memoize@npm:^0.3.0": - version: 0.3.0 - resolution: "@emotion/weak-memoize@npm:0.3.0" - checksum: f43ef4c8b7de70d9fa5eb3105921724651e4188e895beb71f0c5919dc899a7b8743e1fdd99d38b9092dd5722c7be2312ebb47fbdad0c4e38bea58f6df5885cc0 +"@emotion/weak-memoize@npm:^0.3.0, @emotion/weak-memoize@npm:^0.3.1": + version: 0.3.1 + resolution: "@emotion/weak-memoize@npm:0.3.1" + checksum: b2be47caa24a8122622ea18cd2d650dbb4f8ad37b636dc41ed420c2e082f7f1e564ecdea68122b546df7f305b159bf5ab9ffee872abd0f052e687428459af594 languageName: node linkType: hard @@ -11015,6 +11019,44 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.4.1": + version: 1.4.1 + resolution: "@floating-ui/core@npm:1.4.1" + dependencies: + "@floating-ui/utils": ^0.1.1 + checksum: be4ab864fe17eeba5e205bd554c264b9a4895a57c573661bbf638357fa3108677fed7ba3269ec15b4da90e29274c9b626d5a15414e8d1fe691e210d02a03695c + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.3.0": + version: 1.5.1 + resolution: "@floating-ui/dom@npm:1.5.1" + dependencies: + "@floating-ui/core": ^1.4.1 + "@floating-ui/utils": ^0.1.1 + checksum: ddb509030978536ba7b321cf8c764ae9d0142a3b1fefb7e6bc050a5de7e825e12131fa5089009edabf7c125fb274886da211a5220fe17a71d875a7a96eb1386c + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.0.1": + version: 2.0.1 + resolution: "@floating-ui/react-dom@npm:2.0.1" + dependencies: + "@floating-ui/dom": ^1.3.0 + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 00fef2cf69ac2b15952e47505fd9e23f6cc5c20a26adc707862932826d1682f3c30f83c9887abfc93574fdca2d34dd2fc00271527318b1db403549cd6bc9eb00 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.1.1": + version: 0.1.1 + resolution: "@floating-ui/utils@npm:0.1.1" + checksum: 548acdda7902f45b0afbe34e2e7f4cbff0696b95bad8c039f80936519de24ef2ec20e79902825b7815294b37f51a7c52ee86288b0688869a57cc229a164d86b4 + languageName: node + linkType: hard + "@fmvilas/pseudo-yaml-ast@npm:^0.3.1": version: 0.3.1 resolution: "@fmvilas/pseudo-yaml-ast@npm:0.3.1" @@ -13167,16 +13209,17 @@ __metadata: languageName: node linkType: hard -"@mui/base@npm:5.0.0-alpha.127": - version: 5.0.0-alpha.127 - resolution: "@mui/base@npm:5.0.0-alpha.127" +"@mui/base@npm:5.0.0-beta.12": + version: 5.0.0-beta.12 + resolution: "@mui/base@npm:5.0.0-beta.12" dependencies: - "@babel/runtime": ^7.21.0 - "@emotion/is-prop-valid": ^1.2.0 + "@babel/runtime": ^7.22.10 + "@emotion/is-prop-valid": ^1.2.1 + "@floating-ui/react-dom": ^2.0.1 "@mui/types": ^7.2.4 - "@mui/utils": ^5.12.0 - "@popperjs/core": ^2.11.7 - clsx: ^1.2.1 + "@mui/utils": ^5.14.6 + "@popperjs/core": ^2.11.8 + clsx: ^2.0.0 prop-types: ^15.8.1 react-is: ^18.2.0 peerDependencies: @@ -13186,29 +13229,29 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 330baeef1fdaa513707f295e07a3436af24d0831a18109ed877f76b86cf1f392309833d33a2c7ca250db79fdf53f5e88c2618f876f50dbbbf9323bbf08e367e7 + checksum: 3ed9b43323595588ccea7325a79bbaab60bf5a99a970821555c449f53ca4c8f7f298a4d5c8a280c5ea0b2b47273a0b1a03e33ef6cbbbd428b14bcc33222b5f75 languageName: node linkType: hard -"@mui/core-downloads-tracker@npm:^5.12.2": - version: 5.12.2 - resolution: "@mui/core-downloads-tracker@npm:5.12.2" - checksum: d748bdc56df6fdfe6712550a94263cf094c53ddcb70f6a8f633caf23927edf5ac88b1f888429d895fe2a5045b27eb7aa9826ccee5b917e31973667d604ed87fe +"@mui/core-downloads-tracker@npm:^5.14.6": + version: 5.14.6 + resolution: "@mui/core-downloads-tracker@npm:5.14.6" + checksum: 500b6a0e709f75fa8798caf4ad993d1024c046f25f0624e7d36c454ca53aebf119eb8645bb6cf762a2e27520deb843e546c95874e32183f1255044fe73184c79 languageName: node linkType: hard "@mui/material@npm:^5.12.2": - version: 5.12.2 - resolution: "@mui/material@npm:5.12.2" + version: 5.14.6 + resolution: "@mui/material@npm:5.14.6" dependencies: - "@babel/runtime": ^7.21.0 - "@mui/base": 5.0.0-alpha.127 - "@mui/core-downloads-tracker": ^5.12.2 - "@mui/system": ^5.12.1 + "@babel/runtime": ^7.22.10 + "@mui/base": 5.0.0-beta.12 + "@mui/core-downloads-tracker": ^5.14.6 + "@mui/system": ^5.14.6 "@mui/types": ^7.2.4 - "@mui/utils": ^5.12.0 - "@types/react-transition-group": ^4.4.5 - clsx: ^1.2.1 + "@mui/utils": ^5.14.6 + "@types/react-transition-group": ^4.4.6 + clsx: ^2.0.0 csstype: ^3.1.2 prop-types: ^15.8.1 react-is: ^18.2.0 @@ -13226,16 +13269,16 @@ __metadata: optional: true "@types/react": optional: true - checksum: a3464dfbcc496164967e134d6f1d40e060ee02fc2ddb5fcf648ca85580efdbd5eff5aab6a73486d0a7f329f7299127b52501a13993bb131cbd4e310c7f2e633f + checksum: e22977cb010b54ba43e7b13105c7dbe3e5361f960fd4eecc3ed28480a8abc531b44abdb093bafe9ce291c6a23c25039e0677d5d965210d7fc9ac5e25bb68a876 languageName: node linkType: hard -"@mui/private-theming@npm:^5.12.0": - version: 5.12.0 - resolution: "@mui/private-theming@npm:5.12.0" +"@mui/private-theming@npm:^5.14.6": + version: 5.14.6 + resolution: "@mui/private-theming@npm:5.14.6" dependencies: - "@babel/runtime": ^7.21.0 - "@mui/utils": ^5.12.0 + "@babel/runtime": ^7.22.10 + "@mui/utils": ^5.14.6 prop-types: ^15.8.1 peerDependencies: "@types/react": ^17.0.0 || ^18.0.0 @@ -13243,16 +13286,16 @@ __metadata: peerDependenciesMeta: "@types/react": optional: true - checksum: 761bc7a57e1643c2c4c327886882fa5efc7bacae1c6fffe6be4197f49d337261c916a883d96996445efcdedc0672251725c1e5b264b6250d6c9527fd0cafcc62 + checksum: 2d477657c5c10bbc2628b741c85d5bc66f36f379eaafc97f534500584e4ab2f098e8cf29eec0f079b9a34a414027235ab433f3f41771ea4b9cacdcd783111726 languageName: node linkType: hard -"@mui/styled-engine@npm:^5.12.0": - version: 5.12.0 - resolution: "@mui/styled-engine@npm:5.12.0" +"@mui/styled-engine@npm:^5.14.6": + version: 5.14.6 + resolution: "@mui/styled-engine@npm:5.14.6" dependencies: - "@babel/runtime": ^7.21.0 - "@emotion/cache": ^11.10.7 + "@babel/runtime": ^7.22.10 + "@emotion/cache": ^11.11.0 csstype: ^3.1.2 prop-types: ^15.8.1 peerDependencies: @@ -13264,20 +13307,20 @@ __metadata: optional: true "@emotion/styled": optional: true - checksum: 4a415473cf62aa05012f667dd2e9b1dc2fb175be5b0c4d0b8df541e2dac3d7db410e920e0d5910c8e2b8996a4fb51f74d79483e2878a9b5c0d334498f5537d74 + checksum: 7bbef4574195d70945041a645f5d0e372762cad28d873794e750bbd570daf901392205d69c3593ad55c43088361e1b90c7b9798a5d013c4a1783553d42c758dd languageName: node linkType: hard -"@mui/system@npm:^5.12.1": - version: 5.12.1 - resolution: "@mui/system@npm:5.12.1" +"@mui/system@npm:^5.14.6": + version: 5.14.6 + resolution: "@mui/system@npm:5.14.6" dependencies: - "@babel/runtime": ^7.21.0 - "@mui/private-theming": ^5.12.0 - "@mui/styled-engine": ^5.12.0 + "@babel/runtime": ^7.22.10 + "@mui/private-theming": ^5.14.6 + "@mui/styled-engine": ^5.14.6 "@mui/types": ^7.2.4 - "@mui/utils": ^5.12.0 - clsx: ^1.2.1 + "@mui/utils": ^5.14.6 + clsx: ^2.0.0 csstype: ^3.1.2 prop-types: ^15.8.1 peerDependencies: @@ -13292,7 +13335,7 @@ __metadata: optional: true "@types/react": optional: true - checksum: b951959bf5e5af581319354c044f441d97849ff120cfec111d2ed5e7fa05efd63721acff96f48093b8e2bdeb5ccbe35c48613fb925be2b58c596447d10dbed3e + checksum: 5090761f86b858df6109c57c7c1d6912462781b1280d43d672b64e095564eefe4f64f5bf8ebf5ad9cefdc27efba8964e59bc2c234005ca33da4831aff4fed8db languageName: node linkType: hard @@ -13308,18 +13351,18 @@ __metadata: languageName: node linkType: hard -"@mui/utils@npm:^5.12.0": - version: 5.12.0 - resolution: "@mui/utils@npm:5.12.0" +"@mui/utils@npm:^5.14.6": + version: 5.14.6 + resolution: "@mui/utils@npm:5.14.6" dependencies: - "@babel/runtime": ^7.21.0 + "@babel/runtime": ^7.22.10 "@types/prop-types": ^15.7.5 - "@types/react-is": ^16.7.1 || ^17.0.0 + "@types/react-is": ^18.2.1 prop-types: ^15.8.1 react-is: ^18.2.0 peerDependencies: react: ^17.0.0 || ^18.0.0 - checksum: 87b2c7468803b083f50af28d7c215c45291e73fef16570848b596d0f1cde1fc613c20e8951f431217b31451de254744abd50eda5013dedec4982420b5bf1c6b6 + checksum: c5d2f37dec7984b9cf4da10a2fd9eabe23e7481ad0f3674cc9cdc2b7a3938eca28e92068f6d58b42cc807192dbb5e40d4885f8d896a4a01bab32a05c5ac3920b languageName: node linkType: hard @@ -14288,10 +14331,10 @@ __metadata: languageName: node linkType: hard -"@popperjs/core@npm:^2.11.7": - version: 2.11.7 - resolution: "@popperjs/core@npm:2.11.7" - checksum: 5b6553747899683452a1d28898c1b39173a4efd780e74360bfcda8eb42f1c5e819602769c81a10920fc68c881d07fb40429604517d499567eac079cfa6470f19 +"@popperjs/core@npm:^2.11.8": + version: 2.11.8 + resolution: "@popperjs/core@npm:2.11.8" + checksum: e5c69fdebf52a4012f6a1f14817ca8e9599cb1be73dd1387e1785e2ed5e5f0862ff817f420a87c7fc532add1f88a12e25aeb010ffcbdc98eace3d55ce2139cf0 languageName: node linkType: hard @@ -16834,11 +16877,11 @@ __metadata: linkType: hard "@types/codemirror@npm:^5.0.0": - version: 5.60.8 - resolution: "@types/codemirror@npm:5.60.8" + version: 5.60.9 + resolution: "@types/codemirror@npm:5.60.9" dependencies: "@types/tern": "*" - checksum: bc3a63eab0308b3ef5ed2ca22afb7f1c0b8acde6477e1d569df7e93f3fe4f78754d28a3e081c72931259fe1bf906a319b54bcdeba74600b989e5558d32478496 + checksum: 89da80a8d5c904f573289769098ad7721a4b58e056c1d08168b967bcfb63e30433d9d0bee0c43ff4b6679b0b7b87cfe942cc3f250ca0365ee244c9fb68ac9a70 languageName: node linkType: hard @@ -17007,9 +17050,9 @@ __metadata: linkType: hard "@types/d3-force@npm:^3.0.0": - version: 3.0.4 - resolution: "@types/d3-force@npm:3.0.4" - checksum: 779fb597fb41e7bc6a5e1b8969d500deb95c4a73428c7c268bf0ca6f3ed668dd2ed6aa652de7af14d2f9c192dad4f6e7badf2c5bc330624bd8405ac88440b278 + version: 3.0.5 + resolution: "@types/d3-force@npm:3.0.5" + checksum: 09aa0e999841f69b34296fa92273b5b251c50f74c1b7ec2bdef7a3a7fd2d756467e4ede7cc0dcfc4151c04bde0f3feaa48d9b669c38620fb51cee2d7faa01e24 languageName: node linkType: hard @@ -17046,9 +17089,9 @@ __metadata: linkType: hard "@types/d3-selection@npm:*, @types/d3-selection@npm:^3.0.1": - version: 3.0.5 - resolution: "@types/d3-selection@npm:3.0.5" - checksum: 77ea35c92273cd2d736355ab5a4cf6477086b064a1f608489a6fef4198ee6bc1d5440e5dfd880099265ac96c4f4b4fda659d89fbaaef37ba03708663196ba8ad + version: 3.0.6 + resolution: "@types/d3-selection@npm:3.0.6" + checksum: 01f9f3a41b98280947109911bcc6ebffff5c8c3e617695979c04ef9beef445a8cdc48a9fbdb3c9cff45a7345e83e8a5d6b346d7ceedca6ebc11dfd9717dbff6b languageName: node linkType: hard @@ -17062,11 +17105,11 @@ __metadata: linkType: hard "@types/d3-shape@npm:^3.0.1, @types/d3-shape@npm:^3.1.0": - version: 3.1.1 - resolution: "@types/d3-shape@npm:3.1.1" + version: 3.1.2 + resolution: "@types/d3-shape@npm:3.1.2" dependencies: "@types/d3-path": "*" - checksum: 8f1762ecdeb4833a3802be1c65363cbc7cca753d0b836a3855fde4ba12f8e6fc142dba3c5f6d669a9e89374cc6dc414464e4f2d04e72fafd4bc64819ce30bb63 + checksum: 17399a78872aa89cab600cb7977405136a72798dbbb59a4b3684012c2a2ffb9b62720cceaaedc412088b7da086518a6722c909e03cb81e3e238569722feb60f9 languageName: node linkType: hard @@ -17085,12 +17128,12 @@ __metadata: linkType: hard "@types/d3-zoom@npm:^3.0.1": - version: 3.0.3 - resolution: "@types/d3-zoom@npm:3.0.3" + version: 3.0.4 + resolution: "@types/d3-zoom@npm:3.0.4" dependencies: "@types/d3-interpolate": "*" "@types/d3-selection": "*" - checksum: 1c828538e2b04c47f659ead552824bed1c545dd1934f38abfec95f4a3c1cb4eaf7e107959ebd05a3dfec0166fa98677037ede6f633383c6502b1a05eaeb1d418 + checksum: 6330b8411822b979ab0fd5a7a7755295377b4dfc61fd8f925d97e5b388e3dd604b455c0009b0eda3320c14c4ea367f740a04991a71d35befdca5f75218492a68 languageName: node linkType: hard @@ -17204,14 +17247,14 @@ __metadata: linkType: hard "@types/express-serve-static-core@npm:*, @types/express-serve-static-core@npm:^4.17.30, @types/express-serve-static-core@npm:^4.17.33, @types/express-serve-static-core@npm:^4.17.5": - version: 4.17.35 - resolution: "@types/express-serve-static-core@npm:4.17.35" + version: 4.17.36 + resolution: "@types/express-serve-static-core@npm:4.17.36" dependencies: "@types/node": "*" "@types/qs": "*" "@types/range-parser": "*" "@types/send": "*" - checksum: cc8995d10c6feda475ec1b3a0e69eb0f35f21ab6b49129ad5c6f279e0bc5de8175bc04ec51304cb79a43eec3ed2f5a1e01472eb6d5f827b8c35c6ca8ad24eb6e + checksum: 410b13cbd663f18c0f8729e7f2ff54d960d96de76ebbae7cadb612972f85cc66c54051e00d32f11aa230c0a683d81a6d6fc7f7e4e383a95c0801494c517f36e1 languageName: node linkType: hard @@ -17461,11 +17504,11 @@ __metadata: linkType: hard "@types/jquery@npm:^3.3.34": - version: 3.5.16 - resolution: "@types/jquery@npm:3.5.16" + version: 3.5.17 + resolution: "@types/jquery@npm:3.5.17" dependencies: "@types/sizzle": "*" - checksum: 13c995f15d1c2f1d322103dc1cb0a22b95eecc3e7546f00279b8731aea21d7ec04550af40e609ee48e755d4e11bf61c25b4aa9f53df3bcbec4b8fe8e81471732 + checksum: be289003382a8d4a1c6eab9c68845e919e467926557493033e78f6793ed8acd482b01b7b47955e4bf51f65f9b05607beccb32d31d649a2b42f3b07f1444d1f1a languageName: node linkType: hard @@ -17716,11 +17759,11 @@ __metadata: linkType: hard "@types/morgan@npm:^1.9.0": - version: 1.9.4 - resolution: "@types/morgan@npm:1.9.4" + version: 1.9.5 + resolution: "@types/morgan@npm:1.9.5" dependencies: "@types/node": "*" - checksum: d1e99c66a43501dcdf6e94e013dfff4e6c152cbb5f782d954bb722d230a9c1c0fe06fab6df3f3dfa3547735a7598f9471633cc6813d794e9562fd022e217c6ae + checksum: f98deb4c7f2ad6049ad34ed7b0f0d427546bdf2358011070af9d597de1b0a03b38cc10cfe65ef2e7673e569c384303d949e76df701acefe288d547f614142973 languageName: node linkType: hard @@ -17770,9 +17813,9 @@ __metadata: linkType: hard "@types/node@npm:*, @types/node@npm:>=12.12.47, @types/node@npm:>=13.7.0": - version: 20.5.0 - resolution: "@types/node@npm:20.5.0" - checksum: 659bc5fc93b5c02bd88ca4bfae4f6b9dc307d45884d1dd9d69df85819a9943cdc00cd3c87eec3048866df6a67f52297f74d170e47a44f61edb3e8f770d94e85e + version: 20.5.4 + resolution: "@types/node@npm:20.5.4" + checksum: 18de76c990e1c298183661cfc7cf16c9384531538c8090cc27bc3cab801e7c46b9f6943591ab0433955dd57961601684cc0fb19ba2deee9609e137c91afd2e25 languageName: node linkType: hard @@ -17784,9 +17827,9 @@ __metadata: linkType: hard "@types/node@npm:^14.14.31": - version: 14.18.54 - resolution: "@types/node@npm:14.18.54" - checksum: 9fd66f91fcd8e9b25067f784a9c60bd710ef86a89c838c131ab2b1921398adc53b1c70d741bceed48bb2403b75c434b1bbbb255240773819cde36295c4b6abf1 + version: 14.18.56 + resolution: "@types/node@npm:14.18.56" + checksum: 990cd0e853caa264586aa703ed923767fb445ad240e54cfe7c877c8db2e0b377f85c1bc4b80575c7f07dc9f4c56bdfbc6b5c2704a689cf7c8f9ac4ec2ed84975 languageName: node linkType: hard @@ -17798,16 +17841,16 @@ __metadata: linkType: hard "@types/node@npm:^16.0.0, @types/node@npm:^16.11.26, @types/node@npm:^16.9.2": - version: 16.18.41 - resolution: "@types/node@npm:16.18.41" - checksum: b12650d8e4289edafcf0453c8a66c00d6397d465a48b1c683babba4f16f92a6418f678e98a85751e3fef78d23c2b07f641df8da14bf5428ad8282b57b2695243 + version: 16.18.44 + resolution: "@types/node@npm:16.18.44" + checksum: 5600765a303e2ab8913e8dbc6871f2740678bf6420f0b009a0ad059d4cbf7cd93a9679dc304c9d382bee57fa9cf802ea8b8b70839d11903e98bd9e09c98a7e32 languageName: node linkType: hard "@types/node@npm:^18.11.17": - version: 18.17.5 - resolution: "@types/node@npm:18.17.5" - checksum: b8c658a99234b99425243c324b641ed7b9ceb6bee6b06421fdc9bb7c58f9a5552e353225cc549e6982462ac384abe1985022ed76e2e4728797f59b21f659ca2b + version: 18.17.9 + resolution: "@types/node@npm:18.17.9" + checksum: 0d5835710e49a654a1ca34167e7cd504cc709c006006ebe9b23c360dac5637c6c4f1b4f3aff33df1b7f9debfb9ad8bd81da2fc4540970d2ad56eb479cda77613 languageName: node linkType: hard @@ -18034,12 +18077,12 @@ __metadata: languageName: node linkType: hard -"@types/react-is@npm:^16.7.1 || ^17.0.0": - version: 17.0.3 - resolution: "@types/react-is@npm:17.0.3" +"@types/react-is@npm:^18.2.1": + version: 18.2.1 + resolution: "@types/react-is@npm:18.2.1" dependencies: "@types/react": "*" - checksum: 6abb7c47d54f012272650df8a962a28bd82f219291e5ef8b4dfa7fe0bb98ae243b060bf9fbe8ceff6213141794855a006db194b490b00ffd15842ae19d0ce1f0 + checksum: b44c3262efa2c68fa6fe2beb9ef86170b18305469461a3f97aa14943cc033cb21a26944f718bdb6434eea6e8f7fcba251c4f45b65b897a3fcf751b5a6003cf82 languageName: node linkType: hard @@ -18091,12 +18134,12 @@ __metadata: languageName: node linkType: hard -"@types/react-transition-group@npm:^4.2.0, @types/react-transition-group@npm:^4.4.5": - version: 4.4.5 - resolution: "@types/react-transition-group@npm:4.4.5" +"@types/react-transition-group@npm:^4.2.0, @types/react-transition-group@npm:^4.4.6": + version: 4.4.6 + resolution: "@types/react-transition-group@npm:4.4.6" dependencies: "@types/react": "*" - checksum: 265f1c74061556708ffe8d15559e35c60d6c11478c9950d3735575d2c116ca69f461d85effa06d73a613eb8b73c84fd32682feb57cf7c5f9e4284021dbca25b0 + checksum: 0872143821d7ee20a1d81e965f8b1e837837f11cd2206973f1f98655751992d9390304d58bac192c9cd923eca95bff107d8c9e3364a180240d5c2a6fd70fd7c3 languageName: node linkType: hard @@ -18119,13 +18162,13 @@ __metadata: linkType: hard "@types/react@npm:^17": - version: 17.0.64 - resolution: "@types/react@npm:17.0.64" + version: 17.0.65 + resolution: "@types/react@npm:17.0.65" dependencies: "@types/prop-types": "*" "@types/scheduler": "*" csstype: ^3.0.2 - checksum: 71a7b402e8849ff31ed542d1d1a2ff6adb0e6f0e17321d126e7382a2fcb671ec1e1d1bf7045306922fa90b0319c57a8a45e6215bbf00fb6f73c3860d3f869870 + checksum: bb862f0f97a49e690cd5c62743307d17398ee62b87bbb2c85c1b01cb992244c0aef4ecdedaab0e8d33c6315d5a8f067cf90e7f1c366b8819678c59204e202932 languageName: node linkType: hard @@ -21660,13 +21703,20 @@ __metadata: languageName: node linkType: hard -"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.1, clsx@npm:^1.2.1": +"clsx@npm:^1.0.2, clsx@npm:^1.0.4, clsx@npm:^1.1.1": version: 1.2.1 resolution: "clsx@npm:1.2.1" checksum: 30befca8019b2eb7dbad38cff6266cf543091dae2825c856a62a8ccf2c3ab9c2907c4d12b288b73101196767f66812365400a227581484a05f968b0307cfaf12 languageName: node linkType: hard +"clsx@npm:^2.0.0": + version: 2.0.0 + resolution: "clsx@npm:2.0.0" + checksum: a2cfb2351b254611acf92faa0daf15220f4cd648bdf96ce369d729813b85336993871a4bf6978ddea2b81b5a130478339c20d9d0b5c6fc287e5147f0c059276e + languageName: node + linkType: hard + "cluster-key-slot@npm:^1.1.0": version: 1.1.0 resolution: "cluster-key-slot@npm:1.1.0" @@ -22158,8 +22208,8 @@ __metadata: linkType: hard "concurrently@npm:^8.0.0": - version: 8.2.0 - resolution: "concurrently@npm:8.2.0" + version: 8.2.1 + resolution: "concurrently@npm:8.2.1" dependencies: chalk: ^4.1.2 date-fns: ^2.30.0 @@ -22173,7 +22223,7 @@ __metadata: bin: conc: dist/bin/concurrently.js concurrently: dist/bin/concurrently.js - checksum: eafe6a4d9b7fda87f55ea285cfc6acd937a5286ceec8991ab48e6cc27c45fce6a5c6f45e18d7555defa15dc7d7e8941bc5a9d1ceaf182e31441d420e00333434 + checksum: 216cb16d5b301cbd9c657b19430836d1686fe8fa9b9ef35ef7ac601e1a5cf6535166a3e57de446696dbd5e7e3f45d78fc70f33c5fd4bb565342cd5e752c5b069 languageName: node linkType: hard @@ -37744,13 +37794,6 @@ __metadata: languageName: node linkType: hard -"regenerator-runtime@npm:^0.13.11": - version: 0.13.11 - resolution: "regenerator-runtime@npm:0.13.11" - checksum: 27481628d22a1c4e3ff551096a683b424242a216fee44685467307f14d58020af1e19660bf2e26064de946bad7eff28950eae9f8209d55723e2d9351e632bbb4 - languageName: node - linkType: hard - "regenerator-runtime@npm:^0.14.0": version: 0.14.0 resolution: "regenerator-runtime@npm:0.14.0" @@ -40233,13 +40276,20 @@ __metadata: languageName: node linkType: hard -"stylis@npm:4.1.3, stylis@npm:^4.0.6": +"stylis@npm:4.1.3": version: 4.1.3 resolution: "stylis@npm:4.1.3" checksum: d04dbffcb9bf2c5ca8d8dc09534203c75df3bf711d33973ea22038a99cc475412a350b661ebd99cbc01daa50d7eedcf0d130d121800eb7318759a197023442a6 languageName: node linkType: hard +"stylis@npm:4.2.0, stylis@npm:^4.0.6": + version: 4.2.0 + resolution: "stylis@npm:4.2.0" + checksum: 0eb6cc1b866dc17a6037d0a82ac7fa877eba6a757443e79e7c4f35bacedbf6421fadcab4363b39667b43355cbaaa570a3cde850f776498e5450f32ed2f9b7584 + languageName: node + linkType: hard + "subscriptions-transport-ws@npm:^0.11.0": version: 0.11.0 resolution: "subscriptions-transport-ws@npm:0.11.0"