diff --git a/.changeset/backend-process-listener-cleanup.md b/.changeset/backend-process-listener-cleanup.md new file mode 100644 index 0000000000..eb4dc52937 --- /dev/null +++ b/.changeset/backend-process-listener-cleanup.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-app-api': patch +--- + +Fixed memory leak by properly cleaning up process event listeners on backend shutdown. diff --git a/.changeset/beige-crabs-share.md b/.changeset/beige-crabs-share.md new file mode 100644 index 0000000000..6a1a42ffd8 --- /dev/null +++ b/.changeset/beige-crabs-share.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': minor +--- + +**BREAKING**: Removed the `TestApiRegistry` class, use `TestApiProvider` directly instead, storing reused APIs in a variable, e.g. `const apis = [...] as const`. diff --git a/.changeset/better-eggs-slide.md b/.changeset/better-eggs-slide.md new file mode 100644 index 0000000000..cf3856bb86 --- /dev/null +++ b/.changeset/better-eggs-slide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Allow setting optional description on group creation diff --git a/.changeset/catalog-react-catalog-api-mock-shorthand.md b/.changeset/catalog-react-catalog-api-mock-shorthand.md new file mode 100644 index 0000000000..0761dffa0e --- /dev/null +++ b/.changeset/catalog-react-catalog-api-mock-shorthand.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +The `catalogApiMock` test utility now returns a `MockWithApiFactory`, allowing it to be passed directly to test utilities like `renderTestApp` and `TestApiProvider` without needing the `[catalogApiRef, catalogApiMock()]` tuple. diff --git a/.changeset/css-exports-support.md b/.changeset/css-exports-support.md new file mode 100644 index 0000000000..17c1a0d8b7 --- /dev/null +++ b/.changeset/css-exports-support.md @@ -0,0 +1,7 @@ +--- +'@backstage/cli': patch +--- + +Added support for CSS exports in package builds. When a package declares a CSS file in its `exports` field (e.g., `"./styles.css": "./src/styles.css"`), the CLI will automatically bundle it during `backstage-cli package build`, resolving any `@import` statements. The export path is rewritten from `src/` to `dist/` at publish time. + +Fixed `backstage-cli repo fix` to not add `typesVersions` entries for non-script exports like CSS files. diff --git a/.changeset/custom-playwright-channel.md b/.changeset/custom-playwright-channel.md new file mode 100644 index 0000000000..94adf89c0e --- /dev/null +++ b/.changeset/custom-playwright-channel.md @@ -0,0 +1,15 @@ +--- +'@backstage/e2e-test-utils': patch +--- + +Added optional `channel` option to `generateProjects()` to allow customizing the Playwright browser channel for testing against different browsers variants. When not provided, the function defaults to 'chrome' to maintain backward compatibility. + +Example usage: + +```ts +import { generateProjects } from '@backstage/e2e-test-utils'; + +export default defineConfig({ + projects: generateProjects({ channel: 'msedge' }), +}); +``` diff --git a/.changeset/easy-deer-eat.md b/.changeset/easy-deer-eat.md new file mode 100644 index 0000000000..10f21e9b29 --- /dev/null +++ b/.changeset/easy-deer-eat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Changed `gitlab:group:ensureExists` action to use `Groups.show` API instead of `Groups.search` for checking if a group path exists. This is more efficient as it directly retrieves the group by path rather than searching and filtering results. diff --git a/.changeset/empty-games-hug.md b/.changeset/empty-games-hug.md new file mode 100644 index 0000000000..e5bb0b4a95 --- /dev/null +++ b/.changeset/empty-games-hug.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Add missing translation entries for catalog UI text. + +This change adds translation keys and updates relevant UI components to use the correct localized labels and text in the catalog plugin. It ensures that catalog screens such as entity layout, tabs, search result items, table labels, and other UI elements correctly reference the i18n system for translation. + +No functional behavior is changed aside from the improved internationalization support. diff --git a/.changeset/famous-phones-chew.md b/.changeset/famous-phones-chew.md new file mode 100644 index 0000000000..29c9056743 --- /dev/null +++ b/.changeset/famous-phones-chew.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Removed `/alpha` from `scaffolderActionsExtensionPoint` import diff --git a/.changeset/fix-theme-language-selector-leak.md b/.changeset/fix-theme-language-selector-leak.md new file mode 100644 index 0000000000..426127b687 --- /dev/null +++ b/.changeset/fix-theme-language-selector-leak.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Fixed memory leak caused by duplicate `AppThemeSelector` instances and missing cleanup in `AppThemeSelector` and `AppLanguageSelector`. Added `dispose()` method to both selectors for proper resource cleanup. diff --git a/.changeset/frontend-mock-apis-alert-featureflags.md b/.changeset/frontend-mock-apis-alert-featureflags.md new file mode 100644 index 0000000000..59fa8f59a1 --- /dev/null +++ b/.changeset/frontend-mock-apis-alert-featureflags.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-test-utils': minor +--- + +Added `MockAlertApi` and `MockFeatureFlagsApi` implementations to the `mockApis` namespace. The mock implementations include useful testing methods like `clearAlerts()`, `waitForAlert()`, `getState()`, `setState()`, and `clearState()` for better test ergonomics. diff --git a/.changeset/frontend-test-utils-mock-apis-and-shorthand.md b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md new file mode 100644 index 0000000000..c864cba776 --- /dev/null +++ b/.changeset/frontend-test-utils-mock-apis-and-shorthand.md @@ -0,0 +1,21 @@ +--- +'@backstage/frontend-test-utils': minor +--- + +**BREAKING**: The `mockApis` namespace is no longer a re-export from `@backstage/test-utils`. It's now a standalone namespace with mock implementations of most core APIs. Mock API instances can be passed directly to `TestApiProvider`, `renderInTestApp`, and `renderTestApp` without needing `[apiRef, impl]` tuples. As part of this change, the `.factory()` method on some mocks has been removed, since it's now redundant. + +```tsx +// Before +import { mockApis } from '@backstage/frontend-test-utils'; + +renderInTestApp(, { + apis: [[identityApiRef, mockApis.identity()]], +}); + +// After - mock APIs can be passed directly +renderInTestApp(, { + apis: [mockApis.identity()], +}); +``` + +This change also adds `createApiMock`, a public utility for creating mock API factories, intended for plugin authors to create their own `.mock()` variants. diff --git a/.changeset/gentle-onions-occur.md b/.changeset/gentle-onions-occur.md new file mode 100644 index 0000000000..29653e39c2 --- /dev/null +++ b/.changeset/gentle-onions-occur.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-catalog-graph': patch +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-scaffolder': patch +'@backstage/plugin-api-docs': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-catalog': patch +'@backstage/plugin-org': patch +--- + +Adjusted to use the new `@backstage/filter-predicates` types for predicate expressions. diff --git a/.changeset/grumpy-signs-deny.md b/.changeset/grumpy-signs-deny.md new file mode 100644 index 0000000000..4654fed83e --- /dev/null +++ b/.changeset/grumpy-signs-deny.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Allow `ref` as a prop on the `Tag` component + +Affected components: Tag diff --git a/.changeset/improve-in-memory-catalog-client.md b/.changeset/improve-in-memory-catalog-client.md new file mode 100644 index 0000000000..9830b259ec --- /dev/null +++ b/.changeset/improve-in-memory-catalog-client.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-client': patch +--- + +Improved the `InMemoryCatalogClient` test utility to support ordering, pagination, full-text search, and field projection for entity query methods. Also fixed `getEntityFacets` to correctly handle multi-valued fields. diff --git a/.changeset/red-chicken-juggle.md b/.changeset/red-chicken-juggle.md new file mode 100644 index 0000000000..0eb2df8b65 --- /dev/null +++ b/.changeset/red-chicken-juggle.md @@ -0,0 +1,13 @@ +--- +'@backstage/plugin-catalog-react': minor +--- + +**BREAKING ALPHA**: All of the predicate types and functions have been moved to the `@backstage/filter-predicates` package. + +When moving into the more general package, they were renamed as follows: + +- `EntityPredicate` -> `FilterPredicate` +- `EntityPredicateExpression` -> `FilterPredicateExpression` +- `EntityPredicatePrimitive` -> `FilterPredicatePrimitive` +- `entityPredicateToFilterFunction` -> `filterPredicateToFilterFunction` +- `EntityPredicateValue` -> `FilterPredicateValue` diff --git a/.changeset/remove-backend-defaults-from-plugins.md b/.changeset/remove-backend-defaults-from-plugins.md new file mode 100644 index 0000000000..3d322b2554 --- /dev/null +++ b/.changeset/remove-backend-defaults-from-plugins.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-devtools-backend': patch +'@backstage/plugin-mcp-actions-backend': patch +'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-search-backend': patch +'@backstage/plugin-techdocs-backend': patch +'@backstage/plugin-user-settings-backend': patch +--- + +Moved `@backstage/backend-defaults` from `dependencies` to `devDependencies`. diff --git a/.changeset/renovate-4e9885a.md b/.changeset/renovate-4e9885a.md new file mode 100644 index 0000000000..65ea142cc7 --- /dev/null +++ b/.changeset/renovate-4e9885a.md @@ -0,0 +1,5 @@ +--- +'@techdocs/cli': patch +--- + +Updated dependency `find-process` to `^2.0.0`. diff --git a/.changeset/renovate-deeb14e.md b/.changeset/renovate-deeb14e.md new file mode 100644 index 0000000000..431a3c401a --- /dev/null +++ b/.changeset/renovate-deeb14e.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +Updated dependency `webpack` to `~5.104.0`. diff --git a/.changeset/renovate-fc2561e.md b/.changeset/renovate-fc2561e.md new file mode 100644 index 0000000000..d89e5b17c0 --- /dev/null +++ b/.changeset/renovate-fc2561e.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-devtools-react': patch +--- + +Updated dependency `@testing-library/react` to `^16.0.0`. diff --git a/.changeset/repo-tools-type-deps-ambient-jest.md b/.changeset/repo-tools-type-deps-ambient-jest.md new file mode 100644 index 0000000000..87c15e6f80 --- /dev/null +++ b/.changeset/repo-tools-type-deps-ambient-jest.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': patch +--- + +The `type-deps` command now detects ambient global types from the `jest` namespace in declaration files, rather than only looking for explicit imports and reference directives. diff --git a/.changeset/scaffolder-react-test-utils-dep.md b/.changeset/scaffolder-react-test-utils-dep.md new file mode 100644 index 0000000000..88d3ee6309 --- /dev/null +++ b/.changeset/scaffolder-react-test-utils-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests. diff --git a/.changeset/scaffolder-test-utils-dep.md b/.changeset/scaffolder-test-utils-dep.md new file mode 100644 index 0000000000..c9b092bc26 --- /dev/null +++ b/.changeset/scaffolder-test-utils-dep.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Added `@backstage/frontend-test-utils` as a dev dependency for mock API usage in tests. diff --git a/.changeset/small-shirts-lose.md b/.changeset/small-shirts-lose.md new file mode 100644 index 0000000000..2fe3d6c414 --- /dev/null +++ b/.changeset/small-shirts-lose.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-gitlab': patch +--- + +Fixed GitLab search API scope parameter from `'blob'` to `'blobs'`, resolving 400 errors in discovery provider. diff --git a/.changeset/tangy-wasps-invent.md b/.changeset/tangy-wasps-invent.md new file mode 100644 index 0000000000..6b07726df0 --- /dev/null +++ b/.changeset/tangy-wasps-invent.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-home-react': patch +'@backstage/plugin-home': patch +--- + +Support new frontend system in the homepage plugin diff --git a/.changeset/test-utils-jest-peer-dep.md b/.changeset/test-utils-jest-peer-dep.md new file mode 100644 index 0000000000..39c8f1b8f6 --- /dev/null +++ b/.changeset/test-utils-jest-peer-dep.md @@ -0,0 +1,6 @@ +--- +'@backstage/test-utils': patch +'@backstage/backend-test-utils': patch +--- + +Added `@types/jest` as an optional peer dependency, since jest types are exposed in the public API surface. diff --git a/.changeset/thirty-mirrors-own.md b/.changeset/thirty-mirrors-own.md new file mode 100644 index 0000000000..629936d8a7 --- /dev/null +++ b/.changeset/thirty-mirrors-own.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +The Table component now wraps the react-aria-components `Table` with a `ResizableTableContainer` only if any column has a width property set. This means that column widths can adapt to the content otherwise (if no width is explicitly set). + +Affected components: Table diff --git a/.changeset/ui-standard-build.md b/.changeset/ui-standard-build.md new file mode 100644 index 0000000000..d0d8bd7f65 --- /dev/null +++ b/.changeset/ui-standard-build.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Migrated to use the standard `backstage-cli package build` for CSS bundling instead of a custom build script. diff --git a/.changeset/wise-rabbits-double.md b/.changeset/wise-rabbits-double.md new file mode 100644 index 0000000000..6164c6e95c --- /dev/null +++ b/.changeset/wise-rabbits-double.md @@ -0,0 +1,5 @@ +--- +'@backstage/filter-predicates': minor +--- + +Introduced package, basically as the extracted predicate types from `@backstage/plugin-catalog-react/alpha` diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index cb569f01b7..68c0fb0d52 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -53,7 +53,6 @@ Chai Chainguard changeset changesets -Changesets chanwit Chanwit CI/CD @@ -68,12 +67,10 @@ Cobertura codeblocks Codecov codehilite -Codehilite codemod codemods codeowners codescene -CodeScene composability composable config @@ -125,7 +122,6 @@ devs dialogs disambiguator discoverability -Discoverability dls Dockerfile dockerfiles @@ -136,7 +132,6 @@ Dominik DOMPurify don'ts dynatrace -Dynatrace dyno ecco elasticsearch @@ -157,12 +152,10 @@ faqs featureful Figma firehydrant -FireHydrant Firekube Firestore Fiverr flightcontrol -Flightcontrol Francesco Frontside Gaurav @@ -188,7 +181,6 @@ haproxy hardcoded hardcoding harness -Harness Helidon Henneke Heroku @@ -281,7 +273,6 @@ microsite microtasks middleware minikube -Minikube Minio misattributed misconfiguration @@ -290,7 +281,6 @@ mkdocs Mkdocs modularization monorepo -Monorepo monorepos monospace morgan @@ -327,7 +317,6 @@ Olausson Oldsberg onboarded onboarding -Onboarding onelogin openapi OpenSearch @@ -335,7 +324,6 @@ OpenShift openssl orgs overridable -Overridable padding paddings pagerduty @@ -345,13 +333,13 @@ parallelization param params parseable +passthrough passwordless Patrik pattison Peloton PEP performant -Performant periskop Periskop permissioned @@ -533,6 +521,7 @@ Turbopack TSDoc typeahead ui +unassign unbreak Unconference undici diff --git a/.github/workflows/api-breaking-changes-comment.yml b/.github/workflows/api-breaking-changes-comment.yml index b846296cfe..aca727743a 100644 --- a/.github/workflows/api-breaking-changes-comment.yml +++ b/.github/workflows/api-breaking-changes-comment.yml @@ -23,7 +23,7 @@ jobs: comment-cache-key: ${{ steps.hash.outputs.COMMENT_FILE_HASH }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/api-breaking-changes.yml b/.github/workflows/api-breaking-changes.yml index f65c0dfa1a..faa8ebc507 100644 --- a/.github/workflows/api-breaking-changes.yml +++ b/.github/workflows/api-breaking-changes.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.action != 'closed' }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -33,7 +33,7 @@ jobs: registry-url: https://registry.npmjs.org/ - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: linux-v22 diff --git a/.github/workflows/automate_area-labels.yml b/.github/workflows/automate_area-labels.yml index e85a9cf11f..cdf4a28b55 100644 --- a/.github/workflows/automate_area-labels.yml +++ b/.github/workflows/automate_area-labels.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/automate_changeset_feedback.yml b/.github/workflows/automate_changeset_feedback.yml index da06d6c6e8..d7ee3911c8 100644 --- a/.github/workflows/automate_changeset_feedback.yml +++ b/.github/workflows/automate_changeset_feedback.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -34,7 +34,7 @@ jobs: ref: 'refs/pull/${{ github.event.pull_request.number }}/merge' - name: fetch base run: git fetch --depth 1 origin ${{ github.base_ref }} - - uses: backstage/actions/changeset-feedback@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + - uses: backstage/actions/changeset-feedback@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 name: Generate feedback with: diff-ref: 'origin/master' diff --git a/.github/workflows/automate_merge_message.yml b/.github/workflows/automate_merge_message.yml index d644feb580..c1ecde9194 100644 --- a/.github/workflows/automate_merge_message.yml +++ b/.github/workflows/automate_merge_message.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/automate_stale.yml b/.github/workflows/automate_stale.yml index a7eef4fae4..c9ee8f8078 100644 --- a/.github/workflows/automate_stale.yml +++ b/.github/workflows/automate_stale.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/ci-noop.yml b/.github/workflows/ci-noop.yml index 12aafe18ee..d9851778c6 100644 --- a/.github/workflows/ci-noop.yml +++ b/.github/workflows/ci-noop.yml @@ -40,7 +40,7 @@ jobs: name: Test ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4382f00f1..3524a903d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,10 +28,22 @@ jobs: name: Install ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit + # Store the PR number for the Sync Pull Requests workflow + - name: Save PR context + if: matrix.node-version == '22.x' + run: | + mkdir -p ./context + echo "${{ github.event.pull_request.number }}" > ./context/pr-number + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: matrix.node-version == '22.x' + with: + name: pr-context + path: context/ + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: use node.js ${{ matrix.node-version }} @@ -41,7 +53,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} @@ -64,7 +76,7 @@ jobs: name: Verify ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -77,7 +89,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} @@ -126,10 +138,6 @@ jobs: - name: build all packages run: yarn backstage-cli repo build --all - # For now BUI has a custom build script and needs to be built separately - - name: build BUI - run: yarn --cwd packages/ui build - # Build docs-ui to ensure documentation site builds successfully - name: install docs-ui dependencies working-directory: docs-ui @@ -237,7 +245,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 3b65576257..1fe718fabb 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -10,11 +10,11 @@ jobs: timeout-minutes: 10 steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit - - uses: backstage/actions/cron@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + - uses: backstage/actions/cron@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml index a3f47fc737..7dd7bbebd6 100644 --- a/.github/workflows/deploy_docker-image.yml +++ b/.github/workflows/deploy_docker-image.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -37,7 +37,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/deploy_microsite.yml b/.github/workflows/deploy_microsite.yml index 4eeab66408..d7f5509c85 100644 --- a/.github/workflows/deploy_microsite.yml +++ b/.github/workflows/deploy_microsite.yml @@ -23,7 +23,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -65,7 +65,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x @@ -124,7 +124,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -138,7 +138,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x @@ -218,7 +218,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index 020181fb48..e49b7b9227 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -76,7 +76,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} @@ -110,10 +110,6 @@ jobs: - name: build run: yarn backstage-cli repo build --all - # For now BUI has a custom build script and needs to be built separately - - name: build BUI - run: yarn --cwd packages/ui build - - name: verify type dependencies run: yarn lint:type-deps @@ -151,7 +147,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/issue.yaml b/.github/workflows/issue.yaml index 6dc2c924c5..791ca69a87 100644 --- a/.github/workflows/issue.yaml +++ b/.github/workflows/issue.yaml @@ -16,7 +16,7 @@ jobs: if: github.repository == 'backstage/backstage' steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/mui-migration-tracker.yml b/.github/workflows/mui-migration-tracker.yml index a903fc9b55..2898da38b4 100644 --- a/.github/workflows/mui-migration-tracker.yml +++ b/.github/workflows/mui-migration-tracker.yml @@ -18,7 +18,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -32,7 +32,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x diff --git a/.github/workflows/pr-review-comment-trigger.yaml b/.github/workflows/pr-review-comment-trigger.yaml deleted file mode 100644 index 95c9c921f5..0000000000 --- a/.github/workflows/pr-review-comment-trigger.yaml +++ /dev/null @@ -1,37 +0,0 @@ -# This workflow is used to trigger the "PR Review Comment" workflow. This chaining is needed -# because workflows triggered by pull_request_review_comment do not have access to secrets. - -name: PR Review Comment Trigger -on: - pull_request_review_comment: - types: - - created - -permissions: - contents: read - -jobs: - trigger: - runs-on: ubuntu-latest - - # The "PR Review Comment" workflow will check the success status of this workflow and only mark - # the PR for re-review if this workflow was successful, which is when the author leaves a review comment. - if: github.repository == 'backstage/backstage' && github.event.comment.user.id == github.event.pull_request.user.id - - steps: - - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 - with: - egress-policy: audit - - - name: Save PR number - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: | - mkdir -p ./pr - echo $PR_NUMBER > ./pr/pr_number - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: pr_number-${{ github.event.pull_request.number }} - path: pr/ - overwrite: true diff --git a/.github/workflows/pr-review-comment.yaml b/.github/workflows/pr-review-comment.yaml deleted file mode 100644 index aa6618586d..0000000000 --- a/.github/workflows/pr-review-comment.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# This workflow is triggered by "PR Review Comment Trigger" - -name: PR Review Comment -on: - workflow_run: - workflows: [PR Review Comment Trigger] - types: - - completed - -jobs: - re-review: - runs-on: ubuntu-latest - - # The triggering workflow will report success if the PR needs re-review - if: github.repository == 'backstage/backstage' && github.event.workflow_run.conclusion == 'success' - - steps: - # Inspired by https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#using-data-from-the-triggering-workflow - - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 - with: - egress-policy: audit - - - name: Read PR Number - id: pr-number - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: context.payload.workflow_run.id, - }); - const [artifact] = allArtifacts.data.artifacts.filter(artifact => artifact.name.startsWith('pr_number-')) - if (!artifact) { - throw new Error('No PR Number artifact available') - } - - const prNumber = artifact.name.slice('pr_number-'.length) - core.setOutput('pr-number', prNumber); - - - uses: backstage/actions/re-review@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 - with: - app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} - private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} - installation-id: ${{ secrets.BACKSTAGE_GOALIE_INSTALLATION_ID }} - project-id: PVT_kwDOBFKqdc02LQ - issue-number: ${{ steps.pr-number.outputs.pr-number }} diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml deleted file mode 100644 index e42e79c689..0000000000 --- a/.github/workflows/pr.yaml +++ /dev/null @@ -1,34 +0,0 @@ -name: PR -on: - pull_request_target: - types: - - opened - - synchronize - - reopened - - closed - issue_comment: - types: - - created - -jobs: - sync: - runs-on: ubuntu-latest - - # Avoid running on issue comments - if: github.repository == 'backstage/backstage' && ( github.event.pull_request || github.event.issue.pull_request ) - steps: - - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 - with: - egress-policy: audit - - - name: PR sync - uses: backstage/actions/pr-sync@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 - with: - github-token: ${{ secrets.GH_SERVICE_ACCOUNT_TOKEN }} - app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} - private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} - installation-id: ${{ secrets.BACKSTAGE_GOALIE_INSTALLATION_ID }} - project-id: PVT_kwDOBFKqdc02LQ - auto-assign: false - owning-teams: '@backstage/techdocs-core' diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 2b8be7160d..9e76652668 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/sync_bui-docs.yml b/.github/workflows/sync_bui-docs.yml index aff8d75ac6..86d9feafc8 100644 --- a/.github/workflows/sync_bui-docs.yml +++ b/.github/workflows/sync_bui-docs.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -22,7 +22,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x diff --git a/.github/workflows/sync_code-formatting.yml b/.github/workflows/sync_code-formatting.yml index 342fe9c077..d9696acd14 100644 --- a/.github/workflows/sync_code-formatting.yml +++ b/.github/workflows/sync_code-formatting.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -25,7 +25,7 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/sync_dependabot-changesets.yml b/.github/workflows/sync_dependabot-changesets.yml index ebe1635aef..fb51949fd1 100644 --- a/.github/workflows/sync_dependabot-changesets.yml +++ b/.github/workflows/sync_dependabot-changesets.yml @@ -11,7 +11,7 @@ jobs: if: github.actor == 'dependabot[bot]' && github.repository == 'backstage/backstage' steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/sync_patch-release.yml b/.github/workflows/sync_patch-release.yml index 9ce2fd20fc..e126fe2ab9 100644 --- a/.github/workflows/sync_patch-release.yml +++ b/.github/workflows/sync_patch-release.yml @@ -20,7 +20,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/sync_pull-requests-trigger.yml b/.github/workflows/sync_pull-requests-trigger.yml index 10430b18a6..152837b811 100644 --- a/.github/workflows/sync_pull-requests-trigger.yml +++ b/.github/workflows/sync_pull-requests-trigger.yml @@ -2,7 +2,15 @@ name: Sync Pull Requests Trigger on: pull_request_target: - types: [opened, synchronize, reopened, labeled, unlabeled] + types: + - opened + - closed + - reopened + - synchronize + - labeled + - unlabeled + - ready_for_review + - converted_to_draft pull_request_review: types: [submitted, dismissed] issue_comment: @@ -17,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/sync_pull-requests.yml b/.github/workflows/sync_pull-requests.yml index 9bba4f9307..4226b57458 100644 --- a/.github/workflows/sync_pull-requests.yml +++ b/.github/workflows/sync_pull-requests.yml @@ -7,15 +7,15 @@ on: - completed jobs: - # Runs when Sync Pull Requests Trigger completes (PR events like opened, labeled, reviewed) run: runs-on: ubuntu-latest + # Only run on the trigger workflow, or if the source workflow was triggered by a pull request if: > - github.event.workflow_run.name == 'Sync Pull Requests Trigger' && - github.event.workflow_run.conclusion == 'success' + github.event.workflow_run.name == 'Sync Pull Requests Trigger' || + github.event.workflow_run.event == 'pull_request' steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -36,15 +36,19 @@ jobs: with: script: | const fs = require('fs'); - core.setOutput('pr-number', fs.readFileSync('./context/pr-number', 'utf8').trim()); - core.setOutput('label-added', fs.readFileSync('./context/label-added', 'utf8').trim()); - core.setOutput('review-state', fs.readFileSync('./context/review-state', 'utf8').trim()); - core.setOutput('actor', fs.readFileSync('./context/actor', 'utf8').trim()); - core.setOutput('action', fs.readFileSync('./context/action', 'utf8').trim()); + function read(path, fallback) { + try { return fs.readFileSync(path, 'utf8').trim(); } + catch { return fallback; } + } + core.setOutput('pr-number', read('./context/pr-number', '')); + core.setOutput('label-added', read('./context/label-added', '')); + core.setOutput('review-state', read('./context/review-state', '')); + core.setOutput('actor', read('./context/actor', '')); + core.setOutput('action', read('./context/action', 'synchronize')); - name: Backstage PR automation - if: steps.download.outcome == 'success' - uses: backstage/actions/pr-automation@d8f699b19b0d328865976ef5f4982dfa97db9952 # v0.7.4 + if: steps.context.outputs.pr-number + uses: backstage/actions/pr-automation@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} @@ -56,27 +60,11 @@ jobs: review-state: ${{ steps.context.outputs.review-state }} actor: ${{ steps.context.outputs.actor }} action: ${{ steps.context.outputs.action }} - - # Runs when CI or E2E workflows complete to update priority based on check status - update-on-ci: - runs-on: ubuntu-latest - if: > - (github.event.workflow_run.name == 'CI' || github.event.workflow_run.name == 'E2E Linux') && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.pull_requests[0] - steps: - - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 - with: - egress-policy: audit - - - name: Backstage PR automation - uses: backstage/actions/pr-automation@d8f699b19b0d328865976ef5f4982dfa97db9952 # v0.7.4 - with: - app-id: ${{ secrets.BACKSTAGE_GOALIE_APPLICATION_ID }} - private-key: ${{ secrets.BACKSTAGE_GOALIE_PRIVATE_KEY }} - installation-id: ${{ secrets.BACKSTAGE_GOALIE_INSTALLATION_ID }} - project-owner: backstage - project-number: '14' - pr-number: ${{ github.event.workflow_run.pull_requests[0].number }} - action: synchronize + required-checks: | + DCO + E2E Linux 22.x + E2E Linux 24.x + Test 22.x + Test 24.x + Verify 22.x + Verify 24.x diff --git a/.github/workflows/sync_release-manifest.yml b/.github/workflows/sync_release-manifest.yml index df528d97d8..b34615dd6a 100644 --- a/.github/workflows/sync_release-manifest.yml +++ b/.github/workflows/sync_release-manifest.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -25,7 +25,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x diff --git a/.github/workflows/sync_renovate-changesets.yml b/.github/workflows/sync_renovate-changesets.yml index 339cfa509a..8575cab760 100644 --- a/.github/workflows/sync_renovate-changesets.yml +++ b/.github/workflows/sync_renovate-changesets.yml @@ -11,7 +11,7 @@ jobs: if: github.actor == 'renovate[bot]' && github.repository == 'backstage/backstage' steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml index cab617a228..ea93ac6334 100644 --- a/.github/workflows/sync_snyk-github-issues.yml +++ b/.github/workflows/sync_snyk-github-issues.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -24,7 +24,7 @@ jobs: node-version: 22.x registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 3be8d05180..6b91c1b9dd 100644 --- a/.github/workflows/sync_snyk-monitor.yml +++ b/.github/workflows/sync_snyk-monitor.yml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/sync_version-packages.yml b/.github/workflows/sync_version-packages.yml index 18f374e124..7df18100b0 100644 --- a/.github/workflows/sync_version-packages.yml +++ b/.github/workflows/sync_version-packages.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_accessibility-noop.yml b/.github/workflows/verify_accessibility-noop.yml index ee696ebc7f..51caba5acd 100644 --- a/.github/workflows/verify_accessibility-noop.yml +++ b/.github/workflows/verify_accessibility-noop.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_accessibility.yml b/.github/workflows/verify_accessibility.yml index 72b1b596a0..c9f92103bf 100644 --- a/.github/workflows/verify_accessibility.yml +++ b/.github/workflows/verify_accessibility.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -30,7 +30,7 @@ jobs: with: node-version: 22.x - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x - name: run Lighthouse CI diff --git a/.github/workflows/verify_chromatic-noop.yml b/.github/workflows/verify_chromatic-noop.yml index 35015def52..cb4c96902a 100644 --- a/.github/workflows/verify_chromatic-noop.yml +++ b/.github/workflows/verify_chromatic-noop.yml @@ -20,7 +20,7 @@ jobs: name: Chromatic steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_chromatic.yml b/.github/workflows/verify_chromatic.yml index 07491b1ce2..716209bdf6 100644 --- a/.github/workflows/verify_chromatic.yml +++ b/.github/workflows/verify_chromatic.yml @@ -24,7 +24,7 @@ jobs: name: Chromatic steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -40,7 +40,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: Install dependencies - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index 57661b359a..fd531c2be8 100644 --- a/.github/workflows/verify_codeql.yml +++ b/.github/workflows/verify_codeql.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index c4979a6582..af42f25390 100644 --- a/.github/workflows/verify_docs-quality.yml +++ b/.github/workflows/verify_docs-quality.yml @@ -12,7 +12,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-linux-noop.yml b/.github/workflows/verify_e2e-linux-noop.yml index 152f4cc2c0..38ee55f72c 100644 --- a/.github/workflows/verify_e2e-linux-noop.yml +++ b/.github/workflows/verify_e2e-linux-noop.yml @@ -29,7 +29,7 @@ jobs: name: E2E Linux ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml index 2d86e731b3..e8f5660d69 100644 --- a/.github/workflows/verify_e2e-linux.yml +++ b/.github/workflows/verify_e2e-linux.yml @@ -43,10 +43,22 @@ jobs: name: E2E Linux ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit + # Store the PR number for the Sync Pull Requests workflow + - name: Save PR context + if: matrix.node-version == '22.x' && github.event_name == 'pull_request' + run: | + mkdir -p ./context + echo "${{ github.event.pull_request.number }}" > ./context/pr-number + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: matrix.node-version == '22.x' && github.event_name == 'pull_request' + with: + name: pr-context + path: context/ + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure Git @@ -60,14 +72,12 @@ jobs: node-version: ${{ matrix.node-version }} registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} - run: yarn tsc - run: yarn backstage-cli repo build - # BUI has a custom build script and needs to be built separately - - run: yarn --cwd packages/ui build - name: run E2E test run: | sudo sysctl fs.inotify.max_user_watches=524288 diff --git a/.github/workflows/verify_e2e-techdocs.yml b/.github/workflows/verify_e2e-techdocs.yml index 9124f95b9b..373e891d48 100644 --- a/.github/workflows/verify_e2e-techdocs.yml +++ b/.github/workflows/verify_e2e-techdocs.yml @@ -32,7 +32,7 @@ jobs: name: Techdocs steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -46,7 +46,7 @@ jobs: with: node-version: ${{ matrix.node-version }} - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} diff --git a/.github/workflows/verify_e2e-windows-noop.yml b/.github/workflows/verify_e2e-windows-noop.yml index f04748a950..4759555905 100644 --- a/.github/workflows/verify_e2e-windows-noop.yml +++ b/.github/workflows/verify_e2e-windows-noop.yml @@ -25,7 +25,7 @@ jobs: name: E2E Windows ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml index b35fd86225..fdfdeabe1d 100644 --- a/.github/workflows/verify_e2e-windows.yml +++ b/.github/workflows/verify_e2e-windows.yml @@ -33,7 +33,7 @@ jobs: name: E2E Windows ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -80,14 +80,12 @@ jobs: uses: browser-actions/setup-chrome@803ef6dfb4fdf22089c9563225d95e4a515820a0 # latest - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v${{ matrix.node-version }} - run: yarn tsc - run: yarn backstage-cli repo build - # BUI has a custom build script and needs to be built separately - - run: yarn --cwd packages/ui build - name: run E2E test run: yarn e2e-test run env: diff --git a/.github/workflows/verify_fossa.yml b/.github/workflows/verify_fossa.yml index 4966435eac..84d666f00e 100644 --- a/.github/workflows/verify_fossa.yml +++ b/.github/workflows/verify_fossa.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite-noop.yml b/.github/workflows/verify_microsite-noop.yml index 70686e3bd9..acab96962c 100644 --- a/.github/workflows/verify_microsite-noop.yml +++ b/.github/workflows/verify_microsite-noop.yml @@ -21,7 +21,7 @@ jobs: name: Microsite steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite.yml b/.github/workflows/verify_microsite.yml index cc3ad509ba..8b94b7c942 100644 --- a/.github/workflows/verify_microsite.yml +++ b/.github/workflows/verify_microsite.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -70,7 +70,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x @@ -126,7 +126,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit @@ -140,7 +140,7 @@ jobs: registry-url: https://registry.npmjs.org/ # Needed for auth - name: yarn install - uses: backstage/actions/yarn-install@d98113056c83b1b0a1293c8310d77d99db4ea22e # v0.7.3 + uses: backstage/actions/yarn-install@a3895cac2a515224a101c4a2b962b216ed0c9ac8 # v0.7.5 with: cache-prefix: ${{ runner.os }}-v22.x @@ -212,7 +212,7 @@ jobs: name: Microsite steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite_accessibility-noop.yml b/.github/workflows/verify_microsite_accessibility-noop.yml index bb13a85dfa..87963dd78e 100644 --- a/.github/workflows/verify_microsite_accessibility-noop.yml +++ b/.github/workflows/verify_microsite_accessibility-noop.yml @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite_accessibility.yml b/.github/workflows/verify_microsite_accessibility.yml index 4ff24ac73c..76862a9fa7 100644 --- a/.github/workflows/verify_microsite_accessibility.yml +++ b/.github/workflows/verify_microsite_accessibility.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 345824e67a..7651160795 100644 --- a/.github/workflows/verify_windows.yml +++ b/.github/workflows/verify_windows.yml @@ -29,7 +29,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@df199fb7be9f65074067a9eb93f12bb4c5547cf2 # v2.13.3 + uses: step-security/harden-runner@5ef0c079ce82195b2a36a210272d6b661572d83e # v2.14.2 with: egress-policy: audit diff --git a/.gitignore b/.gitignore index aaf05b51cd..dec469903e 100644 --- a/.gitignore +++ b/.gitignore @@ -186,4 +186,7 @@ docs.json tsconfig.typedoc.tmp.json # Storybook -dist-storybook/ \ No newline at end of file +dist-storybook/ + +# Personal allow patterns etc +.claude/settings.local.json diff --git a/OWNERS.md b/OWNERS.md index 302501b74b..e09a0a24c3 100644 --- a/OWNERS.md +++ b/OWNERS.md @@ -283,6 +283,7 @@ Scope: The Scaffolder frontend and backend plugins, and related tooling. | Carlos Esteban Lopez Jaramillo | VMWare | [luchillo17](https://github.com/luchillo17) | `luchillo17#8777` | | David Tuite | Roadie.io | [dtuite](https://github.com/dtuite) | `David Tuite (roadie.io)#1010` | | Deepankumar Loganathan | | [deepan10](https://github.com/deepan10) | `deepan10` | +| Gabriel Dugny | Believe | [GabDug](https://github.com/GabDug) | `GabDug` | | Heikki Hellgren | OP Financial Group | [drodil](https://github.com/drodil) | `deathammer` | | Himanshu Mishra | Harness.io | [OrkoHunter](https://github.com/OrkoHunter) | `OrkoHunter#1520` | | Irma Solakovic | Roadie.io | [Irma12](https://github.com/Irma12) | `Irma#7629` | diff --git a/REVIEWING.md b/REVIEWING.md index 35638797e6..75f6a67eeb 100644 --- a/REVIEWING.md +++ b/REVIEWING.md @@ -2,7 +2,7 @@ This file provides pointers for reviewing pull requests. While the main audience are reviewers, this can also be useful if you are contributing to this repository. -## Quick links +## Quick Links - [Incoming Reviews](https://github.com/orgs/backstage/projects/14/views/1) - [Personal Reviews](https://github.com/orgs/backstage/projects/14/views/2) @@ -10,30 +10,77 @@ This file provides pointers for reviewing pull requests. While the main audience ## Review Workflow -Pull request reviews are coordinated manually by maintainers and reviewers based on project area ownership. Reviews are prioritized and tracked using labels and GitHub Project boards. This review process applies to all pull requests in the [main Backstage repository](https://github.com/backstage/backstage), other repositories may have their own review processes. +Pull request reviews are coordinated by maintainers and reviewers based on project area ownership. Reviews are prioritized and tracked using automated labels and a GitHub Project board. This review process applies to all pull requests in the [main Backstage repository](https://github.com/backstage/backstage), other repositories may have their own review processes. All incoming pull request reviews are tracked on the [Incoming Reviews board](https://github.com/orgs/backstage/projects/14/views/1). This board can be used by members of the `@backstage/reviewers` group to find pull requests to review, as well as maintainers that want an overview of all incoming pull requests. Project area maintainers can use the same board but with additional filters applied to only show incoming pull requests for their project area. These filtered boards are linked to for each project area in [OWNERS.md](./OWNERS.md). There is also a [personal review board](https://github.com/orgs/backstage/projects/14/views/2) that can be used to track reviews that you have been assigned to. -There are several labels that help track the status and prioritize pull requests. You can find more information about these labels in [LABELS.md](./LABELS.md#pull-request-labels). +### Submitting Reviews -Reviewers should use the "request changes" option when they believe changes are necessary before the pull request can be merged. This is both to clarify to the author that the pull request is not ready to be merged, but also to update the status of the pull request in the review queue. If you simply want to contribute to the discussion in a pull request, or have minor or optional suggestions, you can just leave a regular comment. +When reviewing a pull request, always submit a formal review using either **"Approve"** or **"Request changes"**. Do not use "Comment" as a substitute for either of these — the review automation relies on the review state to manage labels and the review board, and only formal approvals and change requests are tracked. -### Review process for @backstage/reviewers +- Use **"Approve"** when you are satisfied with the changes and believe they are ready to be merged (for your area, at least). +- Use **"Request changes"** when changes are necessary before the pull request can be merged. This signals clearly to the author what is expected and moves the pull request out of the review queue until the author responds. +- Use **"Comment"** only to contribute to discussion, ask questions, or leave optional or minor suggestions that do not block the pull request. -Members of the `@backstage/reviewers` do not have any specific areas that they should focus on, they can choose to filter and focus on reviews from any part of the project. They do not assign themselves specific pull requests, but instead leave reviews directly on pull requests without any further process. +Being explicit with "Approve" or "Request changes" is important both for transparent communication with the author and for the automation to function correctly. The automation reads these review states to update labels and the review board accordingly, meaning a "Comment" review will not cause any status change. -Reviews from this group still have meaningful impact on the review process, as they are picked up by project automation. Approving reviews will add the `reviewer-approved` label to the pull request, which greatly increases its priority and visibility for owners in the area. Likewise, requesting changes will add the `waiting-for:author` label to the pull request, which will remove it from the review queue until the author has commented or made changes. +### Closing Pull Requests -### Review process for Project Area Maintainers +Pull requests that have been superseded, are no longer relevant, or otherwise are not worth moving forward should be closed with a comment explaining why. Closed pull requests are automatically removed from the review board by the automation. -Project area maintainers are responsible for reviewing and ultimately merging or closing pull requests towards their project area. They should use the global or filtered board for their project area to find pull requests to review. When they find a pull request that they want to review, they should assign themselves to the pull request, removing it from the review queue and placing it on their personal review board. +### Review Process for @backstage/reviewers -Once a pull request has been assigned a single owner, it is their responsibility to review and eventually merge or close the pull request. They manage all ongoing requests on their personal review board, typically prioritizing the ones at the top of the board marked with `waiting-for:review`. If a pull request is left unreviewed for too long, it will automatically be unassigned and returned to the review queue. Once a pull request has been approved by the assigned owner, the owner should merge the pull request themselves if it is an outside contribution, but otherwise generally leave that to the author of the PR. +Members of the `@backstage/reviewers` do not have any specific areas that they should focus on. They can choose to filter and focus on reviews from any part of the project. They do not assign themselves specific pull requests, but instead leave reviews directly on pull requests without any further process. + +Reviews from this group still have meaningful impact on the review process, as they are picked up by the automation. Approving reviews from a member of this group will add the `reviewer-approved` label to the pull request, which greatly increases its priority and visibility for project area maintainers. Likewise, requesting changes will add the `waiting-for:author` label to the pull request, removing it from the review queue until the author has commented or pushed new changes. + +As a reward for helping out with reviews, members who actively review pull requests will have their own pull requests prioritized higher on the review board. The more reviews you contribute, the higher the priority boost your own pull requests receive. + +### Review Process for Project Area Maintainers + +Project area maintainers are responsible for reviewing and ultimately merging or closing pull requests that target their project area. They should use the global or filtered board for their project area to find pull requests to review. When they find a pull request that they want to review, they should assign themselves to the pull request, removing it from the review queue and placing it on their personal review board. + +Once a pull request has been assigned a single owner, it is their responsibility to review and eventually merge or close the pull request. They manage all ongoing requests on their personal review board, typically prioritizing the ones at the top of the board marked with `waiting-for:review`. If a pull request remains assigned but unreviewed for 14 days, the automation will automatically unassign the reviewer and return the pull request to the review queue. + +### Merging Pull Requests + +When a pull request has been approved, the way it gets merged depends on who the author is: + +- **Outside contributions:** Project area maintainers and core maintainers should merge the pull request themselves once approved, since outside contributors do not have merge access. +- **Maintainer contributions:** Within maintainer teams, it is common to let the author choose when to merge the pull request themselves, unless there is a reason to merge it immediately. The `waiting-for:merge` label will be applied automatically when the pull request is approved. + +### Cross-Area Pull Requests Some pull requests may require review from multiple project areas. In these cases the most relevant owner should assign themselves and coordinate with the other owners for additional reviews. If the most relevant owner is not clear, this is preferably solved in a discussion among the owners. Frequent conflicts should lead to a discussion whether `CODEOWNERS` should be updated to simplify the review process. If some owners are currently unavailable, other owners can assign themselves to the pull request and bring it to the point where they approve the changes for their area, and then send the pull request back to the review queue. +## Pull Request Automation + +Pull request labels and the [review board](https://github.com/orgs/backstage/projects/14) are fully managed by automation. All fields on the review board are computed automatically and **should never be updated manually**. Likewise, pull request labels that are part of the automation should not be added or removed by hand, with one exception described below. + +The automation is triggered by pull request events (opened, updated, reopened, labeled, unlabeled), pull request reviews (submitted, dismissed), issue comments, and CI workflow completions. The source code is available in the [`backstage/actions`](https://github.com/backstage/actions) repository, with the workflow definitions in [`.github/workflows/sync_pull-requests-trigger.yml`](.github/workflows/sync_pull-requests-trigger.yml) and [`.github/workflows/sync_pull-requests.yml`](.github/workflows/sync_pull-requests.yml). + +### Labels + +The `waiting-for:*` status labels, `reviewer-approved` label, and `size:*` labels are all **automated** based on pull request reviews, author activity, and pull request size. See [LABELS.md](./LABELS.md#pull-request-labels) for the meaning of each label. + +The only exception is `waiting-for:decision`, which can and should be set **manually**. Use it when discussion or a decision is needed before the pull request can move forward, for example when the approach needs to be discussed in a [SIG](https://github.com/backstage/community/tree/main/sigs) meeting. Once set, this label will **stick until manually removed** — the automation will not override it. Only remove it once a decision has been made and the pull request is ready to proceed. + +### Review Board Priority + +The priority field on the review board is a number calculated automatically by the automation. It determines the sort order of pull requests on the board. The priority is influenced by several factors: + +- **Pull request size** — Smaller pull requests receive higher priority, where the priority reduces significantly for roughly every 500 lines added. +- **Reviewer approval** — Pull requests with the `reviewer-approved` label receive a large priority boost. +- **Author review contributions** — Authors who have themselves reviewed other pull requests in the repository receive a priority boost proportional to their review activity. +- **Draft status** — Draft pull requests receive significantly reduced priority. +- **CI status** — Pull requests where required CI checks are failing or still pending receive reduced priority. The required checks include DCO, tests, verification, and E2E tests. + +### Stale Review Handling + +If a pull request with the `waiting-for:review` label has been assigned to a reviewer but has not received a review for 14 days, the automation will automatically unassign the reviewer and return the pull request to the review queue. This ensures that pull requests do not get stuck due to reviewer unavailability. + ## Code Style See our code style documented at [STYLE.md](./STYLE.md). diff --git a/docs/features/software-templates/writing-custom-actions.md b/docs/features/software-templates/writing-custom-actions.md index 7e0ee1c5a9..f5ed9e4854 100644 --- a/docs/features/software-templates/writing-custom-actions.md +++ b/docs/features/software-templates/writing-custom-actions.md @@ -28,13 +28,17 @@ boilerplate code, providing a smooth start: ```sh $ yarn backstage-cli new ? What do you want to create? - plugin-common - A new isomorphic common plugin package - plugin-node - A new Node.js library plugin package - plugin-react - A new web library plugin package -> scaffolder-module - An module exporting custom actions for @backstage/plugin-scaffolder-backend + web-library - A library package, exporting shared functionality for web environments + node-library - A library package, exporting shared functionality for Node.js environments + catalog-provider-module - An Entity Provider module for the Software Catalog +> scaffolder-backend-module - A module exporting custom actions for @backstage/plugin-scaffolder-backend + frontend-plugin - A new frontend plugin + backend-plugin - A new backend plugin + backend-plugin-module - A new backend module that extends an existing backend plugin +(Move up and down to reveal more choices) ``` -When prompted, select the option to generate a scaffolder module. This creates a solid foundation for your custom +When prompted, select the option to generate a `scaffolder-backend-module` using the down arrow key. This creates a solid foundation for your custom action. Enter the name of the module you wish to create, and the CLI will generate the required files and directory structure. @@ -205,47 +209,12 @@ argument. It looks like the following: - `ctx.metadata` - an object containing a `name` field, indicating the template name. More metadata fields may be added later. -## Registering Custom Actions - -To register your new custom action in the Backend System, you will need to create a backend module. Here is a very -simplified example of how to do that: - -```ts title="packages/backend/src/index.ts" -/* highlight-add-start */ -import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; -import { createBackendModule } from '@backstage/backend-plugin-api'; -/* highlight-add-end */ - -/* highlight-add-start */ -const scaffolderModuleCustomExtensions = createBackendModule({ - pluginId: 'scaffolder', // name of the plugin that the module is targeting - moduleId: 'custom-extensions', - register(env) { - env.registerInit({ - deps: { - scaffolder: scaffolderActionsExtensionPoint, - // ... and other dependencies as needed - }, - async init({ scaffolder /* ..., other dependencies */ }) { - // Here you have the opportunity to interact with the extension - // point before the plugin itself gets instantiated - scaffolder.addActions(createNewFileAction()); // just an example - }, - }); - }, -}); -/* highlight-add-end */ - -const backend = createBackend(); -backend.add(import('@backstage/plugin-scaffolder-backend')); -/* highlight-add-next-line */ -backend.add(scaffolderModuleCustomExtensions); -``` +### Using Core Services in Custom Actions If your custom action requires core services such as `config` or `cache` they can be imported in the dependencies and passed to the custom action function. -```ts title="packages/backend/src/index.ts" +```ts title="module.ts" import { coreServices, createBackendModule, diff --git a/docs/frontend-system/building-plugins/02-testing.md b/docs/frontend-system/building-plugins/02-testing.md index 9c6db0d3de..82f44df2eb 100644 --- a/docs/frontend-system/building-plugins/02-testing.md +++ b/docs/frontend-system/building-plugins/02-testing.md @@ -31,7 +31,7 @@ describe('Entity details component', () => { }); ``` -To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities: +To mock [Utility APIs](../architecture/33-utility-apis.md) that are used by your component, pass API overrides to `renderInTestApp` using the `apis` option. Mock helpers are available from `@backstage/frontend-test-utils` and plugin-specific test utilities. For a deeper look at the available mock APIs and how to create your own, see [Testing with Utility APIs](../utility-apis/05-testing.md). ```tsx import { screen } from '@testing-library/react'; diff --git a/docs/frontend-system/utility-apis/01-index.md b/docs/frontend-system/utility-apis/01-index.md index 57e895ca36..86d317ddb2 100644 --- a/docs/frontend-system/utility-apis/01-index.md +++ b/docs/frontend-system/utility-apis/01-index.md @@ -35,6 +35,14 @@ Most utility APIs are usable directly without any configuration. But they are pr These cases are all described in [the main article](./04-configuring.md). +## Testing with utility APIs + +> For details, [see the main article](./05-testing.md). + +When testing frontend components and extensions, you often need to provide mock implementations of the utility APIs they depend on. The `@backstage/frontend-test-utils` package provides the `mockApis` namespace with ready-made mocks for all core utility APIs, which can be passed to test utilities like `renderInTestApp` and `TestApiProvider`. + +These are described in detail in [the main article](./05-testing.md). + ## Migrating from the old frontend system If you want to learn how to migrate your own utility APIs from the old frontend system to the new one, that's described in the [Migrating APIs guide](../building-plugins/05-migrating.md#migrating-apis). diff --git a/docs/frontend-system/utility-apis/05-testing.md b/docs/frontend-system/utility-apis/05-testing.md new file mode 100644 index 0000000000..ba1e93e505 --- /dev/null +++ b/docs/frontend-system/utility-apis/05-testing.md @@ -0,0 +1,194 @@ +--- +id: testing +title: Testing with Utility APIs +sidebar_label: Testing +description: Mocking and testing Utility APIs +--- + +When testing frontend components and extensions, you often need to provide mock implementations of the utility APIs they depend on. The `@backstage/frontend-test-utils` package provides the `mockApis` namespace with ready-made mocks for all core utility APIs. + +## The `mockApis` namespace + +The `mockApis` namespace is the main entry point for creating mock utility API instances in tests. It provides two usage patterns for each API: + +### Fake instances + +Call the API function directly to create a fake instance with simplified but functional behavior. These are useful when your test needs the API to actually work, not just be stubbed. + +```ts +import { mockApis } from '@backstage/frontend-test-utils'; + +const configApi = mockApis.config({ data: { app: { title: 'Test' } } }); +configApi.getString('app.title'); // 'Test' + +const alertApi = mockApis.alert(); +alertApi.post({ message: 'hello' }); +expect(alertApi.getAlerts()).toHaveLength(1); +``` + +### Jest mocks + +Call `.mock()` to get an instance where every method is a `jest.fn()`. You can optionally provide partial implementations. This is useful when you want to assert that specific methods were called. + +```ts +import { mockApis } from '@backstage/frontend-test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; + +const permissionApi = mockApis.permission.mock({ + authorize: async () => ({ result: AuthorizeResult.ALLOW }), +}); + +// ... exercise the component ... + +expect(permissionApi.authorize).toHaveBeenCalledTimes(1); +``` + +## Providing mock APIs in tests + +### With `renderInTestApp` + +```tsx +import { screen } from '@testing-library/react'; +import { renderInTestApp, mockApis } from '@backstage/frontend-test-utils'; + +await renderInTestApp(, { + apis: [ + mockApis.identity({ userEntityRef: 'user:default/guest' }), + mockApis.config({ data: { app: { title: 'Test App' } } }), + ], +}); +``` + +You can also use the `[apiRef, implementation]` tuple syntax to provide any API implementation, including ones that aren't from `mockApis`: + +```tsx +import { myCustomApiRef } from '../apis'; + +const myCustomApiInstance = { + // ... +}; + +await renderInTestApp(, { + apis: [ + mockApis.identity({ userEntityRef: 'user:default/guest' }), + [myCustomApiRef, myCustomApiInstance], + ], +}); +``` + +### With `renderTestApp` + +The same `apis` option is available on `renderTestApp`, which is commonly used when testing extensions or entity pages: + +```tsx +import { renderTestApp, mockApis } from '@backstage/frontend-test-utils'; +import { createTestEntityPage } from '@backstage/plugin-catalog-react/testUtils'; + +renderTestApp({ + extensions: [createTestEntityPage({ entity }), myEntityCard], + apis: [mockApis.permission()], +}); +``` + +### With `TestApiProvider` + +For standalone rendering scenarios where you're not using `renderInTestApp`, the `TestApiProvider` component accepts the same `apis` format: + +```tsx +import { render } from '@testing-library/react'; +import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils'; + +render( + + + , +); +``` + +## Plugin-specific test mocks + +Plugins can provide their own mock APIs that follow the same pattern. For example, `@backstage/plugin-catalog-react` provides `catalogApiMock` in its `/testUtils` entry point: + +```tsx +import { renderTestApp } from '@backstage/frontend-test-utils'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; + +renderTestApp({ + extensions: [myExtension], + apis: [catalogApiMock({ entities: [entity] })], +}); +``` + +### Creating your own mock APIs + +If you maintain a plugin that exposes a utility API, you can provide mock utilities that follow the same function + `.mock()` pattern as the built-in `mockApis`. + +Use `attachMockApiFactory` for fake instances with real behavior, and `createApiMock` for the jest-mocked `.mock()` variant where all methods are `jest.fn()`. The full pattern looks like this: + +```ts +import { + attachMockApiFactory, + createApiMock, + type ApiMock, +} from '@backstage/frontend-test-utils'; +import { myApiRef, type MyApi } from '@internal/plugin-example-react'; + +// Fake instance with real behavior +export function myApiMock(options?: { greeting?: string }) { + return attachMockApiFactory(myApiRef, { + greet: async () => options?.greeting ?? 'Hello!', + }); +} + +// Jest mock variant where all methods are jest.fn() +export namespace myApiMock { + export const mock = createApiMock(myApiRef, () => ({ + greet: jest.fn(), + })); +} +``` + +Consumers can then use it just like the core mocks: + +```tsx +// Fake with real behavior +await renderInTestApp(, { + apis: [myApiMock({ greeting: 'Hi there!' })], +}); + +// Jest mock for assertions +const api = myApiMock.mock({ + greet: async () => 'mocked', +}); + +await renderInTestApp(, { + apis: [api], +}); + +expect(api.greet).toHaveBeenCalledTimes(1); +``` + +## Available mock APIs + +The table below lists all core APIs available through the `mockApis` namespace. + +| API | Fake instance | Notes | +| --------------------------------- | --------------------- | ---------------------------------------------------------------------- | +| `mockApis.alert()` | `MockAlertApi` | Collects alerts; has `getAlerts()`, `clearAlerts()`, `waitForAlert()` | +| `mockApis.analytics()` | `MockAnalyticsApi` | Collects events; has `getEvents()` | +| `mockApis.config({ data })` | `MockConfigApi` | Reads from a plain JSON object | +| `mockApis.discovery({ baseUrl })` | Inline | Returns `${baseUrl}/api/${pluginId}`, defaults to `http://example.com` | +| `mockApis.error(options?)` | `MockErrorApi` | Collects errors; has `getErrors()`, `waitForError()` | +| `mockApis.featureFlags(options?)` | `MockFeatureFlagsApi` | In-memory flag state; has `getState()`, `setState()`, `clearState()` | +| `mockApis.fetch(options?)` | `MockFetchApi` | Wraps native `fetch`; supports identity injection and plugin protocol | +| `mockApis.identity(options?)` | Inline | Configurable user ref, ownership, token, profile | +| `mockApis.permission(options?)` | `MockPermissionApi` | Defaults to `ALLOW`; accepts a handler function | +| `mockApis.storage({ data })` | `MockStorageApi` | In-memory storage with bucket support | +| `mockApis.translation()` | `MockTranslationApi` | Passthrough returning default messages from translation refs | + +Each of these also has a `.mock()` variant that returns jest mocks, as described above. diff --git a/docs/getting-started/homepage.md b/docs/getting-started/homepage.md index 7fac59f728..65b866414b 100644 --- a/docs/getting-started/homepage.md +++ b/docs/getting-started/homepage.md @@ -24,7 +24,83 @@ Before we begin, make sure Now, let's get started by installing the home plugin and creating a simple homepage for your Backstage app. -### Setup homepage +## Setup Methods + +There are two ways to set up the home plugin, depending on which frontend system your Backstage app uses: + +1. **New Frontend System (Recommended)** - For apps using the new plugin system with extensions and blueprints +2. **Legacy Frontend System** - For existing apps using the legacy plugin architecture + +### New Frontend System Setup + +If your Backstage app uses the [new frontend system](../frontend-system/index.md), follow these steps: + +#### 1. Install the plugin + +```bash title="From your Backstage root directory" +yarn --cwd packages/app add @backstage/plugin-home +``` + +#### 2. Add the plugin to your app configuration + +Update your `packages/app/src/app.tsx` to include the home plugin: + +```tsx title="packages/app/src/app.tsx" +import homePlugin from '@backstage/plugin-home/alpha'; + +const app = createApp({ + features: [ + // ... other plugins + homePlugin, + ], +}); +``` + +#### 3. Configure the homepage as your root route + +By default, the homepage will be available at `/home`. To make it your app's landing page at `/`, add this configuration to your `app-config.yaml`: + +```yaml title="app-config.yaml" +app: + extensions: + - page:home: + config: + path: / +``` + +The plugin will automatically add a "Home" navigation item to your sidebar and provide a basic homepage layout. + +#### 4. Optional: Enable visit tracking + +Visit tracking is an optional feature that allows users to see their recently visited and most visited pages on the homepage. This feature is **disabled by default** to give you control over what data is collected and stored. + +Visit tracking requires a storage implementation to persist user data: + +- **With UserSettings storage** (recommended): If you have the [UserSettings plugin](https://backstage.io/docs/features/software-catalog/external-integrations/#user-settings) configured with persistent storage, visit data will be stored there and synchronized across devices. +- **Fallback to local storage**: If no persistent storage is available, the plugin will automatically fall back to browser local storage, which stores data locally per device. + +To enable visit tracking, add this configuration to your `app-config.yaml`: + +```yaml title="app-config.yaml" +app: + extensions: + - api:home/visits: true + - app-root-element:home/visit-listener: true +``` + +#### 5. Customizing your homepage + +The New Frontend System provides powerful customization options: + +**Custom Homepage Layouts**: Use the `HomePageLayoutBlueprint` from `@backstage/plugin-home-react/alpha` to create custom homepage layouts with your own design and widget arrangements. A layout receives the installed widgets and is responsible for rendering them. If no custom layout is installed, the plugin provides a built-in default. + +**Adding Homepage Widgets**: Register custom widgets using the `HomePageWidgetBlueprint` from the `@backstage/plugin-home-react/alpha` package. + +For detailed instructions on creating custom layouts, registering widgets, and advanced configuration options, see the [Home plugin documentation](https://github.com/backstage/backstage/tree/master/plugins/home#readme). + +### Legacy Frontend System Setup + +If your Backstage app uses the legacy frontend system, follow these steps: #### 1. Install the plugin diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index b5955702a9..1e2da6ff2b 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -599,6 +599,7 @@ export default { 'frontend-system/utility-apis/creating', 'frontend-system/utility-apis/consuming', 'frontend-system/utility-apis/configuring', + 'frontend-system/utility-apis/testing', ], ), ], diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 411142e8d4..b974969f6c 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -60,6 +60,7 @@ "@backstage/plugin-catalog-unprocessed-entities": "workspace:^", "@backstage/plugin-devtools": "workspace:^", "@backstage/plugin-home": "workspace:^", + "@backstage/plugin-home-react": "workspace:^", "@backstage/plugin-kubernetes": "workspace:^", "@backstage/plugin-kubernetes-cluster": "workspace:^", "@backstage/plugin-notifications": "workspace:^", diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 3503805394..88de62663b 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -18,15 +18,21 @@ import { createApp } from '@backstage/frontend-defaults'; import { pagesPlugin } from './examples/pagesPlugin'; import notFoundErrorPage from './examples/notFoundErrorPageExtension'; import userSettingsPlugin from '@backstage/plugin-user-settings/alpha'; -import homePlugin, { - titleExtensionDataRef, -} from '@backstage/plugin-home/alpha'; +import homePlugin from '@backstage/plugin-home/alpha'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { - coreExtensionData, - createExtension, - createFrontendModule, -} from '@backstage/frontend-plugin-api'; + HomePageLayoutBlueprint, + type HomePageLayoutProps, +} from '@backstage/plugin-home-react/alpha'; +import { Fragment } from 'react'; +import { Content, Header, Page } from '@backstage/core-components'; +import { + CustomHomepageGrid, + WelcomeTitle, + HeaderWorldClock, + type ClockConfig, +} from '@backstage/plugin-home'; import { techdocsPlugin, TechDocsIndexPage, @@ -34,7 +40,6 @@ import { EntityTechdocsContent, } from '@backstage/plugin-techdocs'; import appVisualizerPlugin from '@backstage/plugin-app-visualizer'; -import { homePage } from './HomePage'; import { convertLegacyAppRoot } from '@backstage/core-compat-api'; import { FlatRoutes } from '@backstage/core-app-api'; import { Route } from 'react-router'; @@ -98,18 +103,37 @@ const convertedTechdocsPlugin = convertLegacyPlugin(techdocsPlugin, { ], }); +const clockConfigs: ClockConfig[] = [ + { label: 'NYC', timeZone: 'America/New_York' }, + { label: 'UTC', timeZone: 'UTC' }, + { label: 'STO', timeZone: 'Europe/Stockholm' }, + { label: 'TYO', timeZone: 'Asia/Tokyo' }, +]; + const customHomePageModule = createFrontendModule({ pluginId: 'home', extensions: [ - createExtension({ - name: 'my-home-page', - attachTo: { id: 'page:home', input: 'props' }, - output: [coreExtensionData.reactElement, titleExtensionDataRef], - factory() { - return [ - coreExtensionData.reactElement(homePage), - titleExtensionDataRef('just a title'), - ]; + HomePageLayoutBlueprint.make({ + params: { + loader: async () => + function CustomHomePageLayout({ widgets }: HomePageLayoutProps) { + return ( + +
} pageTitleOverride="Home"> + +
+ + + {widgets.map((widget, index) => ( + + {widget.component} + + ))} + + +
+ ); + }, }, }), ], diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 5dd541789c..65b06f59de 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -88,5 +88,13 @@ "@types/lodash": "^4.14.151", "@types/supertest": "^2.0.8", "supertest": "^7.0.0" + }, + "peerDependencies": { + "@types/jest": "*" + }, + "peerDependenciesMeta": { + "@types/jest": { + "optional": true + } } } diff --git a/packages/catalog-client/package.json b/packages/catalog-client/package.json index fefca00189..5eb70b725b 100644 --- a/packages/catalog-client/package.json +++ b/packages/catalog-client/package.json @@ -51,11 +51,13 @@ "@backstage/catalog-model": "workspace:^", "@backstage/errors": "workspace:^", "cross-fetch": "^4.0.0", + "lodash": "^4.17.21", "uri-template": "^2.0.0" }, "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", + "@types/lodash": "^4.14.151", "msw": "^1.0.0" } } diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts index dbe3390747..d867ab1d28 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts @@ -25,6 +25,12 @@ const entity1: Entity = { namespace: 'default', name: 'e1', uid: 'u1', + annotations: { 'backstage.io/orphan': 'true' }, + tags: ['java', 'spring'], + }, + spec: { + type: 'service', + lifecycle: 'production', }, relations: [{ type: 'relatedTo', targetRef: 'secondcustomkind:default/e2' }], }; @@ -36,140 +42,889 @@ const entity2: Entity = { namespace: 'default', name: 'e2', uid: 'u2', + tags: ['python'], + }, + spec: { + type: 'library', + lifecycle: 'experimental', }, }; -const entities = [entity1, entity2]; +const entity3: Entity = { + apiVersion: 'v1', + kind: 'CustomKind', + metadata: { + namespace: 'other', + name: 'e3', + uid: 'u3', + tags: ['java', 'quarkus'], + }, + spec: { + type: 'service', + lifecycle: 'production', + }, +}; + +const entity4: Entity = { + apiVersion: 'v1', + kind: 'SecondCustomKind', + metadata: { + namespace: 'default', + name: 'e4', + uid: 'u4', + }, + spec: { + type: 'website', + }, +}; + +const entities = [entity1, entity2, entity3, entity4]; describe('InMemoryCatalogClient', () => { - it('getEntities', async () => { - const client = new InMemoryCatalogClient({ entities }); + describe('getEntities', () => { + it('returns all entities without a filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect(client.getEntities()).resolves.toEqual({ items: entities }); + }); - await expect(client.getEntities()).resolves.toEqual({ items: entities }); + it('filters by metadata.name case-insensitively', async () => { + const client = new InMemoryCatalogClient({ entities }); - await expect( - client.getEntities({ filter: { 'metadata.name': 'E1' } }), - ).resolves.toEqual({ items: [entity1] }); + await expect( + client.getEntities({ filter: { 'metadata.name': 'E1' } }), + ).resolves.toEqual({ items: [entity1] }); - await expect( - client.getEntities({ filter: { 'metadata.uid': 'u2' } }), - ).resolves.toEqual({ items: [entity2] }); + await expect( + client.getEntities({ filter: { 'metadata.uid': 'u2' } }), + ).resolves.toEqual({ items: [entity2] }); - await expect( - client.getEntities({ filter: { 'metadata.uid': 'U2' } }), - ).resolves.toEqual({ items: [entity2] }); + await expect( + client.getEntities({ filter: { 'metadata.uid': 'U2' } }), + ).resolves.toEqual({ items: [entity2] }); + }); - await expect( - client.getEntities({ - filter: { 'relations.relatedto': CATALOG_FILTER_EXISTS }, - }), - ).resolves.toEqual({ items: [entity1] }); + it('supports CATALOG_FILTER_EXISTS', async () => { + const client = new InMemoryCatalogClient({ entities }); - await expect( - client.getEntities({ - filter: { 'relations.relatedTo': 'secondcustomkind:default/e2' }, - }), - ).resolves.toEqual({ items: [entity1] }); + await expect( + client.getEntities({ + filter: { 'relations.relatedto': CATALOG_FILTER_EXISTS }, + }), + ).resolves.toEqual({ items: [entity1] }); + }); - await expect( - client.getEntities({ - filter: { kind: ['not-existing', 'CustomKind'] }, - }), - ).resolves.toEqual({ items: [entity1] }); + it('supports filtering by relation value', async () => { + const client = new InMemoryCatalogClient({ entities }); - await expect( - client.getEntities({ - filter: { kind: ['SecondCustomKind', 'also-not-existing'] }, - }), - ).resolves.toEqual({ items: [entity2] }); + await expect( + client.getEntities({ + filter: { 'relations.relatedTo': 'secondcustomkind:default/e2' }, + }), + ).resolves.toEqual({ items: [entity1] }); + }); + + it('supports array filter values as OR', async () => { + const client = new InMemoryCatalogClient({ entities }); + + await expect( + client.getEntities({ + filter: { kind: ['not-existing', 'CustomKind'] }, + }), + ).resolves.toEqual({ items: [entity1, entity3] }); + + await expect( + client.getEntities({ + filter: { kind: ['SecondCustomKind', 'also-not-existing'] }, + }), + ).resolves.toEqual({ items: [entity2, entity4] }); + }); + + it('supports multiple filter sets as OR', async () => { + const client = new InMemoryCatalogClient({ entities }); + + await expect( + client.getEntities({ + filter: [{ 'metadata.name': 'e1' }, { 'metadata.name': 'e3' }], + }), + ).resolves.toEqual({ items: [entity1, entity3] }); + }); + + it('orders results ascending', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'asc' }, + }); + expect(result.items.map(e => e.metadata.name)).toEqual([ + 'e1', + 'e2', + 'e3', + 'e4', + ]); + }); + + it('orders results descending', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'desc' }, + }); + expect(result.items.map(e => e.metadata.name)).toEqual([ + 'e4', + 'e3', + 'e2', + 'e1', + ]); + }); + + it('supports multi-level ordering', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: [ + { field: 'kind', order: 'asc' }, + { field: 'metadata.name', order: 'desc' }, + ], + }); + expect(result.items.map(e => e.metadata.name)).toEqual([ + 'e3', + 'e1', + 'e4', + 'e2', + ]); + }); + + it('sorts entities with missing order fields last regardless of direction', async () => { + const client = new InMemoryCatalogClient({ entities }); + + // entity4 has no lifecycle field - should be last in ascending + const asc = await client.getEntities({ + order: { field: 'spec.lifecycle', order: 'asc' }, + }); + expect(asc.items[asc.items.length - 1].metadata.name).toBe('e4'); + + // entity4 should also be last in descending + const desc = await client.getEntities({ + order: { field: 'spec.lifecycle', order: 'desc' }, + }); + expect(desc.items[desc.items.length - 1].metadata.name).toBe('e4'); + }); + + it('uses entity ref as stable tie-breaker when sort values are equal', async () => { + // entity1 (customkind:default/e1) and entity3 (customkind:other/e3) are both CustomKind + // entity2 (secondcustomkind:default/e2) and entity4 (secondcustomkind:default/e4) are both SecondCustomKind + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'kind', order: 'asc' }, + }); + // Within same kind, should be sorted by entity ref + expect(result.items.map(e => e.metadata.name)).toEqual([ + 'e1', + 'e3', + 'e2', + 'e4', + ]); + }); + + it('applies offset and limit', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'asc' }, + offset: 1, + limit: 2, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e2', 'e3']); + }); + + it('applies limit only', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'asc' }, + limit: 2, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e1', 'e2']); + }); + + it('applies offset only', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'asc' }, + offset: 2, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e3', 'e4']); + }); + + it('supports the after cursor for pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + + const cursor = Buffer.from( + JSON.stringify({ offset: 2, limit: 1 }), + ).toString('base64'); + + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'asc' }, + after: cursor, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e3']); + }); + + it('applies field projection', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + filter: { 'metadata.name': 'e1' }, + fields: ['kind', 'metadata.name'], + }); + expect(result.items).toEqual([ + { kind: 'CustomKind', metadata: { name: 'e1' } }, + ]); + }); + + it('applies field projection for nested paths', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + filter: { 'metadata.name': 'e1' }, + fields: ['metadata'], + }); + expect(result.items).toEqual([{ metadata: entity1.metadata }]); + }); + + it('handles dotted annotation keys in field projection', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + filter: { 'metadata.name': 'e1' }, + fields: ['metadata.annotations.backstage.io/orphan'], + }); + expect(result.items).toEqual([ + { + metadata: { + annotations: { 'backstage.io/orphan': 'true' }, + }, + }, + ]); + }); + + it('silently skips fields that do not exist on entities', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + filter: { 'metadata.name': 'e1' }, + fields: ['kind', 'spec.nonexistent', 'metadata.name'], + }); + expect(result.items).toEqual([ + { kind: 'CustomKind', metadata: { name: 'e1' } }, + ]); + }); + + it('applies field projection with ordering and pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntities({ + order: { field: 'metadata.name', order: 'desc' }, + offset: 1, + limit: 2, + fields: ['metadata.name'], + }); + expect(result.items).toEqual([ + { metadata: { name: 'e3' } }, + { metadata: { name: 'e2' } }, + ]); + }); }); - it('getEntitiesByRefs', async () => { - const client = new InMemoryCatalogClient({ entities }); - await expect( - client.getEntitiesByRefs({ + describe('getEntitiesByRefs', () => { + it('returns entities in the same order as refs', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.getEntitiesByRefs({ + entityRefs: [ + 'secondcustomkind:default/e2', + 'customkind:missing/missing', + 'customkind:default/e1', + ], + }), + ).resolves.toEqual({ items: [entity2, undefined, entity1] }); + }); + + it('supports additional filter on refs', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.getEntitiesByRefs({ + entityRefs: [ + 'secondcustomkind:default/e2', + 'customkind:missing/missing', + 'customkind:default/e1', + ], + filter: { 'metadata.uid': 'u1' }, + }), + ).resolves.toEqual({ items: [undefined, undefined, entity1] }); + }); + + it('applies field projection', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntitiesByRefs({ entityRefs: [ 'secondcustomkind:default/e2', 'customkind:missing/missing', 'customkind:default/e1', ], - }), - ).resolves.toEqual({ items: [entity2, undefined, entity1] }); - await expect( - client.getEntitiesByRefs({ - entityRefs: [ - 'secondcustomkind:default/e2', - 'customkind:missing/missing', - 'customkind:default/e1', + fields: ['kind', 'metadata.name'], + }); + expect(result.items).toEqual([ + { kind: 'SecondCustomKind', metadata: { name: 'e2' } }, + undefined, + { kind: 'CustomKind', metadata: { name: 'e1' } }, + ]); + }); + }); + + describe('queryEntities', () => { + it('returns all entities without parameters', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect(client.queryEntities()).resolves.toEqual({ + items: entities, + totalItems: 4, + pageInfo: {}, + }); + }); + + it('filters by entity filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.queryEntities({ filter: { 'metadata.uid': 'u2' } }), + ).resolves.toEqual({ + items: [entity2], + totalItems: 1, + pageInfo: {}, + }); + }); + + it('supports full-text search on the sort field by default', async () => { + const client = new InMemoryCatalogClient({ entities }); + // When no fullTextFilter.fields are given, the backend defaults to the + // sort field. Here we sort by spec.type and search for 'service'. + const result = await client.queryEntities({ + fullTextFilter: { term: 'service' }, + orderFields: { field: 'spec.type', order: 'asc' }, + }); + expect(result.items).toEqual([entity1, entity3]); + expect(result.totalItems).toBe(2); + }); + + it('defaults full-text search to metadata.uid when no sort field', async () => { + const client = new InMemoryCatalogClient({ entities }); + // Without orderFields, defaults to searching metadata.uid + const result = await client.queryEntities({ + fullTextFilter: { term: 'u1' }, + }); + expect(result.items).toEqual([entity1]); + }); + + it('supports full-text search limited to specific fields', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + fullTextFilter: { term: 'e1', fields: ['metadata.name'] }, + }); + expect(result.items).toEqual([entity1]); + }); + + it('supports full-text search across multiple explicit fields', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + fullTextFilter: { + term: 'e', + fields: ['metadata.name', 'metadata.uid'], + }, + orderFields: { field: 'metadata.name', order: 'asc' }, + }); + // All entities have 'e' in their name + expect(result.items).toEqual( + expect.arrayContaining([entity1, entity2, entity3, entity4]), + ); + }); + + it('supports case-insensitive full-text search', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + fullTextFilter: { term: 'SERVICE', fields: ['spec.type'] }, + }); + expect(result.items).toEqual([entity1, entity3]); + }); + + it('trims whitespace from full-text search term', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + fullTextFilter: { term: ' service ', fields: ['spec.type'] }, + }); + expect(result.items).toEqual([entity1, entity3]); + }); + + it('ignores empty full-text search term', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + fullTextFilter: { term: ' ' }, + }); + expect(result.items).toEqual(entities); + }); + + it('orders results by orderFields', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'desc' }, + }); + expect(result.items.map(e => e.metadata.name)).toEqual([ + 'e4', + 'e3', + 'e2', + 'e1', + ]); + }); + + it('supports multi-level ordering', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + orderFields: [ + { field: 'spec.type', order: 'asc' }, + { field: 'metadata.name', order: 'asc' }, ], - filter: { 'metadata.uid': 'u1' }, - }), - ).resolves.toEqual({ items: [undefined, undefined, entity1] }); - }); - - it('queryEntities', async () => { - const client = new InMemoryCatalogClient({ entities }); - await expect(client.queryEntities()).resolves.toEqual({ - items: entities, - totalItems: 2, - pageInfo: {}, + }); + expect(result.items.map(e => e.metadata.name)).toEqual([ + 'e2', + 'e1', + 'e3', + 'e4', + ]); }); - await expect( - client.queryEntities({ filter: { 'metadata.uid': 'u2' } }), - ).resolves.toEqual({ - items: [entity2], - totalItems: 1, - pageInfo: {}, + + it('uses entity ref as stable tie-breaker for equal sort values', async () => { + // entity1 and entity3 both have spec.type = 'service' + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + filter: { 'spec.type': 'service' }, + orderFields: { field: 'spec.type', order: 'asc' }, + }); + // customkind:default/e1 < customkind:other/e3 lexicographically + expect(result.items.map(e => e.metadata.name)).toEqual(['e1', 'e3']); + }); + + it('sorts entities with missing order fields last for both directions', async () => { + const client = new InMemoryCatalogClient({ entities }); + + const asc = await client.queryEntities({ + orderFields: { field: 'spec.lifecycle', order: 'asc' }, + }); + expect(asc.items[asc.items.length - 1].metadata.name).toBe('e4'); + + const desc = await client.queryEntities({ + orderFields: { field: 'spec.lifecycle', order: 'desc' }, + }); + expect(desc.items[desc.items.length - 1].metadata.name).toBe('e4'); + }); + + it('returns paginated results with limit', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 2, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e1', 'e2']); + expect(result.totalItems).toBe(4); + expect(result.pageInfo.nextCursor).toBeDefined(); + expect(result.pageInfo.prevCursor).toBeUndefined(); + }); + + it('returns paginated results with offset and limit', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'asc' }, + offset: 1, + limit: 2, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e2', 'e3']); + expect(result.totalItems).toBe(4); + expect(result.pageInfo.nextCursor).toBeDefined(); + expect(result.pageInfo.prevCursor).toBeDefined(); + }); + + it('paginates forward through all pages using cursors', async () => { + const client = new InMemoryCatalogClient({ entities }); + const allNames: string[] = []; + + const page1 = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 2, + }); + allNames.push(...page1.items.map(e => e.metadata.name)); + expect(page1.pageInfo.nextCursor).toBeDefined(); + expect(page1.pageInfo.prevCursor).toBeUndefined(); + + const page2 = await client.queryEntities({ + cursor: page1.pageInfo.nextCursor!, + limit: 2, + }); + allNames.push(...page2.items.map(e => e.metadata.name)); + expect(page2.pageInfo.nextCursor).toBeUndefined(); + expect(page2.pageInfo.prevCursor).toBeDefined(); + + expect(allNames).toEqual(['e1', 'e2', 'e3', 'e4']); + expect(page1.totalItems).toBe(4); + expect(page2.totalItems).toBe(4); + }); + + it('paginates backward using prevCursor', async () => { + const client = new InMemoryCatalogClient({ entities }); + + const page1 = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'asc' }, + offset: 2, + limit: 2, + }); + expect(page1.items.map(e => e.metadata.name)).toEqual(['e3', 'e4']); + expect(page1.pageInfo.prevCursor).toBeDefined(); + + const page0 = await client.queryEntities({ + cursor: page1.pageInfo.prevCursor!, + limit: 2, + }); + expect(page0.items.map(e => e.metadata.name)).toEqual(['e1', 'e2']); + expect(page0.pageInfo.prevCursor).toBeUndefined(); + }); + + it('handles page size of 1 for fine-grained pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const allNames: string[] = []; + let cursor: string | undefined; + + const first = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 1, + }); + allNames.push(...first.items.map(e => e.metadata.name)); + cursor = first.pageInfo.nextCursor; + + while (cursor) { + const page = await client.queryEntities({ cursor, limit: 1 }); + allNames.push(...page.items.map(e => e.metadata.name)); + cursor = page.pageInfo.nextCursor; + } + + expect(allNames).toEqual(['e1', 'e2', 'e3', 'e4']); + }); + + it('produces stable pagination with clashing sort values', async () => { + // entity1 and entity3 both have spec.type = 'service' + // entity2 has spec.type = 'library', entity4 has spec.type = 'website' + const client = new InMemoryCatalogClient({ entities }); + const allNames: string[] = []; + let cursor: string | undefined; + + const first = await client.queryEntities({ + orderFields: { field: 'spec.type', order: 'asc' }, + limit: 1, + }); + allNames.push(...first.items.map(e => e.metadata.name)); + cursor = first.pageInfo.nextCursor; + + while (cursor) { + const page = await client.queryEntities({ cursor, limit: 1 }); + allNames.push(...page.items.map(e => e.metadata.name)); + cursor = page.pageInfo.nextCursor; + } + + // library < service < website, with tie-break by entity ref + expect(allNames).toEqual(['e2', 'e1', 'e3', 'e4']); + }); + + it('combines filter, full-text search, and ordering with pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + filter: { kind: 'CustomKind' }, + fullTextFilter: { term: 'java', fields: ['metadata.tags'] }, + orderFields: { field: 'metadata.name', order: 'desc' }, + limit: 1, + }); + expect(result.items.map(e => e.metadata.name)).toEqual(['e3']); + expect(result.totalItems).toBe(2); + expect(result.pageInfo.nextCursor).toBeDefined(); + + const page2 = await client.queryEntities({ + cursor: result.pageInfo.nextCursor!, + limit: 1, + }); + expect(page2.items.map(e => e.metadata.name)).toEqual(['e1']); + expect(page2.pageInfo.nextCursor).toBeUndefined(); + }); + + it('applies field projection', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + filter: { 'metadata.name': 'e1' }, + fields: ['kind', 'metadata.name'], + }); + expect(result.items).toEqual([ + { kind: 'CustomKind', metadata: { name: 'e1' } }, + ]); + }); + + it('applies field projection with cursor pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const page1 = await client.queryEntities({ + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 2, + fields: ['metadata.name'], + }); + expect(page1.items).toEqual([ + { metadata: { name: 'e1' } }, + { metadata: { name: 'e2' } }, + ]); + + const page2 = await client.queryEntities({ + cursor: page1.pageInfo.nextCursor!, + limit: 2, + fields: ['metadata.name'], + }); + expect(page2.items).toEqual([ + { metadata: { name: 'e3' } }, + { metadata: { name: 'e4' } }, + ]); + }); + + it('throws InputError for invalid cursor', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.queryEntities({ cursor: 'not-valid-base64!' }), + ).rejects.toThrow('Invalid cursor'); }); }); - it('streamEntities', async () => { - const client = new InMemoryCatalogClient({ entities }); - const stream = client.streamEntities(); - const results: Entity[][] = []; - for await (const page of stream) { - results.push(page); - } - expect(results).toEqual([entities]); - }); + describe('streamEntities', () => { + it('streams all entities', async () => { + const client = new InMemoryCatalogClient({ entities }); + const stream = client.streamEntities(); + const results: Entity[][] = []; + for await (const page of stream) { + results.push(page); + } + expect(results).toEqual([entities]); + }); - it('getEntityAncestors', async () => { - const client = new InMemoryCatalogClient({ entities }); - await expect( - client.getEntityAncestors({ entityRef: 'secondcustomkind:default/e2' }), - ).resolves.toEqual({ - rootEntityRef: 'secondcustomkind:default/e2', - items: [{ entity: entity2, parentEntityRefs: [] }], + it('streams with filtering and ordering', async () => { + const client = new InMemoryCatalogClient({ entities }); + const stream = client.streamEntities({ + filter: { kind: 'CustomKind' }, + orderFields: { field: 'metadata.name', order: 'desc' }, + }); + const results: Entity[][] = []; + for await (const page of stream) { + results.push(page); + } + expect(results.flat().map(e => e.metadata.name)).toEqual(['e3', 'e1']); + }); + + it('streams in pages based on pageSize', async () => { + const client = new InMemoryCatalogClient({ entities }); + const stream = client.streamEntities({ pageSize: 2 }); + const results: Entity[][] = []; + for await (const page of stream) { + results.push(page); + } + expect(results).toHaveLength(2); + expect(results[0]).toHaveLength(2); + expect(results[1]).toHaveLength(2); + expect(results.flat()).toEqual(entities); + }); + + it('streams with full-text filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + const stream = client.streamEntities({ + fullTextFilter: { term: 'library', fields: ['spec.type'] }, + }); + const results: Entity[][] = []; + for await (const page of stream) { + results.push(page); + } + expect(results.flat()).toEqual([entity2]); }); }); - it('getEntityByRef', async () => { - const client = new InMemoryCatalogClient({ entities }); - await expect( - client.getEntityByRef('secondcustomkind:default/e2'), - ).resolves.toEqual(entity2); - await expect( - client.getEntityByRef('customkind:missing/missing'), - ).resolves.toBeUndefined(); - }); - - it('removeEntityByUid', async () => { - const client = new InMemoryCatalogClient({ entities }); - await expect(client.getEntities()).resolves.toEqual({ - items: expect.arrayContaining([entity2]), + describe('getEntityAncestors', () => { + it('returns the entity with empty parent refs', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.getEntityAncestors({ + entityRef: 'secondcustomkind:default/e2', + }), + ).resolves.toEqual({ + rootEntityRef: 'secondcustomkind:default/e2', + items: [{ entity: entity2, parentEntityRefs: [] }], + }); }); - await expect( - client.removeEntityByUid(entity2.metadata.uid!), - ).resolves.toBeUndefined(); - await expect(client.getEntities()).resolves.not.toEqual({ - items: expect.arrayContaining([entity2]), + + it('throws NotFoundError for missing entity', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.getEntityAncestors({ entityRef: 'customkind:default/missing' }), + ).rejects.toThrow('not found'); }); }); - it('refreshEntity', async () => { - const client = new InMemoryCatalogClient({ entities }); - await expect( - client.refreshEntity('secondcustomkind:default/e2'), - ).resolves.toBeUndefined(); + describe('getEntityByRef', () => { + it('returns entity by ref string', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.getEntityByRef('secondcustomkind:default/e2'), + ).resolves.toEqual(entity2); + }); + + it('returns undefined for missing entity', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.getEntityByRef('customkind:missing/missing'), + ).resolves.toBeUndefined(); + }); + }); + + describe('removeEntityByUid', () => { + it('removes an entity by uid', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect(client.getEntities()).resolves.toEqual({ + items: expect.arrayContaining([entity2]), + }); + await expect( + client.removeEntityByUid(entity2.metadata.uid!), + ).resolves.toBeUndefined(); + await expect(client.getEntities()).resolves.not.toEqual({ + items: expect.arrayContaining([entity2]), + }); + }); + + it('is a no-op for non-existent uid', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.removeEntityByUid('nonexistent'), + ).resolves.toBeUndefined(); + const result = await client.getEntities(); + expect(result.items).toHaveLength(4); + }); + }); + + describe('refreshEntity', () => { + it('is a no-op', async () => { + const client = new InMemoryCatalogClient({ entities }); + await expect( + client.refreshEntity('secondcustomkind:default/e2'), + ).resolves.toBeUndefined(); + }); + }); + + describe('getEntityFacets', () => { + it('returns facet counts for a simple field', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ facets: ['kind'] }); + expect(result.facets.kind).toEqual( + expect.arrayContaining([ + { value: 'CustomKind', count: 2 }, + { value: 'SecondCustomKind', count: 2 }, + ]), + ); + }); + + it('returns facet counts with filter', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['spec.type'], + filter: { kind: 'CustomKind' }, + }); + expect(result.facets['spec.type']).toEqual([ + { value: 'service', count: 2 }, + ]); + }); + + it('handles multi-valued fields correctly', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['metadata.tags'], + }); + // entity1 has ['java', 'spring'], entity2 has ['python'], entity3 has ['java', 'quarkus'] + // entity4 has no tags + expect(result.facets['metadata.tags']).toEqual( + expect.arrayContaining([ + { value: 'java', count: 2 }, + { value: 'spring', count: 1 }, + { value: 'python', count: 1 }, + { value: 'quarkus', count: 1 }, + ]), + ); + }); + + it('counts each distinct value once per entity', async () => { + // Entity with duplicate tag values in different forms - the search index + // might produce duplicates due to traverse behavior. Using a Set ensures + // each entity only contributes 1 to each facet value's count. + const dupEntity: Entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + namespace: 'default', + name: 'dup', + tags: ['java', 'java'], + }, + }; + const client = new InMemoryCatalogClient({ entities: [dupEntity] }); + const result = await client.getEntityFacets({ + facets: ['metadata.tags'], + }); + const javaFacet = result.facets['metadata.tags'].find( + f => f.value === 'java', + ); + // Should count as 1 per entity, not 2 for duplicate values + expect(javaFacet?.count).toBe(1); + }); + + it('returns multiple facets at once', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['kind', 'spec.type'], + }); + expect(Object.keys(result.facets)).toEqual(['kind', 'spec.type']); + expect(result.facets.kind).toHaveLength(2); + expect(result.facets['spec.type']).toEqual( + expect.arrayContaining([ + { value: 'service', count: 2 }, + { value: 'library', count: 1 }, + { value: 'website', count: 1 }, + ]), + ); + }); + + it('returns empty array for facets with no matching values', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.getEntityFacets({ + facets: ['spec.nonexistent'], + }); + expect(result.facets['spec.nonexistent']).toEqual([]); + }); + }); + + describe('not implemented methods', () => { + it('throws NotImplementedError for location and validation methods', async () => { + const client = new InMemoryCatalogClient(); + await expect(client.getLocations()).rejects.toThrow('not implemented'); + await expect(client.getLocationById('id')).rejects.toThrow( + 'not implemented', + ); + await expect(client.getLocationByRef('ref')).rejects.toThrow( + 'not implemented', + ); + await expect(client.addLocation({ target: 'test' })).rejects.toThrow( + 'not implemented', + ); + await expect(client.removeLocationById('id')).rejects.toThrow( + 'not implemented', + ); + await expect(client.getLocationByEntity('ref')).rejects.toThrow( + 'not implemented', + ); + await expect(client.validateEntity(entity1, 'url:test')).rejects.toThrow( + 'not implemented', + ); + await expect( + client.analyzeLocation({ location: { type: 'url', target: 'test' } }), + ).rejects.toThrow('not implemented'); + }); }); }); diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index a9db99ab77..a769f1996d 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -20,6 +20,7 @@ import { CATALOG_FILTER_EXISTS, CatalogApi, EntityFilterQuery, + EntityOrderQuery, GetEntitiesByRefsRequest, GetEntitiesByRefsResponse, GetEntitiesRequest, @@ -42,7 +43,12 @@ import { parseEntityRef, stringifyEntityRef, } from '@backstage/catalog-model'; -import { NotFoundError, NotImplementedError } from '@backstage/errors'; +import { + InputError, + NotFoundError, + NotImplementedError, +} from '@backstage/errors'; +import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; import type { @@ -51,6 +57,62 @@ import type { } from '@backstage/plugin-catalog-common'; import { DEFAULT_STREAM_ENTITIES_LIMIT } from '../constants.ts'; +function makeCursor(data: Record): string { + return btoa(JSON.stringify(data)); +} + +function parseCursor(cursor: string): Record { + return JSON.parse(atob(cursor)); +} + +// CATALOG_FILTER_EXISTS is a Symbol that doesn't survive JSON serialization, +// so we swap it with a sentinel string when encoding/decoding cursors. +const FILTER_EXISTS_SENTINEL = '\0CATALOG_FILTER_EXISTS'; + +function serializeFilterValue(v: unknown): unknown { + if (v === CATALOG_FILTER_EXISTS) return FILTER_EXISTS_SENTINEL; + if (Array.isArray(v)) { + return v.map(x => + x === CATALOG_FILTER_EXISTS ? FILTER_EXISTS_SENTINEL : x, + ); + } + return v; +} + +function deserializeFilterValue(v: unknown): unknown { + if (v === FILTER_EXISTS_SENTINEL) return CATALOG_FILTER_EXISTS; + if (Array.isArray(v)) { + return v.map(x => + x === FILTER_EXISTS_SENTINEL ? CATALOG_FILTER_EXISTS : x, + ); + } + return v; +} + +function serializeFilter(filter?: EntityFilterQuery): any[] | undefined { + if (!filter) return undefined; + return [filter] + .flat() + .map(f => + Object.fromEntries( + Object.entries(f).map(([k, v]) => [k, serializeFilterValue(v)]), + ), + ); +} + +function deserializeFilter(filter?: any[]): EntityFilterQuery | undefined { + if (!filter) return undefined; + return filter.map( + f => + Object.fromEntries( + Object.entries(f).map(([k, v]: [string, any]) => [ + k, + deserializeFilterValue(v), + ]), + ) as Record, + ); +} + function buildEntitySearch(entity: Entity) { const rows = traverse(entity); @@ -130,10 +192,120 @@ function createFilter( }; } +// Resolves a dot-separated field path against an entity, handling keys that +// themselves contain dots (e.g. annotation keys like "backstage.io/orphan"). +// This matches the backend's parseEntityTransformParams implementation. +function getPathArrayAndValue( + input: Entity, + field: string, +): [string[], unknown] { + return field.split('.').reduce( + ([pathArray, inputSubset], pathPart, index, fieldParts) => { + if (lodash.hasIn(inputSubset, pathPart)) { + return [pathArray.concat(pathPart), (inputSubset as any)[pathPart]]; + } else if (fieldParts[index + 1] !== undefined) { + fieldParts[index + 1] = `${pathPart}.${fieldParts[index + 1]}`; + return [pathArray, inputSubset]; + } + return [pathArray, undefined]; + }, + [[] as string[], input as unknown], + ); +} + +function applyFieldsFilter(entity: Entity, fields?: string[]): Entity { + if (!fields?.length) { + return entity; + } + const output: Record = {}; + for (const field of fields) { + const [pathArray, value] = getPathArrayAndValue(entity, field); + if (value !== undefined) { + lodash.set(output, pathArray, value); + } + } + return output as Entity; +} + +function applyOrdering(entities: Entity[], order?: EntityOrderQuery): Entity[] { + if (!order) { + return entities; + } + const orders = [order].flat(); + if (orders.length === 0) { + return entities; + } + + const searchMap = new Map>(); + for (const entity of entities) { + searchMap.set(entity, buildEntitySearch(entity)); + } + + return [...entities].sort((a, b) => { + const aRows = searchMap.get(a)!; + const bRows = searchMap.get(b)!; + + for (const { field, order: dir } of orders) { + const key = field.toLocaleLowerCase('en-US'); + const aRow = aRows.find(r => r.key.toLocaleLowerCase('en-US') === key); + const bRow = bRows.find(r => r.key.toLocaleLowerCase('en-US') === key); + const aValue = + aRow?.value !== null && aRow?.value !== undefined + ? String(aRow.value).toLocaleLowerCase('en-US') + : null; + const bValue = + bRow?.value !== null && bRow?.value !== undefined + ? String(bRow.value).toLocaleLowerCase('en-US') + : null; + + if (aValue === null && bValue === null) continue; + if (aValue === null) return 1; + if (bValue === null) return -1; + + if (aValue < bValue) return dir === 'asc' ? -1 : 1; + if (aValue > bValue) return dir === 'asc' ? 1 : -1; + } + + // Stable tie-breaker by entity ref, matching backend behavior + const aRef = stringifyEntityRef(a); + const bRef = stringifyEntityRef(b); + if (aRef < bRef) return -1; + if (aRef > bRef) return 1; + return 0; + }); +} + +function applyFullTextFilter( + entities: Entity[], + fullTextFilter?: { term: string; fields?: string[] }, +): Entity[] { + if (!fullTextFilter?.term?.trim()) { + return entities; + } + const term = fullTextFilter.term.trim().toLocaleLowerCase('en-US'); + const fields = fullTextFilter.fields?.map(f => f.toLocaleLowerCase('en-US')); + + return entities.filter(entity => { + const rows = buildEntitySearch(entity); + return rows.some(row => { + if ( + fields?.length && + !fields.includes(row.key.toLocaleLowerCase('en-US')) + ) { + return false; + } + if (row.value === null || row.value === undefined) return false; + const value = String(row.value).toLocaleLowerCase('en-US'); + return value.includes(term); + }); + }); +} + /** - * Implements a VERY basic fake catalog client that stores entities in memory. - * It has severely limited functionality, and is only useful under certain - * circumstances in tests. + * Implements a fake catalog client that stores entities in memory. + * Supports filtering, ordering, pagination, full-text search, and field + * projection for entity query methods. Location and validation methods + * throw {@link @backstage/errors#NotImplementedError}. * * @public */ @@ -148,7 +320,34 @@ export class InMemoryCatalogClient implements CatalogApi { request?: GetEntitiesRequest, ): Promise { const filter = createFilter(request?.filter); - return { items: this.#entities.filter(filter) }; + let items = this.#entities.filter(filter); + items = applyOrdering(items, request?.order); + + let offset = request?.offset ?? 0; + let limit = request?.limit; + + if (request?.after) { + try { + const cursor = parseCursor(request.after); + if (cursor.offset !== undefined) offset = cursor.offset as number; + if (cursor.limit !== undefined) limit = cursor.limit as number; + } catch { + throw new InputError('Invalid cursor'); + } + } + + if (offset > 0) { + items = items.slice(offset); + } + if (limit !== undefined) { + items = items.slice(0, limit); + } + + return { + items: request?.fields + ? items.map(e => applyFieldsFilter(e, request.fields)) + : items, + }; } async getEntitiesByRefs( @@ -156,26 +355,104 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { const filter = createFilter(request.filter); const refMap = this.#createEntityRefMap(); + const items = request.entityRefs + .map(ref => refMap.get(ref)) + .map(e => (e && filter(e) ? e : undefined)); return { - items: request.entityRefs - .map(ref => refMap.get(ref)) - .map(e => (e && filter(e) ? e : undefined)), + items: request.fields + ? items.map(e => (e ? applyFieldsFilter(e, request.fields) : undefined)) + : items, }; } async queryEntities( request?: QueryEntitiesRequest, ): Promise { + // Decode query parameters from cursor or from the request directly + let filter: EntityFilterQuery | undefined; + let orderFields: EntityOrderQuery | undefined; + let fullTextFilter: { term: string; fields?: string[] } | undefined; + let offset: number; + let limit: number | undefined; + if (request && 'cursor' in request) { - return { items: [], pageInfo: {}, totalItems: 0 }; + let c: Record; + try { + c = parseCursor(request.cursor); + } catch { + throw new InputError('Invalid cursor'); + } + filter = deserializeFilter(c.filter as any[]); + orderFields = c.orderFields as EntityOrderQuery | undefined; + fullTextFilter = c.fullTextFilter as typeof fullTextFilter; + offset = c.offset as number; + limit = request.limit; + } else { + filter = request?.filter; + orderFields = request?.orderFields; + fullTextFilter = request?.fullTextFilter; + offset = request?.offset ?? 0; + limit = request?.limit; } - const filter = createFilter(request?.filter); - const items = this.#entities.filter(filter); - // TODO(Rugvip): Pagination + + // Apply filter + let items = this.#entities.filter(createFilter(filter)); + + // Apply full-text filter, defaulting to the sort field or metadata.uid + if (fullTextFilter) { + const orderFieldsList = orderFields ? [orderFields].flat() : []; + items = applyFullTextFilter(items, { + ...fullTextFilter, + fields: fullTextFilter.fields ?? [ + orderFieldsList[0]?.field ?? 'metadata.uid', + ], + }); + } + + items = applyOrdering(items, orderFields); + + const totalItems = items.length; + + // No pagination requested, return all items + if (limit === undefined && offset === 0) { + return { + items: request?.fields + ? items.map(e => applyFieldsFilter(e, request?.fields)) + : items, + pageInfo: {}, + totalItems, + }; + } + + const effectiveLimit = limit ?? totalItems; + const pageItems = items.slice(offset, offset + effectiveLimit); + + const cursorBase = { + filter: serializeFilter(filter), + orderFields, + fullTextFilter, + totalItems, + }; + const pageInfo: QueryEntitiesResponse['pageInfo'] = {}; + if (offset + effectiveLimit < totalItems) { + pageInfo.nextCursor = makeCursor({ + ...cursorBase, + offset: offset + effectiveLimit, + }); + } + if (offset > 0) { + pageInfo.prevCursor = makeCursor({ + ...cursorBase, + offset: Math.max(0, offset - effectiveLimit), + }); + } + return { - items, - pageInfo: {}, - totalItems: items.length, + items: request?.fields + ? pageItems.map(e => applyFieldsFilter(e, request?.fields)) + : pageItems, + totalItems, + pageInfo, }; } @@ -219,14 +496,21 @@ export class InMemoryCatalogClient implements CatalogApi { const facetValues = new Map(); for (const entity of filteredEntities) { const rows = buildEntitySearch(entity); - const value = rows.find( - row => row.key === facet.toLocaleLowerCase('en-US'), - )?.value; - if (value) { - facetValues.set( - String(value), - (facetValues.get(String(value)) ?? 0) + 1, - ); + // Use a Set to count each distinct value once per entity, + // matching the backend's count(DISTINCT entity_id) behavior + const uniqueValues = new Set( + rows + .filter( + row => + row.key.toLocaleLowerCase('en-US') === + facet.toLocaleLowerCase('en-US'), + ) + .map(row => row.value) + .filter(v => v !== null && v !== undefined) + .map(v => String(v)), + ); + for (const value of uniqueValues) { + facetValues.set(value, (facetValues.get(value) ?? 0) + 1); } } const counts = Array.from(facetValues.entries()).map( diff --git a/packages/cli/package.json b/packages/cli/package.json index b2d610d00a..562240290f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -118,6 +118,7 @@ "p-queue": "^6.6.2", "pirates": "^4.0.6", "postcss": "^8.1.0", + "postcss-import": "^16.1.0", "process": "^0.11.10", "raw-loader": "^4.0.2", "react-dev-utils": "^12.0.0-next.60", @@ -191,7 +192,7 @@ "msw": "^1.0.0", "nodemon": "^3.0.1", "terser-webpack-plugin": "^5.1.3", - "webpack": "~5.103.0", + "webpack": "~5.104.0", "webpack-dev-server": "^5.0.0" }, "peerDependencies": { @@ -206,7 +207,7 @@ "jsdom": "^27.1.0", "mini-css-extract-plugin": "^2.4.2", "terser-webpack-plugin": "^5.1.3", - "webpack": "~5.103.0", + "webpack": "~5.104.0", "webpack-dev-server": "^5.0.0" }, "peerDependenciesMeta": { diff --git a/packages/cli/src/lib/entryPoints.ts b/packages/cli/src/lib/entryPoints.ts index 17a4f7dc12..063593a7a4 100644 --- a/packages/cli/src/lib/entryPoints.ts +++ b/packages/cli/src/lib/entryPoints.ts @@ -33,18 +33,25 @@ const defaultIndex = { ext: '.ts', }; +const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx']; + function parseEntryPoint(mount: string, path: string): EntryPoint { + const ext = extname(path); + let name = mount; if (name === '.') { name = 'index'; } else if (name.startsWith('./')) { name = name.slice(2); } - if (name.includes('/')) { + + // Script entry points can't have slashes because we create backward-compat + // directories for them. Non-script files (like CSS) can have nested paths. + if (name.includes('/') && SCRIPT_EXTS.includes(ext)) { throw new Error(`Mount point '${mount}' may not contain multiple slashes`); } - return { mount, path, name, ext: extname(path) }; + return { mount, path, name, ext }; } export function readEntryPoints(pkg: BackstagePackageJson): Array { diff --git a/packages/cli/src/modules/build/commands/package/build/command.ts b/packages/cli/src/modules/build/commands/package/build/command.ts index 378fd31896..690534d724 100644 --- a/packages/cli/src/modules/build/commands/package/build/command.ts +++ b/packages/cli/src/modules/build/commands/package/build/command.ts @@ -15,9 +15,14 @@ */ import { OptionValues } from 'commander'; +import fs from 'fs-extra'; import { buildPackage, Output } from '../../../lib/builder'; import { findRoleFromCommand } from '../../../../../lib/role'; -import { PackageGraph, PackageRoles } from '@backstage/cli-node'; +import { + BackstagePackageJson, + PackageGraph, + PackageRoles, +} from '@backstage/cli-node'; import { paths } from '../../../../../lib/paths'; import { buildFrontend } from '../../../lib/buildFrontend'; import { buildBackend } from '../../../lib/buildBackend'; @@ -85,8 +90,13 @@ export async function command(opts: OptionValues): Promise { outputs.add(Output.types); } + const packageJson = (await fs.readJson( + paths.resolveTarget('package.json'), + )) as BackstagePackageJson; + return buildPackage({ outputs, + packageJson, minify: Boolean(opts.minify), workspacePackages: await PackageGraph.listTargetPackages(), }); diff --git a/packages/cli/src/modules/build/lib/builder/config.ts b/packages/cli/src/modules/build/lib/builder/config.ts index ff084148db..91ddb68233 100644 --- a/packages/cli/src/modules/build/lib/builder/config.ts +++ b/packages/cli/src/modules/build/lib/builder/config.ts @@ -37,7 +37,7 @@ import { OutputPlugin, } from 'rollup'; -import { forwardFileImports } from './plugins'; +import { forwardFileImports, cssEntryPoints } from './plugins'; import { BuildOptions, Output } from './types'; import { paths } from '../../../../lib/paths'; import { BackstagePackageJson } from '@backstage/cli-node'; @@ -275,6 +275,7 @@ export async function makeRollupConfigs( target: 'ES2023', minify: options.minify, }), + cssEntryPoints({ entryPoints, targetDir }), ], }); } diff --git a/packages/cli/src/modules/build/lib/builder/packager.ts b/packages/cli/src/modules/build/lib/builder/packager.ts index 5f167f3406..12017b694c 100644 --- a/packages/cli/src/modules/build/lib/builder/packager.ts +++ b/packages/cli/src/modules/build/lib/builder/packager.ts @@ -107,7 +107,8 @@ export const buildPackage = async (options: BuildOptions) => { const rollupConfigs = await makeRollupConfigs(options); - await fs.remove(resolvePath(options.targetDir ?? paths.targetDir, 'dist')); + const targetDir = options.targetDir ?? paths.targetDir; + await fs.remove(resolvePath(targetDir, 'dist')); const buildTasks = rollupConfigs.map(rollupBuild); diff --git a/packages/cli/src/modules/build/lib/builder/plugins.test.ts b/packages/cli/src/modules/build/lib/builder/plugins.test.ts index f6984dd67e..209175bd15 100644 --- a/packages/cli/src/modules/build/lib/builder/plugins.test.ts +++ b/packages/cli/src/modules/build/lib/builder/plugins.test.ts @@ -22,9 +22,25 @@ import { PluginContext, } from 'rollup'; -import { forwardFileImports } from './plugins'; +import { forwardFileImports, cssEntryPoints } from './plugins'; import { createMockDirectory } from '@backstage/backend-test-utils'; +// Helper to call generateBundle hook which can be a function or ObjectHook +async function callGenerateBundle( + plugin: ReturnType, + ctx: PluginContext, + options: NormalizedOutputOptions, + bundle: Record, + isWrite: boolean, +) { + const hook = plugin.generateBundle; + if (typeof hook === 'function') { + await hook.call(ctx, options, bundle, isWrite); + } else if (hook && typeof hook === 'object' && 'handler' in hook) { + await hook.handler.call(ctx, options, bundle, isWrite); + } +} + const context = { meta: { rollupVersion: '0.0.0', @@ -194,3 +210,139 @@ describe('forwardFileImports', () => { }); }); }); + +describe('cssEntryPoints', () => { + it('should be created with correct name', () => { + const plugin = cssEntryPoints({ + entryPoints: [], + targetDir: '/dev', + }); + expect(plugin.name).toBe('backstage-css-entry-points'); + }); + + describe('with createMockDirectory', () => { + const mockDir = createMockDirectory(); + + const emittedFiles: Array<{ fileName: string; source: string }> = []; + const emitContext = { + ...context, + emitFile: (file: { fileName: string; source: string }) => { + emittedFiles.push(file); + return 'asset-id'; + }, + } as unknown as PluginContext; + + beforeEach(() => { + emittedFiles.length = 0; + mockDir.setContent({ + dev: { + src: { + css: { + 'styles.css': '@import "./base.css";\n.root { color: red; }', + 'base.css': '.base { margin: 0; }', + }, + }, + }, + }); + }); + + it('should not emit when isWrite is false', async () => { + const plugin = cssEntryPoints({ + entryPoints: [ + { + mount: './css/styles.css', + path: './src/css/styles.css', + name: 'css/styles.css', + ext: '.css', + }, + ], + targetDir: mockDir.resolve('dev'), + }); + + await callGenerateBundle( + plugin, + emitContext, + { dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions, + {}, + false, + ); + + expect(emittedFiles).toHaveLength(0); + }); + + it('should emit only CSS entry points with resolved imports', async () => { + const plugin = cssEntryPoints({ + entryPoints: [ + // Non-CSS entry should be ignored + { mount: '.', path: './src/index.ts', name: 'index', ext: '.ts' }, + { + mount: './css/styles.css', + path: './src/css/styles.css', + name: 'css/styles.css', + ext: '.css', + }, + ], + targetDir: mockDir.resolve('dev'), + }); + + await callGenerateBundle( + plugin, + emitContext, + { dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions, + {}, + true, + ); + + // Only CSS file should be emitted, not the .ts entry + expect(emittedFiles).toHaveLength(1); + expect(emittedFiles[0].fileName).toBe('css/styles.css'); + expect(emittedFiles[0].source).toContain('.base { margin: 0; }'); + expect(emittedFiles[0].source).toContain('.root { color: red; }'); + expect(emittedFiles[0].source).not.toContain('@import'); + }); + + it('should only emit once per output directory', async () => { + const plugin = cssEntryPoints({ + entryPoints: [ + { + mount: './css/styles.css', + path: './src/css/styles.css', + name: 'css/styles.css', + ext: '.css', + }, + ], + targetDir: mockDir.resolve('dev'), + }); + + // First call should emit + await callGenerateBundle( + plugin, + emitContext, + { dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions, + {}, + true, + ); + expect(emittedFiles).toHaveLength(1); + + // Second call to same dir should not emit again + await callGenerateBundle( + plugin, + emitContext, + { dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions, + {}, + true, + ); + expect(emittedFiles).toHaveLength(1); + + // Call to different dir should emit + await callGenerateBundle( + plugin, + emitContext, + { dir: mockDir.resolve('dev/dist2') } as NormalizedOutputOptions, + {}, + true, + ); + expect(emittedFiles).toHaveLength(2); + }); + }); +}); diff --git a/packages/cli/src/modules/build/lib/builder/plugins.ts b/packages/cli/src/modules/build/lib/builder/plugins.ts index b597f8fa21..2340884e3d 100644 --- a/packages/cli/src/modules/build/lib/builder/plugins.ts +++ b/packages/cli/src/modules/build/lib/builder/plugins.ts @@ -15,6 +15,8 @@ */ import fs from 'fs-extra'; +import postcss from 'postcss'; +import postcssImport from 'postcss-import'; import { dirname, resolve as resolvePath, @@ -27,6 +29,7 @@ import { OutputChunk, HasModuleSideEffects, } from 'rollup'; +import { EntryPoint } from '../../../../lib/entryPoints'; type ForwardFileImportsOptions = { include: Array | string | RegExp | null; @@ -169,3 +172,58 @@ export function forwardFileImports(options: ForwardFileImportsOptions) { }, } satisfies Plugin; } + +interface CssEntryPointsOptions { + entryPoints: EntryPoint[]; + targetDir: string; +} + +/** + * Rollup plugin that bundles CSS entry points using postcss-import. + * CSS files declared in package.json exports are processed and emitted + * as part of the Rollup bundle. + */ +export function cssEntryPoints(options: CssEntryPointsOptions): Plugin { + const cssEntries = options.entryPoints.filter(ep => ep.ext === '.css'); + + // Track output directories we've already emitted CSS to, to avoid duplicates + // when Rollup runs generateBundle multiple times (once per output format) + const generatedFor = new Set(); + + return { + name: 'backstage-css-entry-points', + + async generateBundle(outputOptions, _bundle, isWrite) { + if (!isWrite) { + return; + } + + const dir = outputOptions.dir || dirname(outputOptions.file!); + if (generatedFor.has(dir)) { + return; + } + generatedFor.add(dir); + + for (const entryPoint of cssEntries) { + const sourcePath = resolvePath(options.targetDir, entryPoint.path); + // Strip the src/ prefix to create an output filename relative to the Rollup output directory + const outputPath = entryPoint.path.replace(/^(\.\/)?src\//, ''); + + // Read source CSS + const source = await fs.readFile(sourcePath, 'utf8'); + + // Bundle @import statements using postcss-import + const result = await postcss([postcssImport()]).process(source, { + from: sourcePath, + }); + + // Emit the bundled CSS as an asset + this.emitFile({ + type: 'asset', + fileName: outputPath, + source: result.css, + }); + } + }, + }; +} diff --git a/packages/cli/src/modules/build/lib/packager/productionPack.ts b/packages/cli/src/modules/build/lib/packager/productionPack.ts index 90a973cc32..f2a74179f4 100644 --- a/packages/cli/src/modules/build/lib/packager/productionPack.ts +++ b/packages/cli/src/modules/build/lib/packager/productionPack.ts @@ -148,7 +148,11 @@ async function rewriteEntryPoints( for (const entryPoint of entryPoints) { if (!SCRIPT_EXTS.includes(entryPoint.ext)) { - outputExports[entryPoint.mount] = entryPoint.path; + // Non-script files (like CSS) get their paths rewritten from src/ to dist/ + outputExports[entryPoint.mount] = entryPoint.path.replace( + /^(\.\/)?src\//, + './dist/', + ); continue; } diff --git a/packages/cli/src/modules/maintenance/commands/repo/fix.ts b/packages/cli/src/modules/maintenance/commands/repo/fix.ts index 6b31bed466..a5c6b519ed 100644 --- a/packages/cli/src/modules/maintenance/commands/repo/fix.ts +++ b/packages/cli/src/modules/maintenance/commands/repo/fix.ts @@ -27,10 +27,13 @@ import { resolve as resolvePath, posix, relative as relativePath, + extname, } from 'node:path'; import { paths } from '../../../../lib/paths'; import { publishPreflightCheck } from '../../lib/publishing'; +const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json']; + /** * A mutable object representing a package.json file with potential fixes. */ @@ -122,29 +125,46 @@ export function fixPackageExports(pkg: FixablePackage) { } const newPath = trimRelative(path); + // Only script files and package.json should be added to typesVersions if (typeof value === 'string') { - typeEntries[newPath] = [trimRelative(value)]; + if (SCRIPT_EXTS.includes(extname(value))) { + typeEntries[newPath] = [trimRelative(value)]; + } } else if (value && typeof value === 'object' && !Array.isArray(value)) { if (typeof value.types === 'string') { typeEntries[newPath] = [trimRelative(value.types)]; - } else if (typeof value.default === 'string') { + } else if ( + typeof value.default === 'string' && + SCRIPT_EXTS.includes(extname(value.default)) + ) { typeEntries[newPath] = [trimRelative(value.default)]; } } } - const typesVersions = { '*': typeEntries }; - if (existingTypesVersions !== JSON.stringify(typesVersions)) { - const newPkgEntries = Object.entries(pkg.packageJson).filter( - ([name]) => name !== 'typesVersions', - ); - newPkgEntries.splice( - newPkgEntries.findIndex(([name]) => name === 'exports') + 1, - 0, - ['typesVersions', typesVersions], - ); + const hasTypeEntries = Object.keys(typeEntries).length > 0; + const typesVersions = hasTypeEntries ? { '*': typeEntries } : undefined; - pkg.packageJson = Object.fromEntries(newPkgEntries) as BackstagePackageJson; + if (existingTypesVersions !== JSON.stringify(typesVersions)) { + if (pkg.packageJson.typesVersions) { + // Update in place to preserve field order + if (typesVersions) { + pkg.packageJson.typesVersions = typesVersions; + } else { + delete pkg.packageJson.typesVersions; + } + } else if (typesVersions) { + // Insert after exports when adding for the first time + const newPkgEntries = Object.entries(pkg.packageJson); + newPkgEntries.splice( + newPkgEntries.findIndex(([name]) => name === 'exports') + 1, + 0, + ['typesVersions', typesVersions], + ); + pkg.packageJson = Object.fromEntries( + newPkgEntries, + ) as BackstagePackageJson; + } pkg.changed = true; } diff --git a/packages/cli/src/types.d.ts b/packages/cli/src/types.d.ts index 3ddef88689..984f4bcf57 100644 --- a/packages/cli/src/types.d.ts +++ b/packages/cli/src/types.d.ts @@ -259,3 +259,9 @@ declare module 'webpack-node-externals' { } declare module '@esbuild-kit/cjs-loader' {} + +declare module 'postcss-import' { + import { Plugin } from 'postcss'; + + export default function postcssImport(): Plugin; +} diff --git a/packages/cli/templates/scaffolder-backend-module/src/module.ts b/packages/cli/templates/scaffolder-backend-module/src/module.ts index 56ec38ac33..214926ff35 100644 --- a/packages/cli/templates/scaffolder-backend-module/src/module.ts +++ b/packages/cli/templates/scaffolder-backend-module/src/module.ts @@ -1,5 +1,5 @@ import { createBackendModule } from "@backstage/backend-plugin-api"; -import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha'; +import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node'; import { createExampleAction } from "./actions/example"; /** diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index e039394946..51bdb676ce 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -276,6 +276,7 @@ export class AppThemeSelector implements AppThemeApi { activeThemeId$(): Observable; // (undocumented) static createWithStorage(themes: AppTheme[]): AppThemeSelector; + dispose(): void; // (undocumented) getActiveThemeId(): string | undefined; // (undocumented) diff --git a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts index 04dcfe9ebb..e0da4f9b87 100644 --- a/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppLanguageApi/AppLanguageSelector.ts @@ -69,20 +69,25 @@ export class AppLanguageSelector implements AppLanguageApi { selector.setLanguage(storedLanguage); } - selector.language$().subscribe(({ language }) => { + const subscription = selector.language$().subscribe(({ language }) => { if (language !== window.localStorage.getItem(STORAGE_KEY)) { window.localStorage.setItem(STORAGE_KEY, language); } }); - window.addEventListener('storage', event => { + const storageListener = (event: StorageEvent) => { if (event.key === STORAGE_KEY) { const language = localStorage.getItem(STORAGE_KEY) ?? undefined; if (language) { selector.setLanguage(language); } } - }); + }; + window.addEventListener('storage', storageListener); + + // Store cleanup references for potential disposal + selector.#storageSubscription = subscription; + selector.#storageListener = storageListener; return selector; } @@ -91,6 +96,10 @@ export class AppLanguageSelector implements AppLanguageApi { #language: string; #subject: BehaviorSubject<{ language: string }>; + // References for cleanup when using createWithStorage + #storageSubscription?: { unsubscribe(): void }; + #storageListener?: (event: StorageEvent) => void; + private constructor(languages: string[], initialLanguage: string) { this.#languages = languages; this.#language = initialLanguage; @@ -126,4 +135,20 @@ export class AppLanguageSelector implements AppLanguageApi { language$(): Observable<{ language: string }> { return this.#subject; } + + /** + * Cleans up resources created by createWithStorage(). + * Call this method when the selector is no longer needed to prevent memory leaks. + * This is particularly useful in testing scenarios or when the app is unmounted. + */ + dispose(): void { + if (this.#storageSubscription) { + this.#storageSubscription.unsubscribe(); + this.#storageSubscription = undefined; + } + if (this.#storageListener) { + window.removeEventListener('storage', this.#storageListener); + this.#storageListener = undefined; + } + } } diff --git a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts index b0303cf29a..ef0c2e337b 100644 --- a/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts +++ b/packages/core-app-api/src/apis/implementations/AppThemeApi/AppThemeSelector.ts @@ -39,7 +39,7 @@ export class AppThemeSelector implements AppThemeApi { selector.setActiveThemeId(initialThemeId); - selector.activeThemeId$().subscribe(themeId => { + const subscription = selector.activeThemeId$().subscribe(themeId => { if (themeId) { window.localStorage.setItem(STORAGE_KEY, themeId); } else { @@ -47,12 +47,17 @@ export class AppThemeSelector implements AppThemeApi { } }); - window.addEventListener('storage', event => { + const storageListener = (event: StorageEvent) => { if (event.key === STORAGE_KEY) { const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined; selector.setActiveThemeId(themeId); } - }); + }; + window.addEventListener('storage', storageListener); + + // Store cleanup references for potential disposal + selector.#storageSubscription = subscription; + selector.#storageListener = storageListener; return selector; } @@ -60,6 +65,10 @@ export class AppThemeSelector implements AppThemeApi { private activeThemeId: string | undefined; private readonly subject = new BehaviorSubject(undefined); + // References for cleanup when using createWithStorage + #storageSubscription?: { unsubscribe(): void }; + #storageListener?: (event: StorageEvent) => void; + constructor(private readonly themes: AppTheme[]) {} getInstalledThemes(): AppTheme[] { @@ -78,4 +87,20 @@ export class AppThemeSelector implements AppThemeApi { this.activeThemeId = themeId; this.subject.next(themeId); } + + /** + * Cleans up resources created by createWithStorage(). + * Call this method when the selector is no longer needed to prevent memory leaks. + * This is particularly useful in testing scenarios or when the app is unmounted. + */ + dispose(): void { + if (this.#storageSubscription) { + this.#storageSubscription.unsubscribe(); + this.#storageSubscription = undefined; + } + if (this.#storageListener) { + window.removeEventListener('storage', this.#storageListener); + this.#storageListener = undefined; + } + } } diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index 98067267b5..bfaeb2f2db 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -171,6 +171,7 @@ export class AppManager implements BackstageApp { private readonly defaultApis: Iterable; private readonly bindRoutes: AppOptions['bindRoutes']; private readonly appLanguageApi: AppLanguageApi; + private readonly appThemeApi: AppThemeApi; private readonly translationResources: Array< TranslationResource | TranslationMessages >; @@ -194,6 +195,9 @@ export class AppManager implements BackstageApp { availableLanguages: options.__experimentalTranslations?.availableLanguages, }); + // Create a single AppThemeSelector instance to be shared between + // the loading phase and the main app, avoiding duplicate event listeners + this.appThemeApi = AppThemeSelector.createWithStorage(this.themes); this.translationResources = options.__experimentalTranslations?.resources ?? []; } @@ -240,10 +244,9 @@ export class AppManager implements BackstageApp { const Provider = ({ children }: PropsWithChildren<{}>) => { const needsFeatureFlagRegistrationRef = useRef(true); - const appThemeApi = useMemo( - () => AppThemeSelector.createWithStorage(this.themes), - [], - ); + // Use the shared AppThemeSelector instance created in the constructor + // to avoid creating duplicate event listeners and subscriptions + const appThemeApi = this.appThemeApi; const { routing, featureFlags } = useMemo(() => { const usesReactRouterBeta = isReactRouterBeta(); @@ -436,7 +439,8 @@ DEPRECATION WARNING: React Router Beta is deprecated and support for it will be this.apiFactoryRegistry.register('static', { api: appThemeApiRef, deps: {}, - factory: () => AppThemeSelector.createWithStorage(this.themes), + // Use the shared AppThemeSelector instance to avoid duplicate event listeners + factory: () => this.appThemeApi, }); this.apiFactoryRegistry.register('static', { api: configApiRef, diff --git a/packages/e2e-test-utils/report-playwright.api.md b/packages/e2e-test-utils/report-playwright.api.md index 7153f2e578..f286c84f4d 100644 --- a/packages/e2e-test-utils/report-playwright.api.md +++ b/packages/e2e-test-utils/report-playwright.api.md @@ -9,7 +9,9 @@ import { PlaywrightTestConfig } from '@playwright/test'; export function failOnBrowserErrors(): void; // @public -export function generateProjects(): PlaywrightTestConfig['projects']; +export function generateProjects(options?: { + channel?: string; +}): PlaywrightTestConfig['projects']; // (No @packageDocumentation comment for this package) ``` diff --git a/packages/e2e-test-utils/src/playwright/generateProjects.ts b/packages/e2e-test-utils/src/playwright/generateProjects.ts index 984759644c..33a30e2813 100644 --- a/packages/e2e-test-utils/src/playwright/generateProjects.ts +++ b/packages/e2e-test-utils/src/playwright/generateProjects.ts @@ -24,8 +24,14 @@ import type { BackstagePackage } from '@backstage/cli-node'; * Generates a list of playwright projects by scanning the monorepo for packages with an `e2e-tests/` folder. * * @public + * + * @param options - Optional configuration for the generated Playwright projects. + * When provided, `options.channel` controls the browser channel used for tests. + * Valid values are listed in the [Playwright documentation](https://playwright.dev/docs/api/class-testoptions#test-options-channel). */ -export function generateProjects(): PlaywrightTestConfig['projects'] { +export function generateProjects(options?: { + channel?: string; +}): PlaywrightTestConfig['projects'] { // TODO(Rugvip): Switch this over to use @backstage/cli-node once released, and support SINCE=origin/main const { root, packages } = getPackagesSync(process.cwd()); const e2eTestPackages = [...(root ? [root] : []), ...packages].filter(pkg => { @@ -36,7 +42,7 @@ export function generateProjects(): PlaywrightTestConfig['projects'] { name: pkg.packageJson.name, testDir: resolvePath(pkg.dir, 'e2e-tests'), use: { - channel: 'chrome', + channel: options?.channel ?? 'chrome', }, })); } diff --git a/packages/filter-predicates/.eslintrc.js b/packages/filter-predicates/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/packages/filter-predicates/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/packages/filter-predicates/README.md b/packages/filter-predicates/README.md new file mode 100644 index 0000000000..e8f70fe5aa --- /dev/null +++ b/packages/filter-predicates/README.md @@ -0,0 +1,18 @@ +# @backstage/filter-predicates + +Contains types and implementations related to the concept of +[filter predicate expressions](https://backstage.io/docs/features/software-catalog/catalog-customization#entity-predicate-queries). + +These allow you to uniformly express filters, including logical operators and +advanced matchers, for filtering through structured data. + +Example: + +```json +{ + "filter": { + "kind": "Component", + "spec.type": { "$in": ["service", "website"] } + } +} +``` diff --git a/packages/filter-predicates/catalog-info.yaml b/packages/filter-predicates/catalog-info.yaml new file mode 100644 index 0000000000..63be7ba015 --- /dev/null +++ b/packages/filter-predicates/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-filter-predicates + title: '@backstage/filter-predicates' + description: A library for expressing filter predicates and evaluating them against values +spec: + lifecycle: experimental + type: backstage-common-library + owner: framework-maintainers diff --git a/packages/filter-predicates/package.json b/packages/filter-predicates/package.json new file mode 100644 index 0000000000..de803f713e --- /dev/null +++ b/packages/filter-predicates/package.json @@ -0,0 +1,48 @@ +{ + "name": "@backstage/filter-predicates", + "version": "0.0.0", + "description": "A library for expressing filter predicates and evaluating them against values", + "backstage": { + "role": "common-library" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "keywords": [ + "backstage" + ], + "homepage": "https://backstage.io", + "repository": { + "type": "git", + "url": "https://github.com/backstage/backstage", + "directory": "packages/filter-predicates" + }, + "license": "Apache-2.0", + "sideEffects": false, + "main": "src/index.ts", + "types": "src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "backstage-cli package build", + "clean": "backstage-cli package clean", + "lint": "backstage-cli package lint", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack", + "test": "backstage-cli package test" + }, + "dependencies": { + "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", + "@backstage/types": "workspace:^", + "zod": "^3.25.76", + "zod-validation-error": "^4.0.2" + }, + "devDependencies": { + "@backstage/cli": "workspace:^" + } +} diff --git a/packages/filter-predicates/report.api.md b/packages/filter-predicates/report.api.md new file mode 100644 index 0000000000..76edeb61d2 --- /dev/null +++ b/packages/filter-predicates/report.api.md @@ -0,0 +1,107 @@ +## API Report File for "@backstage/filter-predicates" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Config } from '@backstage/config'; +import { JsonValue } from '@backstage/types'; +import * as zodV3 from 'zod/v3'; + +// @public +export function createZodV3FilterPredicateSchema( + z: typeof zodV3.z, +): zodV3.ZodType; + +// @public +export function evaluateFilterPredicate( + predicate: FilterPredicate, + value: unknown, +): boolean; + +// @public +export type FilterPredicate = + | FilterPredicateExpression + | FilterPredicatePrimitive + | { + $all: FilterPredicate[]; + } + | { + $any: FilterPredicate[]; + } + | { + $not: FilterPredicate; + } + | UnknownFilterPredicateOperator; + +// @public +export type FilterPredicateExpression = { + [KPath in string]: FilterPredicateValue; +} & { + [KPath in `$${string}`]: never; +}; + +// @public +export type FilterPredicatePrimitive = string | number | boolean; + +// @public +export function filterPredicateToFilterFunction( + predicate: FilterPredicate, +): (value: T) => boolean; + +// @public +export type FilterPredicateValue = + | FilterPredicatePrimitive + | { + $exists: boolean; + } + | { + $in: FilterPredicatePrimitive[]; + } + | { + $contains: FilterPredicate; + } + | { + $hasPrefix: string; + } + | UnknownFilterPredicateValueMatcher; + +// @public +export function getJsonValueAtPath( + value: JsonValue | undefined, + path: string, +): JsonValue | undefined; + +// @public +export function parseFilterPredicate(value: unknown): FilterPredicate; + +// @public +export function readFilterPredicateFromConfig( + config: Config, + options?: ReadFilterPredicateFromConfigOptions, +): FilterPredicate; + +// @public +export interface ReadFilterPredicateFromConfigOptions { + key?: string; +} + +// @public +export function readOptionalFilterPredicateFromConfig( + config: Config, + options?: ReadFilterPredicateFromConfigOptions, +): FilterPredicate | undefined; + +// @public +export type UnknownFilterPredicateOperator = { + [KOperator in `$${string}`]: JsonValue; +} & { + [KOperator in '$all' | '$any' | '$not']: never; +}; + +// @public +export type UnknownFilterPredicateValueMatcher = { + [KMatcher in `$${string}`]: JsonValue; +} & { + [KMatcher in '$exists' | '$in' | '$contains' | '$hasPrefix']: never; +}; +``` diff --git a/plugins/catalog-react/src/alpha/predicates/index.ts b/packages/filter-predicates/src/index.ts similarity index 73% rename from plugins/catalog-react/src/alpha/predicates/index.ts rename to packages/filter-predicates/src/index.ts index 71be7403de..414b4127a5 100644 --- a/plugins/catalog-react/src/alpha/predicates/index.ts +++ b/packages/filter-predicates/src/index.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -export type { - EntityPredicate, - EntityPredicateExpression, - EntityPredicatePrimitive, - EntityPredicateValue, -} from './types'; -export { entityPredicateToFilterFunction } from './entityPredicateToFilterFunction'; +/** + * Contains types and implementations related to the concept of filter predicate expressions. + * + * @packageDocumentation + */ + +export * from './predicates'; diff --git a/packages/filter-predicates/src/predicates/config.test.ts b/packages/filter-predicates/src/predicates/config.test.ts new file mode 100644 index 0000000000..494e707f40 --- /dev/null +++ b/packages/filter-predicates/src/predicates/config.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2025 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 { + readFilterPredicateFromConfig, + readOptionalFilterPredicateFromConfig, +} from './config'; + +describe('readFilterPredicateFromConfig', () => { + it('should read a filter predicate from config', () => { + const config = new ConfigReader({ + predicate: { kind: 'component', 'spec.type': 'service' }, + }); + + const result = readFilterPredicateFromConfig(config, { key: 'predicate' }); + + expect(result).toEqual({ kind: 'component', 'spec.type': 'service' }); + }); + + it('should read a filter predicate from the root config', () => { + const config = new ConfigReader({ + kind: 'component', + 'spec.type': 'service', + }); + + const result = readFilterPredicateFromConfig(config); + + expect(result).toEqual({ kind: 'component', 'spec.type': 'service' }); + }); + + it('should throw when filter predicate is missing', () => { + const config = new ConfigReader({}); + + expect(() => + readFilterPredicateFromConfig(config, { key: 'predicate' }), + ).toThrow(/predicate/); + }); + + it('should throw when filter predicate is invalid', () => { + const config = new ConfigReader({ + predicate: { kind: { $invalid: 'foo' } }, + }); + + expect(() => + readFilterPredicateFromConfig(config, { key: 'predicate' }), + ).toThrow(/Could not read filter predicate from config at 'predicate':/); + }); +}); + +describe('readOptionalFilterPredicateFromConfig', () => { + it('should read a filter predicate from config', () => { + const config = new ConfigReader({ + predicate: { kind: 'component' }, + }); + + const result = readOptionalFilterPredicateFromConfig(config, { + key: 'predicate', + }); + + expect(result).toEqual({ kind: 'component' }); + }); + + it('should return undefined when filter predicate is missing', () => { + const config = new ConfigReader({}); + + const result = readOptionalFilterPredicateFromConfig(config, { + key: 'predicate', + }); + + expect(result).toBeUndefined(); + }); + + it('should throw when filter predicate is invalid', () => { + const config = new ConfigReader({ + predicate: { kind: { $invalid: 'foo' } }, + }); + + expect(() => + readOptionalFilterPredicateFromConfig(config, { key: 'predicate' }), + ).toThrow(/Could not read filter predicate from config at 'predicate':/); + }); + + it('should read complex filter predicates', () => { + const config = new ConfigReader({ + filter: { + $any: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'api' }], + }, + }); + + const result = readOptionalFilterPredicateFromConfig(config, { + key: 'filter', + }); + + expect(result).toEqual({ + $any: [{ kind: 'component', 'spec.type': 'service' }, { kind: 'api' }], + }); + }); +}); diff --git a/packages/filter-predicates/src/predicates/config.ts b/packages/filter-predicates/src/predicates/config.ts new file mode 100644 index 0000000000..12f0620523 --- /dev/null +++ b/packages/filter-predicates/src/predicates/config.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2025 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 { InputError, stringifyError } from '@backstage/errors'; +import { parseFilterPredicate } from './schema'; +import { FilterPredicate } from './types'; + +/** + * Options for {@link readFilterPredicateFromConfig} and {@link readOptionalFilterPredicateFromConfig}. + * + * @public + */ +export interface ReadFilterPredicateFromConfigOptions { + /** + * The key to read from the config. If not provided, the entire config is used. + */ + key?: string; +} + +/** + * Read a filter predicate expression from a config object. + * + * @public + */ +export function readFilterPredicateFromConfig( + config: Config, + options?: ReadFilterPredicateFromConfigOptions, +): FilterPredicate { + const key = options?.key; + const value = key ? config.get(key) : config.get(); + + try { + return parseFilterPredicate(value); + } catch (error) { + const where = key ? ` at '${key}'` : ''; + throw new InputError( + `Could not read filter predicate from config${where}: ${stringifyError( + error, + )}`, + ); + } +} + +/** + * Read an optional filter predicate expression from a config object. + * + * @public + */ +export function readOptionalFilterPredicateFromConfig( + config: Config, + options?: ReadFilterPredicateFromConfigOptions, +): FilterPredicate | undefined { + const key = options?.key; + const value = key ? config.getOptional(key) : config.getOptional(); + + if (value === undefined) { + return undefined; + } + + return readFilterPredicateFromConfig(config, options); +} diff --git a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts b/packages/filter-predicates/src/predicates/evaluate.test.ts similarity index 79% rename from plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts rename to packages/filter-predicates/src/predicates/evaluate.test.ts index 152230de7c..6eb54c3c92 100644 --- a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.test.ts +++ b/packages/filter-predicates/src/predicates/evaluate.test.ts @@ -14,10 +14,13 @@ * limitations under the License. */ -import { entityPredicateToFilterFunction } from './entityPredicateToFilterFunction'; -import { EntityPredicate } from './types'; +import { + evaluateFilterPredicate, + filterPredicateToFilterFunction, +} from './evaluate'; +import { FilterPredicate } from './types'; -describe('entityPredicateToFilterFunction', () => { +describe('evaluate', () => { const entities = [ { apiVersion: 'backstage.io/v1alpha1', @@ -129,7 +132,7 @@ describe('entityPredicateToFilterFunction', () => { }, ]; - it.each([ + describe.each([ ['s', { kind: 'component', 'spec.type': 'service' }], ['s', { 'metadata.tags': { $contains: 'java' } }], [ @@ -186,7 +189,7 @@ describe('entityPredicateToFilterFunction', () => { metadata: { $contains: { name: 'a' } }, }, ], - ['', { $unknown: 'ignored' } as unknown as EntityPredicate], + ['', { $unknown: 'ignored' } as unknown as FilterPredicate], [ 's,w', { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, @@ -234,12 +237,33 @@ describe('entityPredicateToFilterFunction', () => { 'metadata.annotations.github.com/repo': { $exists: true }, }, ], + ['a', { 'spec.type': { $hasPrefix: 'g' } }], ])('filter entry %#', (expected, filter) => { - const filtered = entities.filter(entity => - entityPredicateToFilterFunction(filter)(entity), - ); - expect(filtered.map(e => e.metadata.name).sort()).toEqual( - expected.split(',').filter(Boolean).sort(), - ); + it('filterPredicateToFilterFunction', () => { + const filtered = entities.filter(entity => + filterPredicateToFilterFunction(filter)(entity), + ); + expect(filtered.map(e => e.metadata.name).sort()).toEqual( + expected.split(',').filter(Boolean).sort(), + ); + }); + + it('evaluateFilterPredicate', () => { + const filtered = entities.filter(entity => + evaluateFilterPredicate(filter, entity), + ); + expect(filtered.map(e => e.metadata.name).sort()).toEqual( + expected.split(',').filter(Boolean).sort(), + ); + }); + }); + + it('handles unknown filter predicate operators and matchers', () => { + const operator = { $unknown: 'foo' } as unknown as FilterPredicate; + const value = { kind: { $unknown: 'foo' } } as unknown as FilterPredicate; + expect(evaluateFilterPredicate(operator, entities[0])).toBe(false); + expect(evaluateFilterPredicate(value, entities[0])).toBe(false); + expect(filterPredicateToFilterFunction(operator)(entities[0])).toBe(false); + expect(filterPredicateToFilterFunction(value)(entities[0])).toBe(false); }); }); diff --git a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts b/packages/filter-predicates/src/predicates/evaluate.ts similarity index 53% rename from plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts rename to packages/filter-predicates/src/predicates/evaluate.ts index f223911992..2006265004 100644 --- a/plugins/catalog-react/src/alpha/predicates/entityPredicateToFilterFunction.ts +++ b/packages/filter-predicates/src/predicates/evaluate.ts @@ -15,51 +15,48 @@ */ import { JsonValue } from '@backstage/types'; -import { EntityPredicate, EntityPredicateValue } from './types'; -import { valueAtPath } from './valueAtPath'; +import { FilterPredicate, FilterPredicateValue } from './types'; +import { getJsonValueAtPath } from './getJsonValueAtPath'; /** - * Convert an entity predicate to a filter function that can be used to filter entities. - * @alpha - */ -export function entityPredicateToFilterFunction( - entityPredicate: EntityPredicate, -): (value: T) => boolean { - return value => evaluateEntityPredicate(entityPredicate, value); -} - -/** - * Evaluate a entity predicate against a value, typically an entity. + * Evaluate a filter predicate against a value. * - * @internal + * @public */ -function evaluateEntityPredicate( - filter: EntityPredicate, - value: JsonValue, +export function evaluateFilterPredicate( + predicate: FilterPredicate, + value: unknown, ): boolean { - if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) { - return valuesAreEqual(value, filter); + if ( + typeof predicate !== 'object' || + predicate === null || + Array.isArray(predicate) + ) { + return valuesAreEqual(value, predicate); } - if ('$all' in filter) { - return filter.$all.every(f => evaluateEntityPredicate(f, value)); + if ('$all' in predicate) { + return predicate.$all.every(f => evaluateFilterPredicate(f, value)); } - if ('$any' in filter) { - return filter.$any.some(f => evaluateEntityPredicate(f, value)); + if ('$any' in predicate) { + return predicate.$any.some(f => evaluateFilterPredicate(f, value)); } - if ('$not' in filter) { - return !evaluateEntityPredicate(filter.$not, value); + if ('$not' in predicate) { + return !evaluateFilterPredicate(predicate.$not, value); } - for (const filterKey in filter) { - if (!Object.hasOwn(filter, filterKey)) { + for (const filterKey in predicate) { + if (!Object.hasOwn(predicate, filterKey)) { continue; } if (filterKey.startsWith('$')) { return false; } if ( - !evaluatePredicateValue(filter[filterKey], valueAtPath(value, filterKey)) + !evaluateFilterPredicateValue( + predicate[filterKey], + getJsonValueAtPath(value as JsonValue, filterKey), + ) ) { return false; } @@ -69,13 +66,24 @@ function evaluateEntityPredicate( } /** - * Evaluate a single value against a predicate value. + * Convert a filter predicate to a filter function. + * + * @public + */ +export function filterPredicateToFilterFunction( + predicate: FilterPredicate, +): (value: T) => boolean { + return value => evaluateFilterPredicate(predicate, value); +} + +/** + * Evaluate a single value against a filter predicate value. * * @internal */ -function evaluatePredicateValue( - filter: EntityPredicateValue, - value: JsonValue | undefined, +function evaluateFilterPredicateValue( + filter: FilterPredicateValue, + value: unknown, ): boolean { if (typeof filter !== 'object' || filter === null || Array.isArray(filter)) { return valuesAreEqual(value, filter); @@ -85,7 +93,7 @@ function evaluatePredicateValue( if (!Array.isArray(value)) { return false; } - return value.some(v => evaluateEntityPredicate(filter.$contains, v)); + return value.some(v => evaluateFilterPredicate(filter.$contains, v)); } if ('$in' in filter) { return filter.$in.some(search => valuesAreEqual(value, search)); @@ -96,14 +104,19 @@ function evaluatePredicateValue( } return value === undefined; } + if ('$hasPrefix' in filter) { + if (typeof value !== 'string') { + return false; + } + return value + .toLocaleUpperCase('en-US') + .startsWith(filter.$hasPrefix.toLocaleUpperCase('en-US')); + } return false; } -function valuesAreEqual( - a: JsonValue | undefined, - b: JsonValue | undefined, -): boolean { +function valuesAreEqual(a: unknown, b: unknown): boolean { if (a === null || b === null) { return false; } diff --git a/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts b/packages/filter-predicates/src/predicates/getJsonValueAtPath.test.ts similarity index 92% rename from plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts rename to packages/filter-predicates/src/predicates/getJsonValueAtPath.test.ts index 7ea563e4a1..40421232f4 100644 --- a/plugins/catalog-react/src/alpha/predicates/valueAtPath.test.ts +++ b/packages/filter-predicates/src/predicates/getJsonValueAtPath.test.ts @@ -14,9 +14,9 @@ * limitations under the License. */ -import { valueAtPath } from './valueAtPath'; +import { getJsonValueAtPath } from './getJsonValueAtPath'; -describe('valueAtPath', () => { +describe('getJsonValueAtPath', () => { const subject = { name: 'Test', fields: { @@ -58,6 +58,6 @@ describe('valueAtPath', () => { ['mixed.annotations.example.com/description', 'A test subject'], ['mixed.annotations.long.domain.example.com/custom', 'long'], ])(`should find value at path %s`, (path, expected) => { - expect(valueAtPath(subject, path)).toEqual(expected); + expect(getJsonValueAtPath(subject, path)).toEqual(expected); }); }); diff --git a/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts b/packages/filter-predicates/src/predicates/getJsonValueAtPath.ts similarity index 95% rename from plugins/catalog-react/src/alpha/predicates/valueAtPath.ts rename to packages/filter-predicates/src/predicates/getJsonValueAtPath.ts index acb46995d8..2dc982d896 100644 --- a/plugins/catalog-react/src/alpha/predicates/valueAtPath.ts +++ b/packages/filter-predicates/src/predicates/getJsonValueAtPath.ts @@ -27,9 +27,9 @@ import { JsonValue } from '@backstage/types'; * * This lookup does not traverse into arrays, returning `undefined` instead. * - * @internal + * @public */ -export function valueAtPath( +export function getJsonValueAtPath( value: JsonValue | undefined, path: string, ): JsonValue | undefined { @@ -55,7 +55,7 @@ export function valueAtPath( } } if (path.startsWith(`${valueKey}.`)) { - const found = valueAtPath( + const found = getJsonValueAtPath( value[valueKey], path.slice(valueKey.length + 1), ); diff --git a/packages/filter-predicates/src/predicates/index.ts b/packages/filter-predicates/src/predicates/index.ts new file mode 100644 index 0000000000..5684c7e62f --- /dev/null +++ b/packages/filter-predicates/src/predicates/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + readFilterPredicateFromConfig, + readOptionalFilterPredicateFromConfig, +} from './config'; +export type { ReadFilterPredicateFromConfigOptions } from './config'; +export { + evaluateFilterPredicate, + filterPredicateToFilterFunction, +} from './evaluate'; +export { getJsonValueAtPath } from './getJsonValueAtPath'; +export { + createZodV3FilterPredicateSchema, + parseFilterPredicate, +} from './schema'; +export type { + FilterPredicate, + FilterPredicateExpression, + FilterPredicatePrimitive, + FilterPredicateValue, + UnknownFilterPredicateOperator, + UnknownFilterPredicateValueMatcher, +} from './types'; diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts b/packages/filter-predicates/src/predicates/schema.test.ts similarity index 50% rename from plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts rename to packages/filter-predicates/src/predicates/schema.test.ts index 3553fa932b..47f77bbce8 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.test.ts +++ b/packages/filter-predicates/src/predicates/schema.test.ts @@ -15,14 +15,17 @@ */ import { z } from 'zod'; -import { createEntityPredicateSchema } from './createEntityPredicateSchema'; -import { EntityPredicate } from './types'; +import { + createZodV3FilterPredicateSchema, + parseFilterPredicate, +} from './schema'; +import { FilterPredicate } from './types'; -describe('createEntityPredicateSchema', () => { - const schema = createEntityPredicateSchema(z); +describe('createZodV3FilterPredicateSchema', () => { + const schema = createZodV3FilterPredicateSchema(z); describe('valid predicates', () => { - const predicates: EntityPredicate[] = [ + const predicates: FilterPredicate[] = [ 'string', '', 1, @@ -88,7 +91,7 @@ describe('createEntityPredicateSchema', () => { describe('invalid predicates', () => { const predicates: Array< - Exclude + Exclude > = [ [], ['foo', 'bar'], @@ -113,3 +116,104 @@ describe('createEntityPredicateSchema', () => { }); }); }); + +describe('parseFilterPredicate', () => { + describe('valid predicates', () => { + const predicates: FilterPredicate[] = [ + 'string', + '', + 1, + { kind: 'component', 'spec.type': 'service' }, + { 'metadata.tags': { $in: ['java'] } }, + { 'metadata.tags': { $contains: 'java' } }, + { + $all: [ + { 'metadata.tags': { $contains: 'java' } }, + { 'metadata.tags': { $contains: 'spring' } }, + ], + }, + { 'metadata.tags': { $in: ['go'] } }, + { 'metadata.tags.0': 'java' }, + { $not: { 'metadata.tags': { $in: ['java'] } } }, + { + $any: [ + { kind: 'component', 'spec.type': 'service' }, + { kind: 'group' }, + ], + }, + { + relations: { + $contains: { type: 'ownedBy', targetRef: 'group:default/g' }, + }, + }, + { + metadata: { $contains: { name: 'a' } }, + }, + { kind: 'component', 'spec.type': { $in: ['service', 'website'] } }, + { + $any: [ + { + $all: [ + { + kind: 'component', + 'spec.type': { $in: ['service', 'website'] }, + }, + ], + }, + { $all: [{ kind: 'api', 'spec.type': 'grpc' }] }, + ], + }, + { kind: 'component', 'spec.type': { $in: ['service'] } }, + { 'spec.owner': { $exists: true } }, + { 'spec.owner': { $exists: false } }, + { 'spec.type': 'service' }, + { $not: { 'spec.type': 'service' } }, + { + kind: 'component', + 'metadata.annotations.github.com/repo': { $exists: true }, + }, + { $all: [{ x: { $exists: true } }] }, + { $any: [{ x: { $exists: true } }] }, + { $not: { x: { $exists: true } } }, + { $not: { $all: [{ x: { $exists: true } }] } }, + ]; + + it.each(predicates)( + 'should return the predicate for valid input %j', + predicate => { + expect(parseFilterPredicate(predicate)).toEqual(predicate); + }, + ); + }); + + describe('invalid predicates', () => { + const predicates: Array< + Exclude + > = [ + [], + ['foo', 'bar'], + { kind: { 1: 'foo' } }, + { kind: { foo: 'bar' } }, + { kind: { $unknown: 'foo' } }, + { kind: { $in: 'foo' } }, + { kind: { $in: [{ x: 'foo' }] } }, + { kind: { $in: [{ x: 'foo' }] } }, + { 'spec.type': null }, + { $all: [{ x: { $unknown: true } }] }, + { $any: [{ x: { $unknown: true } }] }, + { $not: { x: { $unknown: true } } }, + { $not: { $all: [{ x: { $unknown: true } }] } }, + { $unknown: 'foo' }, + { 'metadata.tags': ['foo', 'bar'] }, + ]; + + it.each(predicates)( + 'should throw InputError for invalid predicate %j', + predicate => { + expect(() => parseFilterPredicate(predicate)).toThrow( + /Invalid filter predicate/, + ); + }, + ); + }); +}); diff --git a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts b/packages/filter-predicates/src/predicates/schema.ts similarity index 53% rename from plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts rename to packages/filter-predicates/src/predicates/schema.ts index 3e31ec4d35..9684660219 100644 --- a/plugins/catalog-react/src/alpha/predicates/createEntityPredicateSchema.ts +++ b/packages/filter-predicates/src/predicates/schema.ts @@ -14,31 +14,39 @@ * limitations under the License. */ +import { InputError } from '@backstage/errors'; +import { fromZodError } from 'zod-validation-error/v3'; +import * as zodV3 from 'zod/v3'; import { - EntityPredicate, - EntityPredicateExpression, - EntityPredicatePrimitive, - EntityPredicateValue, + FilterPredicate, + FilterPredicateExpression, + FilterPredicatePrimitive, + FilterPredicateValue, } from './types'; -import type { z as zImpl, ZodType } from 'zod'; -/** @internal */ -export function createEntityPredicateSchema(z: typeof zImpl) { +/** + * Create a Zod schema for validating filter predicates. + * + * @public + */ +export function createZodV3FilterPredicateSchema( + z: typeof zodV3.z, +): zodV3.ZodType { const primitiveSchema = z.union([ z.string(), z.number(), z.boolean(), - ]) as ZodType; + ]) as zodV3.ZodType; // eslint-disable-next-line prefer-const - let valuePredicateSchema: ZodType; + let valuePredicateSchema: zodV3.ZodType; const expressionSchema = z.lazy(() => z.union([ z.record(z.string().regex(/^(?!\$).*$/), valuePredicateSchema), z.record(z.string().regex(/^\$/), z.never()), ]), - ) as ZodType; + ) as zodV3.ZodType; const predicateSchema = z.lazy(() => z.union([ @@ -48,14 +56,33 @@ export function createEntityPredicateSchema(z: typeof zImpl) { z.object({ $any: z.array(predicateSchema) }), z.object({ $not: predicateSchema }), ]), - ) as ZodType; + ) as zodV3.ZodType; valuePredicateSchema = z.union([ primitiveSchema, z.object({ $exists: z.boolean() }), z.object({ $in: z.array(primitiveSchema) }), z.object({ $contains: predicateSchema }), - ]) as ZodType; + ]) as zodV3.ZodType; return predicateSchema; } + +/** + * Parses a value to check that it's a valid filter predicate. + * + * @public + * @param value - The value to parse. + * @returns A valid filter predicate. + * @throws An error if the value is not a valid filter predicate. + */ +export function parseFilterPredicate(value: unknown): FilterPredicate { + const schema = createZodV3FilterPredicateSchema(zodV3.z); + const result = schema.safeParse(value); + if (!result.success) { + throw new InputError( + `Invalid filter predicate: ${fromZodError(result.error)}`, + ); + } + return result.data; +} diff --git a/packages/filter-predicates/src/predicates/types.ts b/packages/filter-predicates/src/predicates/types.ts new file mode 100644 index 0000000000..8c5b298719 --- /dev/null +++ b/packages/filter-predicates/src/predicates/types.ts @@ -0,0 +1,200 @@ +/* + * Copyright 2025 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 { JsonValue } from '@backstage/types'; + +/** + * Represents future additions to the set of filter predicate operators. + * + * @remarks + * + * If you write code that explicitly inspects filter predicate expressions, you + * should be ready for the appearance of such operators and deliberately + * gracefully fail to match them. + * + * @public + */ +export type UnknownFilterPredicateOperator = { + [KOperator in `$${string}`]: JsonValue; +} & { + [KOperator in '$all' | '$any' | '$not']: never; +}; + +/** + * A filter predicate that can be evaluated against a value. + * + * @remarks + * + * A predicate is always an object at the root. The most basic use case is to + * declare keys that are dot-separated paths into a structured data value, and + * values that all need to match the corresponding property value in the data. + * + * The equality test is case-insensitive and numbers are converted to strings, + * i.e. `"Component"` and `"component"` are considered equal, and so are `7` and + * `"7"`. + * + * Example that matches catalog entity components that are of type `service`: + * + * ```json + * { + * "kind": "Component", + * "spec.type": "service" + * } + * ``` + * + * The special keys `$all`, `$any`, and `$not` are logical operators that can be + * used to combine multiple predicates or negate their result. These must be + * used standalone, i.e. they cannot be combined with other matchers. + * + * Example: + * + * ```json + * { + * "$all": [ + * { + * "kind": "Component" + * }, + * { + * "$any": [{ "spec.type": "service" }, { "spec.type": "website" }] + * } + * ] + * } + * ``` + * + * Objects with the special keys `$exists`, `$in`, and `$contains` are value + * operators that can be used to perform more advanced matching against property + * values than just equality. + * + * Example: + * + * ```json + * { + * "filter": { + * "kind": "Component", + * "spec.type": { "$in": ["service", "website"] }, + * "metadata.annotations.github.com/project-slug": { $exists: true } + * } + * } + * ``` + * + * @public + */ +export type FilterPredicate = + | FilterPredicateExpression + | FilterPredicatePrimitive + | { + /** + * Asserts that all of the given predicates must be true. + */ + $all: FilterPredicate[]; + } + | { + /** + * Asserts that at least one of the given predicates must be true. + */ + $any: FilterPredicate[]; + } + | { + /** + * Asserts that the given predicate must not be true. + */ + $not: FilterPredicate; + } + | UnknownFilterPredicateOperator; + +/** + * A filter predicate expression that matches against one or more object + * properties. + * + * @remarks + * + * Each key of a record is a dot-separated path into the entity structure, e.g. + * `metadata.name`. + * + * The values are filter predicates that are evaluated against the value of the + * property at the given path. + * + * For values that are given as primitives, the equality test is + * case-insensitive and numbers are converted to strings, i.e. `"Component"` and + * `"component"` are considered equal, and so are `7` and `"7"`. + * + * @public + */ +export type FilterPredicateExpression = { + [KPath in string]: FilterPredicateValue; +} & { + [KPath in `$${string}`]: never; +}; + +/** + * Represents future additions to the set of filter predicate value matchers. + * + * @remarks + * + * If you write code that explicitly inspects filter predicate expressions, you + * should be ready for the appearance of such matchers and deliberately + * gracefully fail to match them. + * + * @public + */ +export type UnknownFilterPredicateValueMatcher = { + [KMatcher in `$${string}`]: JsonValue; +} & { + [KMatcher in '$exists' | '$in' | '$contains' | '$hasPrefix']: never; +}; + +/** + * A filter predicate value that can be used to match against a property value. + * + * @public + */ +export type FilterPredicateValue = + | FilterPredicatePrimitive + | { + /** + * Asserts that the property exists and has any value (`true`) - or not + * (`false`). + */ + $exists: boolean; + } + | { + /** + * Asserts that the property value is any one of the given possible + * values. + */ + $in: FilterPredicatePrimitive[]; + } + | { + /** + * Asserts that the property value is an array, and that at least one of + * its elements matches the given predicate. + */ + $contains: FilterPredicate; + } + | { + /** + * Asserts that the property value is string, and that it starts with the given string. + */ + $hasPrefix: string; + } + | UnknownFilterPredicateValueMatcher; + +/** + * A primitive value that can be used in filter predicates. + * + * @public + */ +export type FilterPredicatePrimitive = string | number | boolean; diff --git a/packages/filter-predicates/src/setupTests.ts b/packages/filter-predicates/src/setupTests.ts new file mode 100644 index 0000000000..e512e4ba83 --- /dev/null +++ b/packages/filter-predicates/src/setupTests.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export {}; diff --git a/packages/frontend-test-utils/package.json b/packages/frontend-test-utils/package.json index f359a9d2b8..d0398d7256 100644 --- a/packages/frontend-test-utils/package.json +++ b/packages/frontend-test-utils/package.json @@ -32,31 +32,44 @@ }, "dependencies": { "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-app-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-app": "workspace:^", "@backstage/plugin-app-react": "workspace:^", + "@backstage/plugin-permission-common": "workspace:^", + "@backstage/plugin-permission-react": "workspace:^", "@backstage/test-utils": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", + "i18next": "^22.4.15", + "zen-observable": "^0.10.0", "zod": "^3.25.76" }, "devDependencies": { "@backstage/cli": "workspace:^", "@testing-library/jest-dom": "^6.0.0", + "@types/jest": "*", "@types/react": "^18.0.0", + "@types/zen-observable": "^0.8.0", + "msw": "^2.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", "react-router-dom": "^6.30.2" }, "peerDependencies": { "@testing-library/react": "^16.0.0", + "@types/jest": "*", "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-router-dom": "^6.30.2" }, "peerDependenciesMeta": { + "@types/jest": { + "optional": true + }, "@types/react": { "optional": true } diff --git a/packages/frontend-test-utils/report.api.md b/packages/frontend-test-utils/report.api.md index 4d1a0764ed..748eee15d3 100644 --- a/packages/frontend-test-utils/report.api.md +++ b/packages/frontend-test-utils/report.api.md @@ -3,37 +3,77 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AlertApi } from '@backstage/frontend-plugin-api'; +import { AlertMessage } from '@backstage/frontend-plugin-api'; import { AnalyticsApi } from '@backstage/frontend-plugin-api'; import { AnalyticsEvent } from '@backstage/frontend-plugin-api'; -import { ApiHolder } from '@backstage/frontend-plugin-api'; -import { ApiMock } from '@backstage/test-utils'; +import { ApiFactory } from '@backstage/frontend-plugin-api'; import { ApiRef } from '@backstage/frontend-plugin-api'; import { AppNode } from '@backstage/frontend-plugin-api'; import { AppNodeInstance } from '@backstage/frontend-plugin-api'; -import { ErrorWithContext } from '@backstage/test-utils'; +import { AuthorizeResult } from '@backstage/plugin-permission-common'; +import { Config } from '@backstage/config'; +import { ConfigApi } from '@backstage/core-plugin-api'; +import { ConfigApi as ConfigApi_2 } from '@backstage/frontend-plugin-api'; +import { DiscoveryApi } from '@backstage/frontend-plugin-api'; +import { DiscoveryApi as DiscoveryApi_2 } from '@backstage/core-plugin-api'; +import { ErrorApi } from '@backstage/core-plugin-api'; +import { ErrorApi as ErrorApi_2 } from '@backstage/frontend-plugin-api'; +import { ErrorApiError } from '@backstage/core-plugin-api'; +import { ErrorApiErrorContext } from '@backstage/core-plugin-api'; +import { EvaluatePermissionRequest } from '@backstage/plugin-permission-common'; +import { EvaluatePermissionResponse } from '@backstage/plugin-permission-common'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ExtensionDefinitionParameters } from '@backstage/frontend-plugin-api'; +import { FeatureFlag } from '@backstage/frontend-plugin-api'; +import { FeatureFlagsApi } from '@backstage/frontend-plugin-api'; +import { FeatureFlagsSaveOptions } from '@backstage/frontend-plugin-api'; +import { FeatureFlagState } from '@backstage/frontend-plugin-api'; +import { FetchApi } from '@backstage/core-plugin-api'; +import { FetchApi as FetchApi_2 } from '@backstage/frontend-plugin-api'; import { FrontendFeature } from '@backstage/frontend-plugin-api'; +import { IdentityApi } from '@backstage/frontend-plugin-api'; +import { IdentityApi as IdentityApi_2 } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; -import { JSX as JSX_2 } from 'react/jsx-runtime'; -import { mockApis } from '@backstage/test-utils'; -import { MockConfigApi } from '@backstage/test-utils'; -import { MockErrorApi } from '@backstage/test-utils'; -import { MockErrorApiOptions } from '@backstage/test-utils'; -import { MockFetchApi } from '@backstage/test-utils'; -import { MockFetchApiOptions } from '@backstage/test-utils'; -import { MockPermissionApi } from '@backstage/test-utils'; -import { MockStorageApi } from '@backstage/test-utils'; -import { MockStorageBucket } from '@backstage/test-utils'; +import { JsonValue } from '@backstage/types'; +import { Observable } from '@backstage/types'; +import { PermissionApi } from '@backstage/plugin-permission-react'; import { ReactNode } from 'react'; import { registerMswTestHooks } from '@backstage/test-utils'; import { RenderResult } from '@testing-library/react'; import { RouteRef } from '@backstage/frontend-plugin-api'; -import { testingLibraryDomTypesQueries } from '@testing-library/dom/types/queries'; +import { StorageApi } from '@backstage/core-plugin-api'; +import { StorageApi as StorageApi_2 } from '@backstage/frontend-plugin-api'; +import { StorageValueSnapshot } from '@backstage/core-plugin-api'; +import { TranslationApi } from '@backstage/core-plugin-api/alpha'; +import { TranslationApi as TranslationApi_2 } from '@backstage/frontend-plugin-api'; +import { TranslationRef } from '@backstage/core-plugin-api/alpha'; +import { TranslationSnapshot } from '@backstage/core-plugin-api/alpha'; import { withLogCollector } from '@backstage/test-utils'; -export { ApiMock }; +// @public +export type ApiMock = { + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; + +// @public +export function attachMockApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { + [mockApiFactorySymbol]: ApiFactory; +}; + +// @public +export function createApiMock( + apiRef: ApiRef, + mockFactory: () => jest.Mocked, +): (partialImpl?: Partial) => ApiMock; // @public (undocumented) export function createExtensionTester< @@ -47,7 +87,11 @@ export function createExtensionTester< }, ): ExtensionTester>; -export { ErrorWithContext }; +// @public +export type ErrorWithContext = { + error: ErrorApiError; + context?: ErrorApiErrorContext; +}; // @public (undocumented) export class ExtensionQuery { @@ -100,6 +144,20 @@ export class ExtensionTester { snapshot(): ExtensionSnapshotNode; } +// @public +export class MockAlertApi implements AlertApi { + // (undocumented) + alert$(): Observable; + clearAlerts(): void; + getAlerts(): AlertMessage[]; + // (undocumented) + post(alert: AlertMessage): void; + waitForAlert( + predicate: (alert: AlertMessage) => boolean, + timeoutMs?: number, + ): Promise; +} + // @public export class MockAnalyticsApi implements AnalyticsApi { // (undocumented) @@ -108,36 +166,268 @@ export class MockAnalyticsApi implements AnalyticsApi { getEvents(): AnalyticsEvent[]; } -export { mockApis }; +// @public +export type MockApiFactorySymbol = typeof mockApiFactorySymbol; -export { MockConfigApi }; +// @public +export namespace mockApis { + export function alert(): MockWithApiFactory; + export namespace alert { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function analytics(): MockAnalyticsApi & + MockWithApiFactory; + export namespace analytics { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function config(options?: { + data?: JsonObject; + }): MockConfigApi & MockWithApiFactory; + export namespace config { + const // (undocumented) + mock: (partialImpl?: Partial | undefined) => ApiMock; + } + export function discovery(options?: { + baseUrl?: string; + }): DiscoveryApi & MockWithApiFactory; + export namespace discovery { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function error( + options?: MockErrorApiOptions, + ): MockErrorApi & MockWithApiFactory; + export namespace error { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function featureFlags( + options?: MockFeatureFlagsApiOptions, + ): MockWithApiFactory; + export namespace featureFlags { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function fetch( + options?: MockFetchApiOptions, + ): MockFetchApi & MockWithApiFactory; + export namespace fetch { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function identity(options?: { + userEntityRef?: string; + ownershipEntityRefs?: string[]; + token?: string; + email?: string; + displayName?: string; + picture?: string; + }): MockWithApiFactory; + export namespace identity { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function permission(options?: { + authorize?: + | AuthorizeResult.ALLOW + | AuthorizeResult.DENY + | (( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY); + }): MockPermissionApi & MockWithApiFactory; + export namespace permission { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function storage(options?: { + data?: JsonObject; + }): MockStorageApi & MockWithApiFactory; + export namespace storage { + const // (undocumented) + mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } + export function translation(): MockTranslationApi & + MockWithApiFactory; + export namespace translation { + const mock: ( + partialImpl?: Partial | undefined, + ) => ApiMock; + } +} -export { MockErrorApi }; +// @public +export class MockConfigApi implements ConfigApi { + constructor({ data }: { data: JsonObject }); + get(key?: string): T; + getBoolean(key: string): boolean; + getConfig(key: string): Config; + getConfigArray(key: string): Config[]; + getNumber(key: string): number; + getOptional(key?: string): T | undefined; + getOptionalBoolean(key: string): boolean | undefined; + getOptionalConfig(key: string): Config | undefined; + getOptionalConfigArray(key: string): Config[] | undefined; + getOptionalNumber(key: string): number | undefined; + getOptionalString(key: string): string | undefined; + getOptionalStringArray(key: string): string[] | undefined; + getString(key: string): string; + getStringArray(key: string): string[]; + has(key: string): boolean; + keys(): string[]; +} -export { MockErrorApiOptions }; +// @public +export class MockErrorApi implements ErrorApi { + constructor(options?: MockErrorApiOptions); + // (undocumented) + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }>; + // (undocumented) + getErrors(): ErrorWithContext[]; + // (undocumented) + post(error: ErrorApiError, context?: ErrorApiErrorContext): void; + // (undocumented) + waitForError(pattern: RegExp, timeoutMs?: number): Promise; +} -export { MockFetchApi }; +// @public +export type MockErrorApiOptions = { + collect?: boolean; +}; -export { MockFetchApiOptions }; +// @public +export class MockFeatureFlagsApi implements FeatureFlagsApi { + constructor(options?: MockFeatureFlagsApiOptions); + clearState(): void; + // (undocumented) + getRegisteredFlags(): FeatureFlag[]; + getState(): Record; + // (undocumented) + isActive(name: string): boolean; + // (undocumented) + registerFlag(flag: FeatureFlag): void; + // (undocumented) + save(options: FeatureFlagsSaveOptions): void; + setState(states: Record): void; +} -export { MockPermissionApi }; +// @public +export interface MockFeatureFlagsApiOptions { + initialStates?: Record; +} -export { MockStorageApi }; +// @public +export class MockFetchApi implements FetchApi { + constructor(options?: MockFetchApiOptions); + get fetch(): typeof fetch; +} -export { MockStorageBucket }; +// @public +export interface MockFetchApiOptions { + baseImplementation?: undefined | 'none' | typeof fetch; + injectIdentityAuth?: + | undefined + | { + token: string; + } + | { + identityApi: Pick; + }; + resolvePluginProtocol?: + | undefined + | { + discoveryApi: Pick; + }; +} + +// @public +export class MockPermissionApi implements PermissionApi { + constructor( + requestHandler?: ( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY, + ); + // (undocumented) + authorize( + request: EvaluatePermissionRequest, + ): Promise; +} + +// @public +export class MockStorageApi implements StorageApi { + // (undocumented) + static create(data?: JsonObject): MockStorageApi; + // (undocumented) + forBucket(name: string): StorageApi; + // (undocumented) + observe$( + key: string, + ): Observable>; + // (undocumented) + remove(key: string): Promise; + // (undocumented) + set(key: string, data: T): Promise; + // (undocumented) + snapshot(key: string): StorageValueSnapshot; +} + +// @public +export class MockTranslationApi implements TranslationApi { + // (undocumented) + static create(): MockTranslationApi; + // (undocumented) + getTranslation< + TMessages extends { + [key in string]: string; + }, + >( + translationRef: TranslationRef, + ): TranslationSnapshot; + // (undocumented) + translation$< + TMessages extends { + [key in string]: string; + }, + >(): Observable>; +} + +// @public +export type MockWithApiFactory = TApi & { + [mockApiFactorySymbol]: ApiFactory; +}; export { registerMswTestHooks }; // @public -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult; // @public -export function renderTestApp( - options: RenderTestAppOptions, -): RenderResult; +export function renderTestApp( + options?: RenderTestAppOptions, +): RenderResult; // @public export type RenderTestAppOptions = { @@ -152,37 +442,26 @@ export type RenderTestAppOptions = { }; // @public -export type TestApiPairs = TestApiProviderPropsApiPairs; +export type TestApiPair = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; // @public -export const TestApiProvider: ( - props: TestApiProviderProps, -) => JSX_2.Element; +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; + +// @public +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element; // @public export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; + apis: readonly [...TestApiPairs]; children: ReactNode; }; -// @public -export type TestApiProviderPropsApiPair = TApi extends infer TImpl - ? readonly [ApiRef, Partial] - : never; - -// @public -export type TestApiProviderPropsApiPairs = { - [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; -}; - -// @public -export class TestApiRegistry implements ApiHolder { - static from( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ): TestApiRegistry; - get(api: ApiRef): T | undefined; -} - // @public export type TestAppOptions = { mountedRoutes?: { diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts new file mode 100644 index 0000000000..ddfbacd3b9 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.test.ts @@ -0,0 +1,107 @@ +/* + * Copyright 2025 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 { MockAlertApi } from './MockAlertApi'; + +describe('MockAlertApi', () => { + it('should collect alerts', () => { + const api = new MockAlertApi(); + + api.post({ message: 'Test alert 1' }); + api.post({ message: 'Test alert 2', severity: 'error' }); + api.post({ + message: 'Test alert 3', + severity: 'warning', + display: 'permanent', + }); + + expect(api.getAlerts()).toHaveLength(3); + expect(api.getAlerts()[0]).toMatchObject({ message: 'Test alert 1' }); + expect(api.getAlerts()[1]).toMatchObject({ + message: 'Test alert 2', + severity: 'error', + }); + }); + + it('should clear alerts', () => { + const api = new MockAlertApi(); + + api.post({ message: 'Test alert' }); + expect(api.getAlerts()).toHaveLength(1); + + api.clearAlerts(); + expect(api.getAlerts()).toHaveLength(0); + }); + + it('should notify observers', async () => { + const api = new MockAlertApi(); + const messages: string[] = []; + + const collected = new Promise(resolve => { + api.alert$().subscribe({ + next: alert => { + messages.push(alert.message); + if (messages.length === 2) { + resolve(); + } + }, + }); + }); + + api.post({ message: 'First' }); + api.post({ message: 'Second' }); + + await collected; + expect(messages).toEqual(['First', 'Second']); + }); + + it('should wait for matching alert', async () => { + const api = new MockAlertApi(); + + setTimeout(() => { + api.post({ message: 'Wrong alert' }); + api.post({ message: 'Right alert', severity: 'error' }); + }, 10); + + const alert = await api.waitForAlert( + a => a.message === 'Right alert', + 1000, + ); + + expect(alert).toMatchObject({ message: 'Right alert', severity: 'error' }); + }); + + it('should timeout if alert never appears', async () => { + const api = new MockAlertApi(); + + await expect( + api.waitForAlert(a => a.message === 'Never posted', 100), + ).rejects.toThrow('Timed out waiting for alert'); + }); + + it('should resolve immediately if alert already exists', async () => { + const api = new MockAlertApi(); + + api.post({ message: 'Already posted' }); + + const alert = await api.waitForAlert( + a => a.message === 'Already posted', + 1000, + ); + + expect(alert).toMatchObject({ message: 'Already posted' }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts new file mode 100644 index 0000000000..305a527b87 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/AlertApi/MockAlertApi.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2025 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 { AlertMessage } from '@backstage/frontend-plugin-api'; +import { AlertApi } from '@backstage/frontend-plugin-api'; +import { Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; + +/** + * Mock implementation of {@link @backstage/frontend-plugin-api#AlertApi} for testing alert behavior. + * + * @public + * @example + * ```ts + * const alertApi = new MockAlertApi(); + * alertApi.post({ message: 'Test alert' }); + * expect(alertApi.getAlerts()).toHaveLength(1); + * ``` + */ +export class MockAlertApi implements AlertApi { + private readonly alerts: AlertMessage[] = []; + private readonly observers = new Set<(alert: AlertMessage) => void>(); + + post(alert: AlertMessage) { + this.alerts.push(alert); + this.observers.forEach(observer => observer(alert)); + } + + alert$(): Observable { + return new ObservableImpl(subscriber => { + const observer = (alert: AlertMessage) => { + subscriber.next(alert); + }; + this.observers.add(observer); + return () => { + this.observers.delete(observer); + }; + }); + } + + /** + * Get all alerts that have been posted. + */ + getAlerts(): AlertMessage[] { + return this.alerts; + } + + /** + * Clear all collected alerts. + */ + clearAlerts(): void { + this.alerts.length = 0; + } + + /** + * Wait for an alert matching the given predicate. + * + * @param predicate - Function to test each alert + * @param timeoutMs - Maximum time to wait in milliseconds + * @returns Promise that resolves with the matching alert + */ + async waitForAlert( + predicate: (alert: AlertMessage) => boolean, + timeoutMs: number = 2000, + ): Promise { + const existing = this.alerts.find(predicate); + if (existing) { + return existing; + } + const observers = this.observers; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + observers.delete(observer); + reject(new Error('Timed out waiting for alert')); + }, timeoutMs); + + function observer(alert: AlertMessage) { + if (predicate(alert)) { + clearTimeout(timeoutId); + observers.delete(observer); + resolve(alert); + } + } + + observers.add(observer); + }); + } +} diff --git a/plugins/catalog-react/src/alpha/predicates/types.ts b/packages/frontend-test-utils/src/apis/AlertApi/index.ts similarity index 50% rename from plugins/catalog-react/src/alpha/predicates/types.ts rename to packages/frontend-test-utils/src/apis/AlertApi/index.ts index f19026c01b..919149e249 100644 --- a/plugins/catalog-react/src/alpha/predicates/types.ts +++ b/packages/frontend-test-utils/src/apis/AlertApi/index.ts @@ -14,27 +14,4 @@ * limitations under the License. */ -/** @alpha */ -export type EntityPredicate = - | EntityPredicateExpression - | EntityPredicatePrimitive - | { $all: EntityPredicate[] } - | { $any: EntityPredicate[] } - | { $not: EntityPredicate }; - -/** @alpha */ -export type EntityPredicateExpression = { - [KPath in string]: EntityPredicateValue; -} & { - [KPath in `$${string}`]: never; -}; - -/** @alpha */ -export type EntityPredicateValue = - | EntityPredicatePrimitive - | { $exists: boolean } - | { $in: EntityPredicatePrimitive[] } - | { $contains: EntityPredicate }; - -/** @alpha */ -export type EntityPredicatePrimitive = string | number | boolean; +export { MockAlertApi } from './MockAlertApi'; diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts new file mode 100644 index 0000000000..c3157a8aff --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockConfigApi } from './MockConfigApi'; + +describe('MockConfigApi', () => { + it('is able to read some basic config', () => { + const mock = new MockConfigApi({ + data: { + app: { + title: 'Hello', + }, + x: 1, + y: false, + z: [{ a: 3 }], + }, + }); + + expect(mock.getString('app.title')).toEqual('Hello'); + expect(mock.getNumber('x')).toEqual(1); + expect(mock.getBoolean('y')).toEqual(false); + expect(mock.getConfigArray('z')[0].getOptionalNumber('a')).toEqual(3); + + expect(() => mock.getString('x')).toThrow( + "Invalid type in config for key 'x' in 'mock-config', got number, wanted string", + ); + expect(() => mock.getString('missing')).toThrow( + "Missing required config value at 'missing'", + ); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts new file mode 100644 index 0000000000..349eee0d3a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/MockConfigApi.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config, ConfigReader } from '@backstage/config'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { ConfigApi } from '@backstage/core-plugin-api'; + +/** + * MockConfigApi is a thin wrapper around {@link @backstage/config#ConfigReader} + * that can be used to mock configuration using a plain object. + * + * @public + * @example + * ```tsx + * const mockConfig = new MockConfigApi({ + * data: { app: { baseUrl: 'https://example.com' } }, + * }); + * + * const rendered = await renderInTestApp( + * + * + * , + * ); + * ``` + */ +export class MockConfigApi implements ConfigApi { + private readonly config: ConfigReader; + + // NOTE: not extending in order to avoid inheriting the static `.fromConfigs` + constructor({ data }: { data: JsonObject }) { + this.config = new ConfigReader(data); + } + + /** {@inheritdoc @backstage/config#Config.has} */ + has(key: string): boolean { + return this.config.has(key); + } + /** {@inheritdoc @backstage/config#Config.keys} */ + keys(): string[] { + return this.config.keys(); + } + /** {@inheritdoc @backstage/config#Config.get} */ + get(key?: string): T { + return this.config.get(key); + } + /** {@inheritdoc @backstage/config#Config.getOptional} */ + getOptional(key?: string): T | undefined { + return this.config.getOptional(key); + } + /** {@inheritdoc @backstage/config#Config.getConfig} */ + getConfig(key: string): Config { + return this.config.getConfig(key); + } + /** {@inheritdoc @backstage/config#Config.getOptionalConfig} */ + getOptionalConfig(key: string): Config | undefined { + return this.config.getOptionalConfig(key); + } + /** {@inheritdoc @backstage/config#Config.getConfigArray} */ + getConfigArray(key: string): Config[] { + return this.config.getConfigArray(key); + } + /** {@inheritdoc @backstage/config#Config.getOptionalConfigArray} */ + getOptionalConfigArray(key: string): Config[] | undefined { + return this.config.getOptionalConfigArray(key); + } + /** {@inheritdoc @backstage/config#Config.getNumber} */ + getNumber(key: string): number { + return this.config.getNumber(key); + } + /** {@inheritdoc @backstage/config#Config.getOptionalNumber} */ + getOptionalNumber(key: string): number | undefined { + return this.config.getOptionalNumber(key); + } + /** {@inheritdoc @backstage/config#Config.getBoolean} */ + getBoolean(key: string): boolean { + return this.config.getBoolean(key); + } + /** {@inheritdoc @backstage/config#Config.getOptionalBoolean} */ + getOptionalBoolean(key: string): boolean | undefined { + return this.config.getOptionalBoolean(key); + } + /** {@inheritdoc @backstage/config#Config.getString} */ + getString(key: string): string { + return this.config.getString(key); + } + /** {@inheritdoc @backstage/config#Config.getOptionalString} */ + getOptionalString(key: string): string | undefined { + return this.config.getOptionalString(key); + } + /** {@inheritdoc @backstage/config#Config.getStringArray} */ + getStringArray(key: string): string[] { + return this.config.getStringArray(key); + } + /** {@inheritdoc @backstage/config#Config.getOptionalStringArray} */ + getOptionalStringArray(key: string): string[] | undefined { + return this.config.getOptionalStringArray(key); + } +} diff --git a/packages/frontend-test-utils/src/apis/ConfigApi/index.ts b/packages/frontend-test-utils/src/apis/ConfigApi/index.ts new file mode 100644 index 0000000000..6418899f79 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ConfigApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MockConfigApi } from './MockConfigApi'; diff --git a/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts new file mode 100644 index 0000000000..1fbfb576bd --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.test.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { MockErrorApi } from './MockErrorApi'; + +async function ifResolved(promise: Promise): Promise { + return Promise.race([promise, Promise.resolve<'not-yet'>('not-yet')]); +} + +describe('MockErrorApi', () => { + it('should throw errors by default', () => { + const api = new MockErrorApi(); + expect(() => api.post(new Error('NOPE'))).toThrow( + 'MockErrorApi received unexpected error, Error: NOPE', + ); + }); + + it('should collect errors', () => { + const api = new MockErrorApi({ collect: true }); + + api.post(new Error('e1')); + api.post(new Error('e2'), { hidden: true }); + api.post(new Error('e3')); + + expect(api.getErrors()).toEqual([ + { + error: new Error('e1'), + }, + { + error: new Error('e2'), + context: { hidden: true }, + }, + { + error: new Error('e3'), + }, + ]); + }); + + it('should not emit values', async () => { + const api = new MockErrorApi({ collect: true }); + + const promise = new Promise((resolve, reject) => { + api.error$().subscribe({ + next({ error }) { + reject(error); + }, + error(error) { + reject(error); + }, + complete() { + reject(new Error('observable was completed')); + }, + }); + + setTimeout(() => resolve('timed-out'), 100); + }); + + await expect(promise).resolves.toBe('timed-out'); + }); + + it('should wait for errors', async () => { + const api = new MockErrorApi({ collect: true }); + + const wait1 = api.waitForError(/1/); + const wait2 = api.waitForError(/2/); + + await expect(ifResolved(wait1)).resolves.toBe('not-yet'); + await expect(ifResolved(wait2)).resolves.toBe('not-yet'); + api.post(new Error('e0')); + await expect(ifResolved(wait1)).resolves.toBe('not-yet'); + await expect(ifResolved(wait2)).resolves.toBe('not-yet'); + api.post(new Error('e1')); + await expect(ifResolved(wait1)).resolves.toEqual({ + error: new Error('e1'), + }); + await expect(ifResolved(wait2)).resolves.toBe('not-yet'); + api.post(new Error('e2'), { hidden: true }); + await expect(ifResolved(wait2)).resolves.toEqual({ + error: new Error('e2'), + context: { hidden: true }, + }); + }); + + it('should time out waiting for error', async () => { + const api = new MockErrorApi({ collect: true }); + + await expect(api.waitForError(/1/, 1)).rejects.toThrow( + 'Timed out waiting for error', + ); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts new file mode 100644 index 0000000000..c7c8556355 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ErrorApi/MockErrorApi.ts @@ -0,0 +1,106 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + ErrorApi, + ErrorApiError, + ErrorApiErrorContext, +} from '@backstage/core-plugin-api'; +import { Observable } from '@backstage/types'; + +/** + * Constructor arguments for {@link MockErrorApi} + * @public + */ +export type MockErrorApiOptions = { + // Need to be true if getErrors is used in testing. + collect?: boolean; +}; + +/** + * ErrorWithContext contains error and ErrorApiErrorContext + * @public + */ +export type ErrorWithContext = { + error: ErrorApiError; + context?: ErrorApiErrorContext; +}; + +type Waiter = { + pattern: RegExp; + resolve: (err: ErrorWithContext) => void; +}; + +const nullObservable = { + subscribe: () => ({ unsubscribe: () => {}, closed: true }), + + [Symbol.observable]() { + return this; + }, +}; + +/** + * Mock implementation of the {@link core-plugin-api#ErrorApi} to be used in tests. + * Includes withForError and getErrors methods for error testing. + * @public + */ +export class MockErrorApi implements ErrorApi { + private readonly errors = new Array(); + private readonly waiters = new Set(); + + constructor(private readonly options: MockErrorApiOptions = {}) {} + + post(error: ErrorApiError, context?: ErrorApiErrorContext) { + if (this.options.collect) { + this.errors.push({ error, context }); + + for (const waiter of this.waiters) { + if (waiter.pattern.test(error.message)) { + this.waiters.delete(waiter); + waiter.resolve({ error, context }); + } + } + + return; + } + + throw new Error(`MockErrorApi received unexpected error, ${error}`); + } + + error$(): Observable<{ + error: ErrorApiError; + context?: ErrorApiErrorContext; + }> { + return nullObservable; + } + + getErrors(): ErrorWithContext[] { + return this.errors; + } + + waitForError( + pattern: RegExp, + timeoutMs: number = 2000, + ): Promise { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Timed out waiting for error')); + }, timeoutMs); + + this.waiters.add({ resolve, pattern }); + }); + } +} diff --git a/packages/frontend-test-utils/src/apis/ErrorApi/index.ts b/packages/frontend-test-utils/src/apis/ErrorApi/index.ts new file mode 100644 index 0000000000..1273a91531 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/ErrorApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MockErrorApi } from './MockErrorApi'; +export type { MockErrorApiOptions, ErrorWithContext } from './MockErrorApi'; diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts new file mode 100644 index 0000000000..0798fb48fd --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright 2025 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 { FeatureFlagState } from '@backstage/frontend-plugin-api'; +import { MockFeatureFlagsApi } from './MockFeatureFlagsApi'; + +describe('MockFeatureFlagsApi', () => { + it('should register flags', () => { + const api = new MockFeatureFlagsApi(); + + api.registerFlag({ + name: 'test-flag-1', + pluginId: 'test-plugin', + description: 'Test flag 1', + }); + + api.registerFlag({ + name: 'test-flag-2', + pluginId: 'test-plugin', + description: 'Test flag 2', + }); + + expect(api.getRegisteredFlags()).toHaveLength(2); + expect(api.getRegisteredFlags()[0].name).toBe('test-flag-1'); + }); + + it('should not register duplicate flags', () => { + const api = new MockFeatureFlagsApi(); + + api.registerFlag({ + name: 'test-flag', + pluginId: 'test-plugin', + description: 'Test flag', + }); + + api.registerFlag({ + name: 'test-flag', + pluginId: 'test-plugin', + description: 'Test flag duplicate', + }); + + expect(api.getRegisteredFlags()).toHaveLength(1); + }); + + it('should handle feature flag states', () => { + const api = new MockFeatureFlagsApi(); + + expect(api.isActive('test-flag')).toBe(false); + + api.save({ states: { 'test-flag': FeatureFlagState.Active } }); + expect(api.isActive('test-flag')).toBe(true); + + api.save({ states: { 'test-flag': FeatureFlagState.None } }); + expect(api.isActive('test-flag')).toBe(false); + }); + + it('should initialize with states', () => { + const api = new MockFeatureFlagsApi({ + initialStates: { + 'flag-1': FeatureFlagState.Active, + 'flag-2': FeatureFlagState.None, + }, + }); + + expect(api.isActive('flag-1')).toBe(true); + expect(api.isActive('flag-2')).toBe(false); + }); + + it('should save and replace states', () => { + const api = new MockFeatureFlagsApi({ + initialStates: { 'flag-1': FeatureFlagState.Active }, + }); + + expect(api.isActive('flag-1')).toBe(true); + + api.save({ + states: { + 'flag-2': FeatureFlagState.Active, + }, + }); + + expect(api.isActive('flag-1')).toBe(false); + expect(api.isActive('flag-2')).toBe(true); + }); + + it('should save and merge states', () => { + const api = new MockFeatureFlagsApi({ + initialStates: { 'flag-1': FeatureFlagState.Active }, + }); + + expect(api.isActive('flag-1')).toBe(true); + + api.save({ + states: { + 'flag-2': FeatureFlagState.Active, + }, + merge: true, + }); + + expect(api.isActive('flag-1')).toBe(true); + expect(api.isActive('flag-2')).toBe(true); + }); + + it('should get and set state', () => { + const api = new MockFeatureFlagsApi(); + + api.setState({ + 'flag-1': FeatureFlagState.Active, + 'flag-2': FeatureFlagState.None, + }); + + const state = api.getState(); + expect(state).toEqual({ + 'flag-1': FeatureFlagState.Active, + 'flag-2': FeatureFlagState.None, + }); + }); + + it('should clear state', () => { + const api = new MockFeatureFlagsApi({ + initialStates: { 'flag-1': FeatureFlagState.Active }, + }); + + expect(api.isActive('flag-1')).toBe(true); + + api.clearState(); + expect(api.isActive('flag-1')).toBe(false); + expect(api.getState()).toEqual({}); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts new file mode 100644 index 0000000000..5421687c65 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/MockFeatureFlagsApi.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2025 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 { + FeatureFlag, + FeatureFlagsApi, + FeatureFlagsSaveOptions, + FeatureFlagState, +} from '@backstage/frontend-plugin-api'; + +/** + * Options for configuring {@link MockFeatureFlagsApi}. + * + * @public + */ +export interface MockFeatureFlagsApiOptions { + /** + * Initial feature flag states. + */ + initialStates?: Record; +} + +/** + * Mock implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi} for testing feature flag behavior. + * + * @public + * @example + * ```ts + * const api = new MockFeatureFlagsApi({ + * initialStates: { 'my-feature': FeatureFlagState.Active } + * }); + * expect(api.isActive('my-feature')).toBe(true); + * ``` + */ +export class MockFeatureFlagsApi implements FeatureFlagsApi { + private readonly registeredFlags: FeatureFlag[] = []; + private readonly states: Map; + + constructor(options?: MockFeatureFlagsApiOptions) { + this.states = new Map(Object.entries(options?.initialStates ?? {})); + } + + registerFlag(flag: FeatureFlag): void { + if (!this.registeredFlags.some(f => f.name === flag.name)) { + this.registeredFlags.push(flag); + } + } + + getRegisteredFlags(): FeatureFlag[] { + return this.registeredFlags; + } + + isActive(name: string): boolean { + return this.states.get(name) === FeatureFlagState.Active; + } + + save(options: FeatureFlagsSaveOptions): void { + if (options.merge) { + for (const [name, state] of Object.entries(options.states)) { + this.states.set(name, state); + } + } else { + this.states.clear(); + for (const [name, state] of Object.entries(options.states)) { + this.states.set(name, state); + } + } + } + + /** + * Get the current state of all feature flags as a record. + */ + getState(): Record { + return Object.fromEntries(this.states); + } + + /** + * Set the state of multiple feature flags. + */ + setState(states: Record): void { + for (const [name, state] of Object.entries(states)) { + this.states.set(name, state); + } + } + + /** + * Clear all feature flag states. + */ + clearState(): void { + this.states.clear(); + } +} diff --git a/packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts new file mode 100644 index 0000000000..370e9f3b88 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FeatureFlagsApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MockFeatureFlagsApi } from './MockFeatureFlagsApi'; +export type { MockFeatureFlagsApiOptions } from './MockFeatureFlagsApi'; diff --git a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts new file mode 100644 index 0000000000..ae4486d38f --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.test.ts @@ -0,0 +1,95 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '../../..'; +import { MockFetchApi } from './MockFetchApi'; + +describe('MockFetchApi', () => { + const worker = setupServer(); + registerMswTestHooks(worker); + + it('works with default constructor', async () => { + worker.use( + http.get('http://example.com/data.json', () => + HttpResponse.json({ a: 'foo' }, { status: 200 }), + ), + ); + const m = new MockFetchApi(); + const response = await m.fetch('http://example.com/data.json'); + await expect(response.json()).resolves.toEqual({ a: 'foo' }); + }); + + describe('baseImplementation', () => { + it('works with a mock implementation', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ baseImplementation: inner }); + await m.fetch('http://example.com/data.json'); + expect(inner).toHaveBeenLastCalledWith('http://example.com/data.json'); + }); + }); + + describe('resolvePluginProtocol', () => { + it('works', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ + baseImplementation: inner, + resolvePluginProtocol: { + discoveryApi: { + getBaseUrl: async id => `https://blah.com/api/${id}`, + }, + }, + }); + await m.fetch('plugin://the-plugin/a/data.json'); + expect(inner.mock.calls[0][0]).toBe( + 'https://blah.com/api/the-plugin/a/data.json', + ); + }); + }); + + describe('injectIdentityAuth', () => { + it('works with token', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ + baseImplementation: inner, + injectIdentityAuth: { token: 'hello' }, + }); + await m.fetch('http://example.com/data.json'); + expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe( + 'Bearer hello', + ); + }); + + it('works with identityApi', async () => { + const inner = jest.fn(); + const m = new MockFetchApi({ + baseImplementation: inner, + injectIdentityAuth: { + identityApi: { + async getCredentials() { + return { token: 'hello2' }; + }, + }, + }, + }); + await m.fetch('http://example.com/data.json'); + expect(inner.mock.calls[0][0].headers?.get('authorization')).toBe( + 'Bearer hello2', + ); + }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts new file mode 100644 index 0000000000..b61ca680d1 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/MockFetchApi.ts @@ -0,0 +1,167 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createFetchApi, + FetchMiddleware, + FetchMiddlewares, +} from '@backstage/core-app-api'; +import { + DiscoveryApi, + FetchApi, + IdentityApi, +} from '@backstage/core-plugin-api'; +/** + * The options given when constructing a {@link MockFetchApi}. + * + * @public + */ +export interface MockFetchApiOptions { + /** + * Define the underlying base `fetch` implementation. + * + * @defaultValue undefined + * @remarks + * + * Leaving out this parameter or passing `undefined`, makes the API use the + * global `fetch` implementation to make real network requests. + * + * `'none'` swallows all calls and makes no requests at all. + * + * You can also pass in any `fetch` compatible callback, such as a + * `jest.fn()`, if you want to use a custom implementation or to just track + * and assert on calls. + */ + baseImplementation?: undefined | 'none' | typeof fetch; + + /** + * Add translation from `plugin://` URLs to concrete http(s) URLs, basically + * simulating what + * {@link @backstage/core-app-api#FetchMiddlewares.resolvePluginProtocol} + * does. + * + * @defaultValue undefined + * @remarks + * + * Leaving out this parameter or passing `undefined`, disables plugin protocol + * translation. + * + * To enable the feature, pass in a discovery API which is then used to + * resolve the URLs. + */ + resolvePluginProtocol?: + | undefined + | { discoveryApi: Pick }; + + /** + * Add token based Authorization headers to requests, basically simulating + * what {@link @backstage/core-app-api#FetchMiddlewares.injectIdentityAuth} + * does. + * + * @defaultValue undefined + * @remarks + * + * Leaving out this parameter or passing `undefined`, disables auth injection. + * + * To enable the feature, pass in either a static token or an identity API + * which is queried on each request for a token. + */ + injectIdentityAuth?: + | undefined + | { token: string } + | { identityApi: Pick }; +} + +/** + * A test helper implementation of {@link @backstage/core-plugin-api#FetchApi}. + * + * @public + */ +export class MockFetchApi implements FetchApi { + private readonly implementation: FetchApi; + + /** + * Creates a mock {@link @backstage/core-plugin-api#FetchApi}. + */ + constructor(options?: MockFetchApiOptions) { + this.implementation = build(options); + } + + /** {@inheritdoc @backstage/core-plugin-api#FetchApi.fetch} */ + get fetch(): typeof fetch { + return this.implementation.fetch; + } +} + +// +// Helpers +// + +function build(options?: MockFetchApiOptions): FetchApi { + return createFetchApi({ + baseImplementation: baseImplementation(options), + middleware: [ + resolvePluginProtocol(options), + injectIdentityAuth(options), + ].filter((x): x is FetchMiddleware => Boolean(x)), + }); +} + +function baseImplementation( + options: MockFetchApiOptions | undefined, +): typeof fetch { + const implementation = options?.baseImplementation; + if (!implementation) { + // Return a wrapper that evaluates global.fetch at call time, not construction time. + // This allows MSW to patch global.fetch after MockFetchApi is constructed. + return (input, init) => global.fetch(input, init); + } else if (implementation === 'none') { + return () => Promise.resolve(new Response()); + } + return implementation; +} + +function resolvePluginProtocol( + allOptions: MockFetchApiOptions | undefined, +): FetchMiddleware | undefined { + const options = allOptions?.resolvePluginProtocol; + if (!options) { + return undefined; + } + + return FetchMiddlewares.resolvePluginProtocol({ + discoveryApi: options.discoveryApi, + }); +} + +function injectIdentityAuth( + allOptions: MockFetchApiOptions | undefined, +): FetchMiddleware | undefined { + const options = allOptions?.injectIdentityAuth; + if (!options) { + return undefined; + } + + const identityApi: Pick = + 'token' in options + ? { getCredentials: async () => ({ token: options.token }) } + : options.identityApi; + + return FetchMiddlewares.injectIdentityAuth({ + identityApi: identityApi as IdentityApi, + allowUrl: () => true, + }); +} diff --git a/packages/frontend-test-utils/src/apis/FetchApi/index.ts b/packages/frontend-test-utils/src/apis/FetchApi/index.ts new file mode 100644 index 0000000000..5ac0c08ff1 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/FetchApi/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MockFetchApi } from './MockFetchApi'; +export type { MockFetchApiOptions } from './MockFetchApi'; diff --git a/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts new file mode 100644 index 0000000000..55bb7653e5 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/MockWithApiFactory.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2025 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 { ApiRef } from '@backstage/frontend-plugin-api'; +import { ApiFactory } from '@backstage/frontend-plugin-api'; + +/** + * Symbol used to mark mock API instances with their corresponding API factory. + * + * @ignore + */ +export const mockApiFactorySymbol = Symbol.for('@backstage/mock-api'); + +/** + * Symbol used to mark mock API instances with their corresponding API factory. + * This allows mock APIs to be passed directly to test utilities without + * needing to explicitly provide the [apiRef, implementation] tuple. + * + * @public + */ +export type MockApiFactorySymbol = typeof mockApiFactorySymbol; + +/** + * Type for an API instance that has been marked as a mock API. + * + * @public + */ +export type MockWithApiFactory = TApi & { + [mockApiFactorySymbol]: ApiFactory; +}; + +/** + * Helper to attach mock API metadata to an instance. + * + * @internal + */ +export function mockWithApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { [mockApiFactorySymbol]: ApiFactory } { + const marked = implementation as TImpl & { + [mockApiFactorySymbol]: ApiFactory; + }; + (marked as any)[mockApiFactorySymbol] = { + api: apiRef, + deps: {}, + factory: () => implementation, + }; + return marked; +} + +/** + * Attaches mock API factory metadata to an API instance, allowing it to be + * passed directly to test utilities without needing to explicitly provide + * the [apiRef, implementation] tuple. + * + * @public + * @example + * ```ts + * const catalogApi = attachMockApiFactory( + * catalogApiRef, + * new InMemoryCatalogClient() + * ); + * // Can now be passed directly to TestApiProvider + * + * ``` + */ +export function attachMockApiFactory( + apiRef: ApiRef, + implementation: TImpl, +): TImpl & { [mockApiFactorySymbol]: ApiFactory } { + const marked = implementation as TImpl & { + [mockApiFactorySymbol]: ApiFactory; + }; + (marked as any)[mockApiFactorySymbol] = { + api: apiRef, + deps: {}, + factory: () => implementation, + }; + return marked; +} + +/** + * Retrieves the API factory from a mock API instance. + * Returns undefined if the value is not a mock API instance. + * + * @internal + */ +export function getMockApiFactory( + value: unknown, +): ApiFactory | undefined { + if ( + typeof value === 'object' && + value !== null && + mockApiFactorySymbol in value && + typeof (value as any)[mockApiFactorySymbol] === 'object' + ) { + return (value as any)[mockApiFactorySymbol]; + } + return undefined; +} diff --git a/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts new file mode 100644 index 0000000000..b617d89cc4 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2021 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AuthorizeResult, + createPermission, +} from '@backstage/plugin-permission-common'; +import { MockPermissionApi } from './MockPermissionApi'; + +describe('MockPermissionApi', () => { + it('returns ALLOW by default', async () => { + const api = new MockPermissionApi(); + + await expect( + api.authorize({ + permission: createPermission({ name: 'permission.1', attributes: {} }), + }), + ).resolves.toEqual({ result: AuthorizeResult.ALLOW }); + }); + + it('allows passing a handler to customize the result', async () => { + const api = new MockPermissionApi(request => + request.permission.name === 'permission.2' + ? AuthorizeResult.DENY + : AuthorizeResult.ALLOW, + ); + + await expect( + api.authorize({ + permission: createPermission({ name: 'permission.2', attributes: {} }), + }), + ).resolves.toEqual({ result: AuthorizeResult.DENY }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts new file mode 100644 index 0000000000..c54a3993d3 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/PermissionApi/MockPermissionApi.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { PermissionApi } from '@backstage/plugin-permission-react'; +import { + EvaluatePermissionResponse, + EvaluatePermissionRequest, + AuthorizeResult, +} from '@backstage/plugin-permission-common'; + +/** + * Mock implementation of + * {@link @backstage/plugin-permission-react#PermissionApi}. Supply a + * requestHandler function to override the mock result returned for a given + * request. + * + * @public + */ +export class MockPermissionApi implements PermissionApi { + constructor( + private readonly requestHandler: ( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY = () => + AuthorizeResult.ALLOW, + ) {} + + async authorize( + request: EvaluatePermissionRequest, + ): Promise { + return { result: this.requestHandler(request) }; + } +} diff --git a/packages/frontend-test-utils/src/apis/PermissionApi/index.ts b/packages/frontend-test-utils/src/apis/PermissionApi/index.ts new file mode 100644 index 0000000000..f69316b811 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/PermissionApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MockPermissionApi } from './MockPermissionApi'; diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts new file mode 100644 index 0000000000..34908f561a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.test.ts @@ -0,0 +1,280 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StorageApi } from '@backstage/core-plugin-api'; +import { MockStorageApi } from './MockStorageApi'; + +describe('WebStorage Storage API', () => { + const createMockStorage = (): StorageApi => { + return MockStorageApi.create(); + }; + + it('should return undefined for values which are unset', async () => { + const storage = createMockStorage(); + + expect(storage.snapshot('myfakekey').value).toBeUndefined(); + expect(storage.snapshot('myfakekey')).toEqual({ + key: 'myfakekey', + presence: 'absent', + value: undefined, + }); + }); + + it('should allow the setting and snapshotting of the simple data structures', async () => { + const storage = createMockStorage(); + + await storage.set('myfakekey', 'helloimastring'); + await storage.set('mysecondfakekey', 1234); + await storage.set('mythirdfakekey', true); + expect(storage.snapshot('myfakekey').value).toBe('helloimastring'); + expect(storage.snapshot('mysecondfakekey').value).toBe(1234); + expect(storage.snapshot('mythirdfakekey').value).toBe(true); + expect(storage.snapshot('myfakekey')).toEqual({ + key: 'myfakekey', + presence: 'present', + value: 'helloimastring', + }); + expect(storage.snapshot('mysecondfakekey')).toEqual({ + key: 'mysecondfakekey', + presence: 'present', + value: 1234, + }); + expect(storage.snapshot('mythirdfakekey')).toEqual({ + key: 'mythirdfakekey', + presence: 'present', + value: true, + }); + }); + + it('should allow setting of complex datastructures', async () => { + const storage = createMockStorage(); + + const mockData = { + something: 'here', + is: [{ super: { complex: [{ but: 'something', why: true }] } }], + }; + + await storage.set('myfakekey', mockData); + + expect(storage.snapshot('myfakekey').value).toEqual(mockData); + expect(storage.snapshot('myfakekey')).toEqual({ + key: 'myfakekey', + presence: 'present', + value: mockData, + }); + }); + + it('should subscribe to key changes when setting a new value', async () => { + const storage = createMockStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.set('correctKey', mockData); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + presence: 'present', + value: mockData, + }); + }); + + it('should subscribe to key changes when deleting a value', async () => { + const storage = createMockStorage(); + + const wrongKeyNextHandler = jest.fn(); + const selectedKeyNextHandler = jest.fn(); + const mockData = { hello: 'im a great new value' }; + + storage.set('correctKey', mockData); + + await new Promise(resolve => { + storage.observe$('correctKey').subscribe({ + next: (...args) => { + selectedKeyNextHandler(...args); + resolve(); + }, + }); + + storage.observe$('wrongKey').subscribe({ next: wrongKeyNextHandler }); + + storage.remove('correctKey'); + }); + + expect(wrongKeyNextHandler).not.toHaveBeenCalled(); + expect(selectedKeyNextHandler).toHaveBeenCalledTimes(1); + expect(selectedKeyNextHandler).toHaveBeenCalledWith({ + key: 'correctKey', + presence: 'absent', + value: undefined, + }); + }); + + it('should be able to create different buckets for different uses', async () => { + const rootStorage = createMockStorage(); + + const firstStorage = rootStorage.forBucket('userSettings'); + const secondStorage = rootStorage.forBucket('profileSettings'); + const keyName = 'blobby'; + + await firstStorage.set(keyName, 'boop'); + await secondStorage.set(keyName, 'deerp'); + + expect(firstStorage.snapshot(keyName)).not.toBe( + secondStorage.snapshot(keyName), + ); + expect(firstStorage.snapshot(keyName).value).toBe('boop'); + expect(secondStorage.snapshot(keyName).value).toBe('deerp'); + expect(firstStorage.snapshot(keyName)).not.toEqual( + secondStorage.snapshot(keyName), + ); + expect(firstStorage.snapshot(keyName)).toEqual({ + key: keyName, + presence: 'present', + value: 'boop', + }); + expect(secondStorage.snapshot(keyName)).toEqual({ + key: keyName, + presence: 'present', + value: 'deerp', + }); + }); + + it('should not clash with other namespaces when creating buckets', async () => { + const rootStorage = createMockStorage(); + + // when getting key test2 it will translate to /profile/something/deep/test2 + const firstStorage = rootStorage + .forBucket('profile') + .forBucket('something') + .forBucket('deep'); + // when getting key deep/test2 it will translate to /profile/something/deep/test2 + const secondStorage = rootStorage.forBucket('profile/something'); + + await firstStorage.set('test2', { error: true }); + + expect(secondStorage.snapshot('deep/test2').value).toBe(undefined); + expect(secondStorage.snapshot('deep/test2')).toMatchObject({ + presence: 'absent', + }); + }); + + it('should not reuse storage instances between different rootStorages', async () => { + const rootStorage1 = createMockStorage(); + const rootStorage2 = createMockStorage(); + + const firstStorage = rootStorage1.forBucket('something'); + const secondStorage = rootStorage2.forBucket('something'); + + await firstStorage.set('test2', true); + + expect(firstStorage.snapshot('test2').value).toBe(true); + expect(secondStorage.snapshot('test2').value).toBe(undefined); + expect(firstStorage.snapshot('test2')).toEqual({ + key: 'test2', + presence: 'present', + value: true, + }); + expect(secondStorage.snapshot('test2')).toEqual({ + key: 'test2', + presence: 'absent', + value: undefined, + }); + }); + + it('should freeze the snapshot value', async () => { + const storage = createMockStorage(); + + const data = { foo: 'bar', baz: [{ foo: 'bar' }] }; + storage.set('foo', data); + + const snapshot = storage.snapshot('foo'); + expect(snapshot.value).not.toBe(data); + + if (snapshot.presence !== 'present') { + throw new Error('Invalid presence'); + } + + expect(() => { + snapshot.value.foo = 'buzz'; + }).toThrow(/Cannot assign to read only property/); + expect(() => { + snapshot.value.baz[0].foo = 'buzz'; + }).toThrow(/Cannot assign to read only property/); + expect(() => { + snapshot.value.baz.push({ foo: 'buzz' }); + }).toThrow(/Cannot add property 1, object is not extensible/); + }); + + it('should freeze observed values', async () => { + const storage = createMockStorage(); + + const snapshotPromise = new Promise(resolve => { + storage.observe$('test').subscribe({ + next: resolve, + }); + }); + + storage.set('test', { + foo: { + bar: 'baz', + }, + }); + + const snapshot = await snapshotPromise; + expect(snapshot.presence).toBe('present'); + expect(() => { + snapshot.value!.foo.bar = 'qux'; + }).toThrow(/Cannot assign to read only property 'bar' of object/); + }); + + it('should JSON serialize stored values', async () => { + const storage = createMockStorage(); + + storage.set('test', { + foo: { + toJSON() { + return { + bar: 'baz', + }; + }, + }, + }); + + expect(storage.snapshot('test')).toMatchObject({ + presence: 'present', + value: { + foo: { + bar: 'baz', + }, + }, + }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts new file mode 100644 index 0000000000..b4cf762599 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/MockStorageApi.ts @@ -0,0 +1,141 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StorageApi, StorageValueSnapshot } from '@backstage/core-plugin-api'; +import { JsonObject, JsonValue, Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; + +/** + * Mock implementation of the {@link core-plugin-api#StorageApi} to be used in tests + * + * @public + */ +export class MockStorageApi implements StorageApi { + private readonly namespace: string; + private readonly data: JsonObject; + private readonly bucketStorageApis: Map; + + private constructor( + namespace: string, + bucketStorageApis: Map, + data?: JsonObject, + ) { + this.namespace = namespace; + this.bucketStorageApis = bucketStorageApis; + this.data = { ...data }; + } + + static create(data?: JsonObject) { + // Translate a nested data object structure into a flat object with keys + // like `/a/b` with their corresponding leaf values + const keyValues: { [key: string]: any } = {}; + function put(value: { [key: string]: any }, namespace: string) { + for (const [key, val] of Object.entries(value)) { + if (typeof val === 'object' && val !== null) { + put(val, `${namespace}/${key}`); + } else { + const namespacedKey = `${namespace}/${key.replace(/^\//, '')}`; + keyValues[namespacedKey] = val; + } + } + } + put(data ?? {}, ''); + return new MockStorageApi('', new Map(), keyValues); + } + + forBucket(name: string): StorageApi { + if (!this.bucketStorageApis.has(name)) { + this.bucketStorageApis.set( + name, + new MockStorageApi( + `${this.namespace}/${name}`, + this.bucketStorageApis, + this.data, + ), + ); + } + return this.bucketStorageApis.get(name)!; + } + + snapshot(key: string): StorageValueSnapshot { + if (this.data.hasOwnProperty(this.getKeyName(key))) { + const data = this.data[this.getKeyName(key)]; + return { + key, + presence: 'present', + value: data as T, + }; + } + return { + key, + presence: 'absent', + value: undefined, + }; + } + + async set(key: string, data: T): Promise { + const serialized = JSON.parse(JSON.stringify(data), (_key, value) => { + if (typeof value === 'object' && value !== null) { + Object.freeze(value); + } + return value; + }); + this.data[this.getKeyName(key)] = serialized; + this.notifyChanges({ + key, + presence: 'present', + value: serialized, + }); + } + + async remove(key: string): Promise { + delete this.data[this.getKeyName(key)]; + this.notifyChanges({ + key, + presence: 'absent', + value: undefined, + }); + } + + observe$( + key: string, + ): Observable> { + return this.observable.filter(({ key: messageKey }) => messageKey === key); + } + + private getKeyName(key: string) { + return `${this.namespace}/${encodeURIComponent(key)}`; + } + + private notifyChanges(message: StorageValueSnapshot) { + for (const subscription of this.subscribers) { + subscription.next(message); + } + } + + private subscribers = new Set< + ZenObservable.SubscriptionObserver> + >(); + + private readonly observable = new ObservableImpl< + StorageValueSnapshot + >(subscriber => { + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); +} diff --git a/packages/frontend-test-utils/src/apis/StorageApi/index.ts b/packages/frontend-test-utils/src/apis/StorageApi/index.ts new file mode 100644 index 0000000000..6e2ebc6918 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/StorageApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { MockStorageApi } from './MockStorageApi'; diff --git a/packages/frontend-test-utils/src/apis/TestApiProvider.tsx b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx new file mode 100644 index 0000000000..215e8c8a71 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TestApiProvider.tsx @@ -0,0 +1,127 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ReactNode } from 'react'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ApiProvider } from '../../../core-app-api/src/apis/system'; +import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api'; +import { + getMockApiFactory, + type MockWithApiFactory, +} from './MockWithApiFactory'; + +/** + * Represents a single API implementation, either as a tuple of the reference and the implementation, or a mock with an embedded factory. + * @public + */ +export type TestApiPair = + | readonly [ApiRef, TApi extends infer TImpl ? Partial : never] + | MockWithApiFactory; + +/** + * Represents an array of mock API implementation. + * @public + */ +export type TestApiPairs = { + [TIndex in keyof TApiPairs]: TestApiPair; +}; + +/** @internal */ +export function resolveTestApiEntries( + apis: readonly [...TestApiPairs], +): ApiHolder { + const apiMap = new Map(); + + for (const entry of apis) { + const mockFactory = getMockApiFactory(entry); + if (mockFactory) { + apiMap.set(mockFactory.api.id, mockFactory.factory({})); + } else { + const [apiRef, impl] = entry as readonly [ApiRef, any]; + apiMap.set(apiRef.id, impl); + } + } + + return { + get: (ref: ApiRef) => apiMap.get(ref.id) as T | undefined, + }; +} + +/** + * Properties for the {@link TestApiProvider} component. + * + * @public + */ +export type TestApiProviderProps = { + apis: readonly [...TestApiPairs]; + children: ReactNode; +}; + +/** + * The `TestApiProvider` is a Utility API context provider for standalone rendering + * scenarios where you're not using `renderInTestApp` or other test utilities. + * + * It lets you provide any number of API implementations, without necessarily + * having to fully implement each of the APIs. + * + * @remarks + * + * For most test scenarios, prefer using the `apis` option in `renderInTestApp` or + * `createExtensionTester` instead of wrapping components with `TestApiProvider`. + * + * @example + * ```tsx + * import { render } from '\@testing-library/react'; + * import { myCustomApiRef } from '../apis'; + * import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils'; + * + * // Mock custom APIs with tuple syntax + * const myCustomApiMock = { myMethod: jest.fn() }; + * render( + * + * + * + * ); + * + * // Use with built-in mock APIs (no tuples needed) + * render( + * + * + * + * ); + * ``` + * + * @public + */ +export function TestApiProvider( + props: TestApiProviderProps, +): JSX.Element { + return ( + + ); +} diff --git a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx new file mode 100644 index 0000000000..4bee969a6d --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.test.tsx @@ -0,0 +1,212 @@ +/* + * Copyright 2020 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { MockTranslationApi } from './MockTranslationApi'; + +describe('MockTranslationApi', () => { + function snapshotWithMessages< + const TMessages extends { [key in string]: string }, + >(messages: TMessages) { + const translationApi = MockTranslationApi.create(); + const ref = createTranslationRef({ + id: 'test', + messages, + }); + const snapshot = translationApi.getTranslation(ref); + if (!snapshot.ready) { + throw new Error('Translation snapshot is not ready'); + } + return snapshot; + } + + it('should format plain messages', () => { + const snapshot = snapshotWithMessages({ + foo: 'Foo', + bar: 'Bar', + baz: 'Baz', + }); + + expect(snapshot.t('foo')).toBe('Foo'); + expect(snapshot.t('bar')).toBe('Bar'); + expect(snapshot.t('baz')).toBe('Baz'); + }); + + it('should support interpolation', () => { + const snapshot = snapshotWithMessages({ + shallow: 'Foo {{ bar }}', + multiple: 'Foo {{ bar }} {{ baz }}', + deep: 'Foo {{ bar.baz }}', + }); + + // @ts-expect-error + expect(snapshot.t('shallow')).toBe('Foo {{ bar }}'); + expect(snapshot.t('shallow', { bar: 'Bar' })).toBe('Foo Bar'); + + // @ts-expect-error + expect(snapshot.t('multiple')).toBe('Foo {{ bar }} {{ baz }}'); + // @ts-expect-error + expect(snapshot.t('multiple', { bar: 'Bar' })).toBe('Foo Bar {{ baz }}'); + expect(snapshot.t('multiple', { bar: 'Bar', baz: 'Baz' })).toBe( + 'Foo Bar Baz', + ); + + // @ts-expect-error + expect(snapshot.t('deep')).toBe('Foo {{ bar.baz }}'); + expect(snapshot.t('deep', { bar: { baz: 'Baz' } })).toBe('Foo Baz'); + }); + + // Escaping isn't as useful in React, since we don't need to escape HTML in strings + it('should not escape by default', () => { + const snapshot = snapshotWithMessages({ + foo: 'Foo {{ foo }}', + }); + + expect(snapshot.t('foo', { foo: '
' })).toBe('Foo
'); + expect( + snapshot.t('foo', { + foo: '
', + interpolation: { escapeValue: true }, + }), + ).toBe('Foo <div>'); + }); + + it('should support nesting', () => { + const snapshot = snapshotWithMessages({ + foo: 'Foo $t(bar) $t(baz)', + bar: 'Nested', + baz: 'Baz {{ qux }}', + }); + + expect(snapshot.t('foo', { qux: 'Deep' })).toBe('Foo Nested Baz Deep'); + }); + + it('should support jsx interpolation', () => { + const snapshot = snapshotWithMessages({ + empty: 'derp', + jsx: '={{ x }}', + jsxNested: '={{ x.y.z }}', + jsxDeep: '<$t(jsx)>', + }); + + expect(snapshot.t('jsx', { x:

hello

})).toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect(snapshot.t('jsx', { replace: { x:

hello

} })) + .toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect( + snapshot.t('jsxNested', { replace: { x: { y: { z:

hello

} } } }), + ).toMatchInlineSnapshot(` + + = +

+ hello +

+
+ `); + expect(snapshot.t('jsxDeep', { x:

hello

})).toMatchInlineSnapshot(` + + <= +

+ hello +

+ > +
+ `); + }); + + it('should support formatting', () => { + const snapshot = snapshotWithMessages({ + plain: '= {{ x }}', + number: '= {{ x, number }}', + numberFixed: '= {{ x, number(minimumFractionDigits: 2) }}', + relativeTime: '= {{ x, relativeTime }}', + relativeSeconds: '= {{ x, relativeTime(second) }}', + relativeSecondsShort: + '= {{ x, relativeTime(range: second; style: short) }}', + list: '= {{ x, list }}', + }); + + expect(snapshot.t('plain', { x: '5' })).toBe('= 5'); + expect(snapshot.t('number', { x: 5 })).toBe('= 5'); + expect( + snapshot.t('number', { + x: 5, + formatParams: { x: { minimumFractionDigits: 1 } }, + }), + ).toBe('= 5.0'); + expect(snapshot.t('numberFixed', { x: 5 })).toBe('= 5.00'); + expect( + snapshot.t('numberFixed', { + x: 5, + formatParams: { x: { minimumFractionDigits: 3 } }, + }), + ).toBe('= 5.000'); + expect(snapshot.t('relativeTime', { x: 3 })).toBe('= in 3 days'); + expect(snapshot.t('relativeTime', { x: -3 })).toBe('= 3 days ago'); + expect( + snapshot.t('relativeTime', { + x: 15, + formatParams: { x: { range: 'weeks' } }, + }), + ).toBe('= in 15 weeks'); + expect( + snapshot.t('relativeTime', { + x: 15, + formatParams: { x: { range: 'weeks', style: 'short' } }, + }), + ).toBe('= in 15 wk.'); + expect(snapshot.t('relativeSeconds', { x: 1 })).toBe('= in 1 second'); + expect(snapshot.t('relativeSeconds', { x: 2 })).toBe('= in 2 seconds'); + expect(snapshot.t('relativeSeconds', { x: -3 })).toBe('= 3 seconds ago'); + expect(snapshot.t('relativeSeconds', { x: 0 })).toBe('= in 0 seconds'); + expect(snapshot.t('relativeSecondsShort', { x: 1 })).toBe('= in 1 sec.'); + expect(snapshot.t('relativeSecondsShort', { x: 2 })).toBe('= in 2 sec.'); + expect(snapshot.t('relativeSecondsShort', { x: -3 })).toBe('= 3 sec. ago'); + expect(snapshot.t('relativeSecondsShort', { x: 0 })).toBe('= in 0 sec.'); + expect(snapshot.t('list', { x: ['a'] })).toBe('= a'); + expect(snapshot.t('list', { x: ['a', 'b'] })).toBe('= a and b'); + expect(snapshot.t('list', { x: ['a', 'b', 'c'] })).toBe('= a, b, and c'); + }); + + it('should support plurals', () => { + const snapshot = snapshotWithMessages({ + derp_one: 'derp', + derp_other: 'derps', + derpWithCount_one: '{{ count }} derp', + derpWithCount_other: '{{ count }} derps', + }); + + expect(snapshot.t('derp', { count: 1 })).toBe('derp'); + expect(snapshot.t('derp', { count: 2 })).toBe('derps'); + expect(snapshot.t('derp', { count: 0 })).toBe('derps'); + expect(snapshot.t('derpWithCount', { count: 1 })).toBe('1 derp'); + expect(snapshot.t('derpWithCount', { count: 2 })).toBe('2 derps'); + expect(snapshot.t('derpWithCount', { count: 0 })).toBe('0 derps'); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts new file mode 100644 index 0000000000..d5fa29ea73 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/TranslationApi/MockTranslationApi.ts @@ -0,0 +1,110 @@ +/* + * 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 { + TranslationApi, + TranslationRef, + TranslationSnapshot, +} from '@backstage/core-plugin-api/alpha'; +import { createInstance as createI18n, type i18n as I18n } from 'i18next'; +import ObservableImpl from 'zen-observable'; + +import { Observable } from '@backstage/types'; +// Internal import to avoid code duplication, this will lead to duplication in build output +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { toInternalTranslationRef } from '../../../../frontend-plugin-api/src/translation/TranslationRef'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { JsxInterpolator } from '../../../../core-app-api/src/apis/implementations/TranslationApi/I18nextTranslationApi'; + +const DEFAULT_LANGUAGE = 'en'; + +/** + * Mock implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}. + * + * @public + */ +export class MockTranslationApi implements TranslationApi { + static create() { + const i18n = createI18n({ + fallbackLng: DEFAULT_LANGUAGE, + supportedLngs: [DEFAULT_LANGUAGE], + interpolation: { + escapeValue: false, + // Used for the JsxInterpolator format hook + alwaysFormat: true, + }, + ns: [], + defaultNS: false, + fallbackNS: false, + + // Disable resource loading on init, meaning i18n will be ready to use immediately + initImmediate: false, + }); + + i18n.init(); + if (!i18n.isInitialized) { + throw new Error('i18next was unexpectedly not initialized'); + } + + const interpolator = JsxInterpolator.fromI18n(i18n); + + return new MockTranslationApi(i18n, interpolator); + } + + readonly #i18n: I18n; + readonly #interpolator: JsxInterpolator; + readonly #registeredRefs = new Set(); + + private constructor(i18n: I18n, interpolator: JsxInterpolator) { + this.#i18n = i18n; + this.#interpolator = interpolator; + } + + getTranslation( + translationRef: TranslationRef, + ): TranslationSnapshot { + const internalRef = toInternalTranslationRef(translationRef); + + if (!this.#registeredRefs.has(internalRef.id)) { + this.#registeredRefs.add(internalRef.id); + this.#i18n.addResourceBundle( + DEFAULT_LANGUAGE, + internalRef.id, + internalRef.getDefaultMessages(), + false, // do not merge + true, // overwrite existing + ); + } + + const t = this.#interpolator.wrapT( + this.#i18n.getFixedT(null, internalRef.id), + ); + + return { + ready: true, + t, + }; + } + + translation$(): Observable< + TranslationSnapshot + > { + // No need to implement, getTranslation will always return a ready snapshot + return new ObservableImpl>(_subscriber => { + return () => {}; + }); + } +} diff --git a/packages/frontend-test-utils/src/utils/index.ts b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts similarity index 72% rename from packages/frontend-test-utils/src/utils/index.ts rename to packages/frontend-test-utils/src/apis/TranslationApi/index.ts index 2f6bfb5f9c..2c10347545 100644 --- a/packages/frontend-test-utils/src/utils/index.ts +++ b/packages/frontend-test-utils/src/apis/TranslationApi/index.ts @@ -14,11 +14,4 @@ * limitations under the License. */ -export { - TestApiProvider, - TestApiRegistry, - type TestApiProviderPropsApiPair, - type TestApiProviderPropsApiPairs, - type TestApiPairs, -} from './TestApiProvider'; -export type { TestApiProviderProps } from './TestApiProvider'; +export { MockTranslationApi } from './MockTranslationApi'; diff --git a/packages/frontend-test-utils/src/apis/createApiMock.test.ts b/packages/frontend-test-utils/src/apis/createApiMock.test.ts new file mode 100644 index 0000000000..8b03b6df75 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/createApiMock.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2025 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 { createApiRef } from '@backstage/frontend-plugin-api'; +import { createApiMock } from './createApiMock'; +import { getMockApiFactory } from './MockWithApiFactory'; + +describe('createApiMock', () => { + type TestApi = { + greet(name: string): string; + count: number; + }; + + const testApiRef = createApiRef({ id: 'test.create-mock' }); + + it('returns a factory function that produces jest mocks', () => { + const mock = createApiMock(testApiRef, () => ({ + greet: jest.fn(), + count: 0 as any, + })); + + const api = mock(); + api.greet('world'); + expect(api.greet).toHaveBeenCalledTimes(1); + expect(api.greet).toHaveBeenCalledWith('world'); + }); + + it('applies partial implementations via mockImplementation', () => { + const mock = createApiMock(testApiRef, () => ({ + greet: jest.fn(), + count: 0 as any, + })); + + const api = mock({ greet: (name: string) => `Hello ${name}!` }); + expect(api.greet('world')).toBe('Hello world!'); + expect(api.greet).toHaveBeenCalledTimes(1); + }); + + it('preserves non-function partial values', () => { + const mock = createApiMock(testApiRef, () => ({ + greet: jest.fn(), + count: 0 as any, + })); + + const api = mock({ count: 42 }); + expect(api.count).toBe(42); + }); + + it('attaches a mock API factory via the symbol', () => { + const mock = createApiMock(testApiRef, () => ({ + greet: jest.fn(), + count: 0 as any, + })); + + const api = mock(); + const factory = getMockApiFactory(api); + expect(factory).toBeDefined(); + expect(factory!.api).toBe(testApiRef); + }); + + it('creates fresh mocks on each call', () => { + const mock = createApiMock(testApiRef, () => ({ + greet: jest.fn(), + count: 0 as any, + })); + + const api1 = mock(); + const api2 = mock(); + api1.greet('a'); + expect(api1.greet).toHaveBeenCalledTimes(1); + expect(api2.greet).toHaveBeenCalledTimes(0); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/createApiMock.ts b/packages/frontend-test-utils/src/apis/createApiMock.ts new file mode 100644 index 0000000000..759a8a1616 --- /dev/null +++ b/packages/frontend-test-utils/src/apis/createApiMock.ts @@ -0,0 +1,87 @@ +/* + * Copyright 2026 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 { ApiFactory, type ApiRef } from '@backstage/frontend-plugin-api'; +import { mockApiFactorySymbol } from './MockWithApiFactory'; + +/** + * Represents a mocked version of an API, where you automatically have access to + * the mocked versions of all of its methods along with a factory that returns + * that same mock. + * + * @public + */ +export type ApiMock = { + [mockApiFactorySymbol]: ApiFactory; +} & { + [Key in keyof TApi]: TApi[Key] extends (...args: infer Args) => infer Return + ? TApi[Key] & jest.MockInstance + : TApi[Key]; +}; + +/** + * Creates a standardized Backstage Utility API mockfactory function for + * producing mock API instances. + * + * @remarks + * + * Each method in the mock factory is a `jest.fn()`, and you can optionally pass + * partial implementations when calling the returned function. No type + * parameters should be provided to this function, they will be inferred from + * the provided API reference. + * + * @public + * @example + * ```ts + * import { createApiMock } from '@backstage/frontend-test-utils'; + * import { myApiRef } from '../apis'; + * + * // Set up the mock factory + * const mock = createApiMock(myApiRef, () => ({ + * greet: jest.fn(), + * })); + * + * // Create a mock with default behavior + * const api = mock(); + * + * // Or with a partial implementation + * const api = mock({ greet: async () => 'Hello!' }); + * expect(api.greet).toHaveBeenCalledTimes(1); + * ``` + */ +export function createApiMock( + apiRef: ApiRef, + mockFactory: () => jest.Mocked, +): (partialImpl?: Partial) => ApiMock { + return partialImpl => { + const mock = mockFactory(); + if (partialImpl) { + for (const [key, impl] of Object.entries(partialImpl)) { + if (typeof impl === 'function') { + (mock as any)[key].mockImplementation(impl); + } else { + (mock as any)[key] = impl; + } + } + } + (mock as any)[mockApiFactorySymbol] = { + api: apiRef, + deps: {}, + factory: () => mock, + }; + return mock as unknown as ApiMock; + }; +} diff --git a/packages/frontend-test-utils/src/apis/index.ts b/packages/frontend-test-utils/src/apis/index.ts index e5a0786cd0..6e09833298 100644 --- a/packages/frontend-test-utils/src/apis/index.ts +++ b/packages/frontend-test-utils/src/apis/index.ts @@ -14,18 +14,73 @@ * limitations under the License. */ +export { mockApis } from './mockApis'; +export { createApiMock, type ApiMock } from './createApiMock'; export { - MockConfigApi, - type ErrorWithContext, - MockErrorApi, - type MockErrorApiOptions, - MockFetchApi, - type MockFetchApiOptions, - MockPermissionApi, - MockStorageApi, - type MockStorageBucket, - mockApis, - type ApiMock, -} from '@backstage/test-utils'; + type MockApiFactorySymbol, + type MockWithApiFactory, + attachMockApiFactory, +} from './MockWithApiFactory'; +export { + TestApiProvider, + type TestApiProviderProps, + type TestApiPair, + type TestApiPairs, +} from './TestApiProvider'; -export { MockAnalyticsApi } from './AnalyticsApi/MockAnalyticsApi'; +/** + * Mock API classes are exported as types only to prevent direct instantiation. + * Always use the `mockApis` namespace to create mock instances (e.g., `mockApis.alert()`). + */ + +/** + * @public + */ +export type { MockAlertApi } from './AlertApi'; + +/** + * @public + */ +export type { MockAnalyticsApi } from './AnalyticsApi'; + +/** + * @public + */ +export type { MockConfigApi } from './ConfigApi'; + +/** + * @public + */ +export type { + MockErrorApi, + MockErrorApiOptions, + ErrorWithContext, +} from './ErrorApi'; + +/** + * @public + */ +export type { MockFetchApi, MockFetchApiOptions } from './FetchApi'; + +/** + * @public + */ +export type { + MockFeatureFlagsApi, + MockFeatureFlagsApiOptions, +} from './FeatureFlagsApi'; + +/** + * @public + */ +export type { MockPermissionApi } from './PermissionApi'; + +/** + * @public + */ +export type { MockStorageApi } from './StorageApi'; + +/** + * @public + */ +export type { MockTranslationApi } from './TranslationApi'; diff --git a/packages/frontend-test-utils/src/apis/mockApis.test.ts b/packages/frontend-test-utils/src/apis/mockApis.test.ts new file mode 100644 index 0000000000..9c6310ca9c --- /dev/null +++ b/packages/frontend-test-utils/src/apis/mockApis.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2025 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 { FeatureFlagState } from '@backstage/frontend-plugin-api'; +import { mockApis } from './mockApis'; + +describe('mockApis', () => { + describe('alert', () => { + it('can create an instance', () => { + const alert = mockApis.alert(); + alert.post({ message: 'test alert' }); + expect(alert.getAlerts()).toHaveLength(1); + expect(alert.getAlerts()[0]).toMatchObject({ message: 'test alert' }); + }); + + it('can clear alerts', () => { + const alert = mockApis.alert(); + alert.post({ message: 'test' }); + expect(alert.getAlerts()).toHaveLength(1); + alert.clearAlerts(); + expect(alert.getAlerts()).toHaveLength(0); + }); + + it('can create a mock and make assertions on it', () => { + const alert = mockApis.alert.mock({ + post: jest.fn(msg => { + expect(msg).toMatchObject({ message: 'test' }); + }), + }); + alert.post({ message: 'test' }); + expect(alert.post).toHaveBeenCalledTimes(1); + }); + }); + + describe('featureFlags', () => { + it('can create an instance', () => { + const featureFlags = mockApis.featureFlags({ + initialStates: { 'test-flag': FeatureFlagState.Active }, + }); + expect(featureFlags.isActive('test-flag')).toBe(true); + expect(featureFlags.isActive('other-flag')).toBe(false); + }); + + it('can save and merge state', () => { + const featureFlags = mockApis.featureFlags({ + initialStates: { 'flag-1': FeatureFlagState.Active }, + }); + + featureFlags.save({ + states: { 'flag-2': FeatureFlagState.Active }, + merge: true, + }); + + expect(featureFlags.isActive('flag-1')).toBe(true); + expect(featureFlags.isActive('flag-2')).toBe(true); + }); + + it('can set and clear state using helper methods', () => { + const featureFlags = mockApis.featureFlags(); + featureFlags.setState({ 'test-flag': FeatureFlagState.Active }); + expect(featureFlags.getState()).toEqual({ + 'test-flag': FeatureFlagState.Active, + }); + featureFlags.clearState(); + expect(featureFlags.getState()).toEqual({}); + }); + + it('can create a mock and make assertions on it', () => { + const featureFlags = mockApis.featureFlags.mock({ + isActive: jest.fn(() => true), + }); + expect(featureFlags.isActive('test')).toBe(true); + expect(featureFlags.isActive).toHaveBeenCalledTimes(1); + }); + }); + + describe('re-exported APIs from test-utils', () => { + it('should have analytics', () => { + expect(mockApis.analytics).toBeDefined(); + }); + + it('should have config', () => { + expect(mockApis.config).toBeDefined(); + }); + + it('should have discovery', () => { + expect(mockApis.discovery).toBeDefined(); + }); + + it('should have identity', () => { + expect(mockApis.identity).toBeDefined(); + }); + + it('should have permission', () => { + expect(mockApis.permission).toBeDefined(); + }); + + it('should have storage', () => { + expect(mockApis.storage).toBeDefined(); + }); + + it('should have translation', () => { + expect(mockApis.translation).toBeDefined(); + }); + }); +}); diff --git a/packages/frontend-test-utils/src/apis/mockApis.ts b/packages/frontend-test-utils/src/apis/mockApis.ts new file mode 100644 index 0000000000..9e3dce1e3a --- /dev/null +++ b/packages/frontend-test-utils/src/apis/mockApis.ts @@ -0,0 +1,465 @@ +/* + * Copyright 2025 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 { + alertApiRef, + analyticsApiRef, + configApiRef, + discoveryApiRef, + errorApiRef, + fetchApiRef, + featureFlagsApiRef, + identityApiRef, + storageApiRef, + translationApiRef, + type AnalyticsApi, + type ConfigApi, + type DiscoveryApi, + type ErrorApi, + type FetchApi, + type IdentityApi, + type StorageApi, + type TranslationApi, +} from '@backstage/frontend-plugin-api'; +import { + permissionApiRef, + type PermissionApi, +} from '@backstage/plugin-permission-react'; +import { JsonObject } from '@backstage/types'; +import { + AuthorizeResult, + EvaluatePermissionRequest, +} from '@backstage/plugin-permission-common'; +import { MockAlertApi } from './AlertApi'; +import { + MockFeatureFlagsApi, + MockFeatureFlagsApiOptions, +} from './FeatureFlagsApi'; +import { MockAnalyticsApi } from './AnalyticsApi'; +import { MockConfigApi } from './ConfigApi'; +import { MockErrorApi, MockErrorApiOptions } from './ErrorApi'; +import { MockFetchApi, MockFetchApiOptions } from './FetchApi'; +import { MockStorageApi } from './StorageApi'; +import { MockPermissionApi } from './PermissionApi'; +import { MockTranslationApi } from './TranslationApi'; +import { + mockWithApiFactory, + type MockWithApiFactory, +} from './MockWithApiFactory'; +import { createApiMock } from './createApiMock'; + +/** + * Mock implementations of the core utility APIs, to be used in tests. + * + * @public + * @remarks + * + * There are some variations among the APIs depending on what needs tests + * might have, but overall there are two main usage patterns: + * + * 1: Creating an actual fake API instance, often with a simplified version + * of functionality, by calling the mock API itself as a function. + * + * ```ts + * // The function often accepts parameters that control its behavior + * const foo = mockApis.foo(); + * ``` + * + * 2: Creating a mock API, where all methods are replaced with jest mocks, by + * calling the API's `mock` function. + * + * ```ts + * // You can optionally supply a subset of its methods to implement + * const foo = mockApis.foo.mock({ + * someMethod: () => 'mocked result', + * }); + * // After exercising your test, you can make assertions on the mock: + * expect(foo.someMethod).toHaveBeenCalledTimes(2); + * expect(foo.otherMethod).toHaveBeenCalledWith(testData); + * ``` + */ +export namespace mockApis { + /** + * Fake implementation of {@link @backstage/frontend-plugin-api#AlertApi}. + * + * @public + * @example + * + * ```tsx + * const alertApi = mockApis.alert(); + * alertApi.post({ message: 'Test alert' }); + * expect(alertApi.getAlerts()).toHaveLength(1); + * ``` + */ + export function alert(): MockWithApiFactory { + const instance = new MockAlertApi(); + return mockWithApiFactory( + alertApiRef, + instance, + ) as MockWithApiFactory; + } + /** + * Mock helpers for {@link @backstage/frontend-plugin-api#AlertApi}. + * + * @see {@link @backstage/frontend-plugin-api#mockApis.alert} + * @public + */ + export namespace alert { + /** + * Creates a mock implementation of + * {@link @backstage/frontend-plugin-api#AlertApi}. All methods are + * replaced with jest mock functions, and you can optionally pass in a + * subset of methods with an explicit implementation. + * + * @public + */ + export const mock = createApiMock(alertApiRef, () => ({ + post: jest.fn(), + alert$: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. + * + * @public + * @example + * + * ```tsx + * const featureFlagsApi = mockApis.featureFlags({ + * initialStates: { 'my-feature': FeatureFlagState.Active }, + * }); + * expect(featureFlagsApi.isActive('my-feature')).toBe(true); + * ``` + */ + export function featureFlags( + options?: MockFeatureFlagsApiOptions, + ): MockWithApiFactory { + const instance = new MockFeatureFlagsApi(options); + return mockWithApiFactory( + featureFlagsApiRef, + instance, + ) as MockWithApiFactory; + } + /** + * Mock helpers for {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. + * + * @see {@link @backstage/frontend-plugin-api#mockApis.featureFlags} + * @public + */ + export namespace featureFlags { + /** + * Creates a mock implementation of + * {@link @backstage/frontend-plugin-api#FeatureFlagsApi}. All methods are + * replaced with jest mock functions, and you can optionally pass in a + * subset of methods with an explicit implementation. + * + * @public + */ + export const mock = createApiMock(featureFlagsApiRef, () => ({ + registerFlag: jest.fn(), + getRegisteredFlags: jest.fn(), + isActive: jest.fn(), + save: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#AnalyticsApi}. + * + * @public + */ + export function analytics(): MockAnalyticsApi & + MockWithApiFactory { + const instance = new MockAnalyticsApi(); + return mockWithApiFactory(analyticsApiRef, instance) as MockAnalyticsApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#AnalyticsApi}. + * + * @public + */ + export namespace analytics { + export const mock = createApiMock(analyticsApiRef, () => ({ + captureEvent: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api/alpha#TranslationApi}. + * By default returns the default translation. + * + * @public + */ + export function translation(): MockTranslationApi & + MockWithApiFactory { + const instance = MockTranslationApi.create(); + return mockWithApiFactory( + translationApiRef, + instance, + ) as MockTranslationApi & MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api/alpha#TranslationApi}. + * + * @see {@link @backstage/frontend-plugin-api#mockApis.translation} + * @public + */ + export namespace translation { + /** + * Creates a mock of {@link @backstage/core-plugin-api/alpha#TranslationApi}. + * + * @public + */ + export const mock = createApiMock(translationApiRef, () => ({ + getTranslation: jest.fn(), + translation$: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#ConfigApi}. + * + * @public + */ + export function config(options?: { + data?: JsonObject; + }): MockConfigApi & MockWithApiFactory { + const instance = new MockConfigApi({ data: options?.data ?? {} }); + return mockWithApiFactory(configApiRef, instance) as MockConfigApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#ConfigApi}. + * + * @public + */ + export namespace config { + export const mock = createApiMock(configApiRef, () => ({ + has: jest.fn(), + keys: jest.fn(), + get: jest.fn(), + getOptional: jest.fn(), + getConfig: jest.fn(), + getOptionalConfig: jest.fn(), + getConfigArray: jest.fn(), + getOptionalConfigArray: jest.fn(), + getNumber: jest.fn(), + getOptionalNumber: jest.fn(), + getBoolean: jest.fn(), + getOptionalBoolean: jest.fn(), + getString: jest.fn(), + getOptionalString: jest.fn(), + getStringArray: jest.fn(), + getOptionalStringArray: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#DiscoveryApi}. + * + * @public + */ + export function discovery(options?: { + baseUrl?: string; + }): DiscoveryApi & MockWithApiFactory { + const baseUrl = options?.baseUrl ?? 'http://example.com'; + const instance: DiscoveryApi = { + async getBaseUrl(pluginId: string) { + return `${baseUrl}/api/${pluginId}`; + }, + }; + return mockWithApiFactory(discoveryApiRef, instance) as DiscoveryApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#DiscoveryApi}. + * + * @public + */ + export namespace discovery { + export const mock = createApiMock(discoveryApiRef, () => ({ + getBaseUrl: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#IdentityApi}. + * + * @public + */ + export function identity(options?: { + userEntityRef?: string; + ownershipEntityRefs?: string[]; + token?: string; + email?: string; + displayName?: string; + picture?: string; + }): MockWithApiFactory { + const { + userEntityRef = 'user:default/test', + ownershipEntityRefs = ['user:default/test'], + token, + email, + displayName, + picture, + } = options ?? {}; + const instance: IdentityApi = { + async getBackstageIdentity() { + return { type: 'user', ownershipEntityRefs, userEntityRef }; + }, + async getCredentials() { + return { token }; + }, + async getProfileInfo() { + return { email, displayName, picture }; + }, + async signOut() {}, + }; + return mockWithApiFactory(identityApiRef, instance) as IdentityApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#IdentityApi}. + * + * @public + */ + export namespace identity { + export const mock = createApiMock(identityApiRef, () => ({ + getBackstageIdentity: jest.fn(), + getCredentials: jest.fn(), + getProfileInfo: jest.fn(), + signOut: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/plugin-permission-react#PermissionApi}. + * + * @public + */ + export function permission(options?: { + authorize?: + | AuthorizeResult.ALLOW + | AuthorizeResult.DENY + | (( + request: EvaluatePermissionRequest, + ) => AuthorizeResult.ALLOW | AuthorizeResult.DENY); + }): MockPermissionApi & MockWithApiFactory { + const authorizeInput = options?.authorize; + const handler = + typeof authorizeInput === 'function' + ? authorizeInput + : () => authorizeInput ?? AuthorizeResult.ALLOW; + const instance = new MockPermissionApi(handler); + return mockWithApiFactory(permissionApiRef, instance) as MockPermissionApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/plugin-permission-react#PermissionApi}. + * + * @public + */ + export namespace permission { + export const mock = createApiMock(permissionApiRef, () => ({ + authorize: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#StorageApi}. + * + * @public + */ + export function storage(options?: { + data?: JsonObject; + }): MockStorageApi & MockWithApiFactory { + const instance = MockStorageApi.create(options?.data); + return mockWithApiFactory(storageApiRef, instance) as MockStorageApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#StorageApi}. + * + * @public + */ + export namespace storage { + export const mock = createApiMock(storageApiRef, () => ({ + forBucket: jest.fn(), + snapshot: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + observe$: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#ErrorApi}. + * + * @public + */ + export function error( + options?: MockErrorApiOptions, + ): MockErrorApi & MockWithApiFactory { + const instance = new MockErrorApi(options); + return mockWithApiFactory(errorApiRef, instance) as MockErrorApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#ErrorApi}. + * + * @public + */ + export namespace error { + export const mock = createApiMock(errorApiRef, () => ({ + post: jest.fn(), + error$: jest.fn(), + })); + } + + /** + * Fake implementation of {@link @backstage/core-plugin-api#FetchApi}. + * + * @public + */ + export function fetch( + options?: MockFetchApiOptions, + ): MockFetchApi & MockWithApiFactory { + const instance = new MockFetchApi(options); + return mockWithApiFactory(fetchApiRef, instance) as MockFetchApi & + MockWithApiFactory; + } + + /** + * Mock helpers for {@link @backstage/core-plugin-api#FetchApi}. + * + * @public + */ + export namespace fetch { + export const mock = createApiMock(fetchApiRef, () => ({ + fetch: jest.fn(), + })); + } +} diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx index 8293872e29..9ce1863186 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx @@ -173,11 +173,11 @@ describe('createExtensionTester', () => { }); const tester = createExtensionTester(extension, { - apis: [[analyticsApiRef, analyticsApiMock]], + apis: [[analyticsApiRef, analyticsApiMock] as const], }); renderInTestApp(tester.reactElement(), { - apis: [[analyticsApiRef, analyticsApiMock]], + apis: [[analyticsApiRef, analyticsApiMock] as const], }); expect(screen.getByText('Test')).toBeInTheDocument(); diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.tsx index 91a7bf9022..3a373b3b3a 100644 --- a/packages/frontend-test-utils/src/app/createExtensionTester.tsx +++ b/packages/frontend-test-utils/src/app/createExtensionTester.tsx @@ -38,7 +38,7 @@ import { readAppExtensionsConfig } from '../../../frontend-app-api/src/tree/read // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { createErrorCollector } from '../../../frontend-app-api/src/wiring/createErrorCollector'; import { OpaqueExtensionDefinition } from '@internal/frontend'; -import { TestApiRegistry, type TestApiPairs } from '../utils'; +import { resolveTestApiEntries, TestApiPairs } from '../apis/TestApiProvider'; /** * Represents a snapshot of an extension in the app tree. @@ -96,7 +96,7 @@ export class ExtensionTester { /** @internal */ static forSubject< T extends ExtensionDefinitionParameters, - TApiPairs extends any[], + const TApiPairs extends any[], >( subject: ExtensionDefinition, options?: { @@ -281,9 +281,7 @@ export class ExtensionTester { collector, ); - const apiHolder = this.#apis - ? TestApiRegistry.from(...this.#apis) - : TestApiRegistry.from(); + const apiHolder = resolveTestApiEntries(this.#apis ?? []); instantiateAppNodeTree(tree.root, apiHolder, collector); diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx index 334722f38a..c9d90715f6 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.test.tsx @@ -16,11 +16,8 @@ import { useCallback } from 'react'; import { screen, fireEvent } from '@testing-library/react'; -import { - MockAnalyticsApi, - TestApiProvider, -} from '@backstage/frontend-test-utils'; -import { analyticsApiRef, useAnalytics } from '@backstage/frontend-plugin-api'; +import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils'; +import { useAnalytics } from '@backstage/frontend-plugin-api'; import { Routes, Route } from 'react-router-dom'; import { renderInTestApp } from './renderInTestApp'; @@ -47,10 +44,10 @@ describe('renderInTestApp', () => { ); }; - const analyticsApiMock = new MockAnalyticsApi(); + const analyticsApiMock = mockApis.analytics(); renderInTestApp( - + , ); @@ -94,10 +91,10 @@ describe('renderInTestApp', () => { ); }; - const analyticsApiMock = new MockAnalyticsApi(); + const analyticsApiMock = mockApis.analytics(); renderInTestApp(, { - apis: [[analyticsApiRef, analyticsApiMock]], + apis: [analyticsApiMock], }); fireEvent.click(screen.getByRole('button', { name: 'Click me' })); diff --git a/packages/frontend-test-utils/src/app/renderInTestApp.tsx b/packages/frontend-test-utils/src/app/renderInTestApp.tsx index bcc6e5948e..10b36b68b4 100644 --- a/packages/frontend-test-utils/src/app/renderInTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderInTestApp.tsx @@ -32,12 +32,14 @@ import { FrontendFeature, createFrontendModule, createApiFactory, + type ApiRef, } from '@backstage/frontend-plugin-api'; import { RouterBlueprint } from '@backstage/plugin-app-react'; import appPlugin from '@backstage/plugin-app'; -import { type TestApiPairs } from '../utils'; +import { getMockApiFactory } from '../apis/MockWithApiFactory'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; +import { TestApiPairs } from '../apis/TestApiProvider'; const DEFAULT_MOCK_CONFIG = { app: { baseUrl: 'http://localhost:3000' }, @@ -88,11 +90,10 @@ export type TestAppOptions = { * * @example * ```ts - * import { identityApiRef } from '@backstage/frontend-plugin-api'; * import { mockApis } from '@backstage/frontend-test-utils'; * * renderInTestApp(, { - * apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]], + * apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })], * }) * ``` */ @@ -163,7 +164,7 @@ const appPluginOverride = appPlugin.withOverrides({ * @public * Renders the given element in a test app, for use in unit tests. */ -export function renderInTestApp( +export function renderInTestApp( element: JSX.Element, options?: TestAppOptions, ): RenderResult { @@ -241,9 +242,14 @@ export function renderInTestApp( }, ]), __internal: options?.apis && { - apiFactoryOverrides: options.apis.map(([apiRef, implementation]) => - createApiFactory(apiRef, implementation), - ), + apiFactoryOverrides: options.apis.map(entry => { + const mockFactory = getMockApiFactory(entry); + if (mockFactory) { + return mockFactory; + } + const [apiRef, implementation] = entry as readonly [ApiRef, any]; + return createApiFactory(apiRef, implementation); + }), }, } as CreateSpecializedAppInternalOptions); diff --git a/packages/frontend-test-utils/src/app/renderTestApp.tsx b/packages/frontend-test-utils/src/app/renderTestApp.tsx index b83ae7832a..858ad462f4 100644 --- a/packages/frontend-test-utils/src/app/renderTestApp.tsx +++ b/packages/frontend-test-utils/src/app/renderTestApp.tsx @@ -25,16 +25,18 @@ import { ExtensionDefinition, FrontendFeature, RouteRef, + type ApiRef, } from '@backstage/frontend-plugin-api'; -import { render } from '@testing-library/react'; +import { render, type RenderResult } from '@testing-library/react'; import appPlugin from '@backstage/plugin-app'; import { JsonObject } from '@backstage/types'; import { ConfigReader } from '@backstage/config'; import { MemoryRouter } from 'react-router-dom'; import { RouterBlueprint } from '@backstage/plugin-app-react'; -import { type TestApiPairs } from '../utils'; +import { getMockApiFactory } from '../apis/MockWithApiFactory'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import type { CreateSpecializedAppInternalOptions } from '../../../frontend-app-api/src/wiring/createSpecializedApp'; +import { TestApiPairs } from '../apis/TestApiProvider'; const DEFAULT_MOCK_CONFIG = { app: { baseUrl: 'http://localhost:3000' }, @@ -89,11 +91,10 @@ export type RenderTestAppOptions = { * * @example * ```ts - * import { identityApiRef } from '@backstage/frontend-plugin-api'; * import { mockApis } from '@backstage/frontend-test-utils'; * * renderTestApp({ - * apis: [[identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })]], + * apis: [mockApis.identity({ userEntityRef: 'user:default/guest' })], * extensions: [...], * }) * ``` @@ -115,12 +116,12 @@ const appPluginOverride = appPlugin.withOverrides({ * * @public */ -export function renderTestApp( - options: RenderTestAppOptions, -) { - const extensions = [...(options.extensions ?? [])]; +export function renderTestApp( + options?: RenderTestAppOptions, +): RenderResult { + const extensions = [...(options?.extensions ?? [])]; - if (options.mountedRoutes) { + if (options?.mountedRoutes) { for (const [path, routeRef] of Object.entries(options.mountedRoutes)) { extensions.push( createExtension({ @@ -150,7 +151,7 @@ export function renderTestApp( params: { component: ({ children }) => ( ( appPluginOverride, ]; - if (options.features) { + if (options?.features) { features.push(...options.features); } @@ -183,9 +184,14 @@ export function renderTestApp( }, ]), __internal: options?.apis && { - apiFactoryOverrides: options.apis.map(([apiRef, implementation]) => - createApiFactory(apiRef, implementation), - ), + apiFactoryOverrides: options.apis.map(entry => { + const mockFactory = getMockApiFactory(entry); + if (mockFactory) { + return mockFactory; + } + const [apiRef, implementation] = entry as readonly [ApiRef, any]; + return createApiFactory(apiRef, implementation); + }), }, } as CreateSpecializedAppInternalOptions); diff --git a/packages/frontend-test-utils/src/index.ts b/packages/frontend-test-utils/src/index.ts index b709e7ac03..b4e7a4e007 100644 --- a/packages/frontend-test-utils/src/index.ts +++ b/packages/frontend-test-utils/src/index.ts @@ -22,10 +22,6 @@ export * from './apis'; export * from './app'; -export * from './utils'; - -// Explicit export to satisfy API Extractor -export type { TestApiPairs } from './utils'; export { withLogCollector } from '@backstage/test-utils'; diff --git a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx b/packages/frontend-test-utils/src/utils/TestApiProvider.tsx deleted file mode 100644 index 97529033d0..0000000000 --- a/packages/frontend-test-utils/src/utils/TestApiProvider.tsx +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright 2020 The Backstage Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { ReactNode } from 'react'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { ApiProvider } from '../../../core-app-api/src/apis/system'; -import { ApiHolder, ApiRef } from '@backstage/frontend-plugin-api'; - -/** - * Helper type for representing an API reference paired with a partial implementation. - * @public - */ -export type TestApiProviderPropsApiPair = TApi extends infer TImpl - ? readonly [ApiRef, Partial] - : never; - -/** - * Helper type for representing an array of API reference pairs. - * @public - */ -export type TestApiProviderPropsApiPairs = { - [TIndex in keyof TApiPairs]: TestApiProviderPropsApiPair; -}; - -/** - * Shorter alias for TestApiProviderPropsApiPairs for use in function signatures. - * @public - */ -export type TestApiPairs = TestApiProviderPropsApiPairs; - -/** - * Properties for the {@link TestApiProvider} component. - * - * @public - */ -export type TestApiProviderProps = { - apis: readonly [...TestApiProviderPropsApiPairs]; - children: ReactNode; -}; - -/** - * The `TestApiRegistry` is an {@link @backstage/frontend-plugin-api#ApiHolder} implementation - * that is particularly well suited for development and test environments such as - * unit tests, storybooks, and isolated plugin development setups. - * - * @remarks - * - * For most test scenarios, prefer using the `apis` option in `renderInTestApp` or - * `createExtensionTester` instead of creating a registry directly. - * - * @public - */ -export class TestApiRegistry implements ApiHolder { - /** - * Creates a new {@link TestApiRegistry} with a list of API implementation pairs. - * - * Similar to the {@link TestApiProvider}, there is no need to provide a full - * implementation of each API, it's enough to implement the methods that are tested. - * - * @example - * ```ts - * import { identityApiRef } from '@backstage/frontend-plugin-api'; - * import { mockApis } from '@backstage/frontend-test-utils'; - * - * const apis = TestApiRegistry.from( - * [identityApiRef, mockApis.identity({ userEntityRef: 'user:default/guest' })], - * ); - * ``` - * - * @public - * @param apis - A list of pairs mapping an ApiRef to its respective implementation. - */ - static from( - ...apis: readonly [...TestApiProviderPropsApiPairs] - ) { - return new TestApiRegistry( - new Map(apis.map(([api, impl]) => [api.id, impl])), - ); - } - - private constructor(private readonly apis: Map) {} - - /** - * Returns an implementation of the API. - * - * @public - */ - get(api: ApiRef): T | undefined { - return this.apis.get(api.id) as T | undefined; - } -} - -/** - * The `TestApiProvider` is a Utility API context provider for standalone rendering - * scenarios where you're not using `renderInTestApp` or other test utilities. - * - * It lets you provide any number of API implementations, without necessarily - * having to fully implement each of the APIs. - * - * @remarks - * - * For most test scenarios, prefer using the `apis` option in `renderInTestApp` or - * `createExtensionTester` instead of wrapping components with `TestApiProvider`. - * - * @example - * ```tsx - * import { render } from '\@testing-library/react'; - * import { identityApiRef } from '\@backstage/frontend-plugin-api'; - * import { TestApiProvider, mockApis } from '\@backstage/frontend-test-utils'; - * - * render( - * - * - * - * ); - * ``` - * - * @public - */ -export const TestApiProvider = ( - props: TestApiProviderProps, -) => { - return ( - - ); -}; diff --git a/packages/repo-tools/src/commands/type-deps/type-deps.ts b/packages/repo-tools/src/commands/type-deps/type-deps.ts index d6049f347c..80d30a41a8 100644 --- a/packages/repo-tools/src/commands/type-deps/type-deps.ts +++ b/packages/repo-tools/src/commands/type-deps/type-deps.ts @@ -89,7 +89,21 @@ function findAllDeps(declSrc: string) { .filter(n => !n.startsWith('.')) // We allow references to these without an explicit dependency. .filter(n => !['node', 'react'].includes(n)); - return Array.from(new Set([...importedDeps, ...referencedDeps])); + + // Detect ambient global type namespaces (e.g. jest.Mocked, jest.MockInstance) + // that are used in type positions but don't appear as explicit imports. + // Strip comments first to avoid false positives from JSDoc examples. + const strippedSrc = declSrc + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + const ambientDeps: string[] = []; + if (/\bjest\.\w/.test(strippedSrc)) { + ambientDeps.push('jest'); + } + + return Array.from( + new Set([...importedDeps, ...referencedDeps, ...ambientDeps]), + ); } /** diff --git a/packages/techdocs-cli/package.json b/packages/techdocs-cli/package.json index 390611e9d9..77a76d475c 100644 --- a/packages/techdocs-cli/package.json +++ b/packages/techdocs-cli/package.json @@ -65,7 +65,7 @@ "@types/node": "^22.13.14", "@types/serve-handler": "^6.1.0", "@types/webpack-env": "^1.15.3", - "find-process": "^1.4.5", + "find-process": "^2.0.0", "nodemon": "^3.0.1", "techdocs-cli-embedded-app": "link:../techdocs-cli-embedded-app" } diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index f6127ee5c7..591cb149e8 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -74,12 +74,16 @@ }, "peerDependencies": { "@testing-library/react": "^16.0.0", + "@types/jest": "*", "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-router-dom": "^6.30.2" }, "peerDependenciesMeta": { + "@types/jest": { + "optional": true + }, "@types/react": { "optional": true } diff --git a/packages/ui/css/styles.css b/packages/ui/css/styles.css deleted file mode 100644 index f501f5144b..0000000000 --- a/packages/ui/css/styles.css +++ /dev/null @@ -1,9272 +0,0 @@ -@layer tokens, base, components, utilities; - -/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */ -@layer tokens { - :root { - --bui-font-regular: system-ui; - --bui-font-monospace: ui-monospace, "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", monospace; - --bui-font-weight-regular: 400; - --bui-font-weight-bold: 600; - --bui-font-size-1: .625rem; - --bui-font-size-2: .75rem; - --bui-font-size-3: .875rem; - --bui-font-size-4: 1rem; - --bui-font-size-5: 1.25rem; - --bui-font-size-6: 1.5rem; - --bui-font-size-7: 2rem; - --bui-font-size-8: 3rem; - --bui-font-size-9: 4rem; - --bui-font-size-10: 5.75rem; - --bui-space: .25rem; - --bui-space-0_5: calc(var(--bui-space) * .5); - --bui-space-1: var(--bui-space); - --bui-space-1_5: calc(var(--bui-space) * 1.5); - --bui-space-2: calc(var(--bui-space) * 2); - --bui-space-3: calc(var(--bui-space) * 3); - --bui-space-4: calc(var(--bui-space) * 4); - --bui-space-5: calc(var(--bui-space) * 5); - --bui-space-6: calc(var(--bui-space) * 6); - --bui-space-7: calc(var(--bui-space) * 7); - --bui-space-8: calc(var(--bui-space) * 8); - --bui-space-9: calc(var(--bui-space) * 9); - --bui-space-10: calc(var(--bui-space) * 10); - --bui-space-11: calc(var(--bui-space) * 11); - --bui-space-12: calc(var(--bui-space) * 12); - --bui-space-13: calc(var(--bui-space) * 13); - --bui-space-14: calc(var(--bui-space) * 14); - --bui-radius-1: calc(.125rem); - --bui-radius-2: calc(.25rem); - --bui-radius-3: calc(.5rem); - --bui-radius-4: calc(.75rem); - --bui-radius-5: calc(1rem); - --bui-radius-6: calc(1.25rem); - --bui-radius-full: 9999px; - --bui-black: #000; - --bui-white: #fff; - --bui-bg-solid: #1f5493; - --bui-bg-solid-hover: #163a66; - --bui-bg-solid-pressed: #0f2b4e; - --bui-bg-solid-disabled: #163a66; - --bui-bg-neutral-0: #f8f8f8; - --bui-bg-neutral-1: #fff; - --bui-bg-neutral-1-hover: oklch(0% 0 0 / .12); - --bui-bg-neutral-1-pressed: oklch(0% 0 0 / .16); - --bui-bg-neutral-1-disabled: oklch(0% 0 0 / .06); - --bui-bg-neutral-2: oklch(0% 0 0 / .06); - --bui-bg-neutral-2-hover: oklch(0% 0 0 / .12); - --bui-bg-neutral-2-pressed: oklch(0% 0 0 / .16); - --bui-bg-neutral-2-disabled: oklch(0% 0 0 / .06); - --bui-bg-neutral-3: oklch(0% 0 0 / .06); - --bui-bg-neutral-3-hover: oklch(0% 0 0 / .12); - --bui-bg-neutral-3-pressed: oklch(0% 0 0 / .16); - --bui-bg-neutral-3-disabled: oklch(0% 0 0 / .06); - --bui-bg-neutral-4: oklch(0% 0 0 / .06); - --bui-bg-neutral-4-hover: oklch(0% 0 0 / .12); - --bui-bg-neutral-4-pressed: oklch(0% 0 0 / .16); - --bui-bg-neutral-4-disabled: oklch(0% 0 0 / .06); - --bui-bg-danger: #ffe2e2; - --bui-bg-warning: #ffedd5; - --bui-bg-success: #dcfce7; - --bui-bg-info: #dbeafe; - --bui-fg-primary: var(--bui-black); - --bui-fg-secondary: oklch(0% 0 0 / .5); - --bui-fg-disabled: oklch(0% 0 0 / .28); - --bui-fg-solid: var(--bui-white); - --bui-fg-solid-disabled: #98a8bc; - --bui-fg-danger-on-bg: #991919; - --bui-fg-warning-on-bg: #92310a; - --bui-fg-success-on-bg: #116932; - --bui-fg-info-on-bg: #173da6; - --bui-fg-danger: #ec3b18; - --bui-fg-warning: #ef7a32; - --bui-fg-success: #1ed760; - --bui-fg-info: #0d74ce; - --bui-border: #0000001a; - --bui-border-hover: #0003; - --bui-border-pressed: #0006; - --bui-border-disabled: #0000001a; - --bui-border-info: #7ea9d6; - --bui-border-danger: #f87a7a; - --bui-border-warning: #e36d05; - --bui-border-success: #53db83; - --bui-ring: #1f5493; - --bui-scrollbar: #a0a0a03b; - --bui-scrollbar-thumb: #a0a0a0; - --bui-shadow: 0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a; - --bui-animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite; - } - - [data-theme-mode="dark"] { - --bui-bg-solid: #9cc9ff; - --bui-bg-solid-hover: #83b9fd; - --bui-bg-solid-pressed: #83b9fd; - --bui-bg-solid-disabled: #1b3d68; - --bui-bg-neutral-0: #333; - --bui-bg-neutral-1: oklch(100% 0 0 / .1); - --bui-bg-neutral-1-hover: oklch(100% 0 0 / .14); - --bui-bg-neutral-1-pressed: oklch(100% 0 0 / .2); - --bui-bg-neutral-1-disabled: oklch(100% 0 0 / .1); - --bui-bg-neutral-2: oklch(100% 0 0 / .06); - --bui-bg-neutral-2-hover: oklch(100% 0 0 / .1); - --bui-bg-neutral-2-pressed: oklch(100% 0 0 / .16); - --bui-bg-neutral-2-disabled: oklch(100% 0 0 / .06); - --bui-bg-neutral-3: oklch(100% 0 0 / .08); - --bui-bg-neutral-3-hover: oklch(100% 0 0 / .12); - --bui-bg-neutral-3-pressed: oklch(100% 0 0 / .2); - --bui-bg-neutral-3-disabled: oklch(100% 0 0 / .08); - --bui-bg-neutral-4: oklch(100% 0 0 / .08); - --bui-bg-neutral-4-hover: oklch(100% 0 0 / .12); - --bui-bg-neutral-4-pressed: oklch(100% 0 0 / .2); - --bui-bg-neutral-4-disabled: oklch(100% 0 0 / .08); - --bui-bg-danger: #300c0c; - --bui-bg-warning: #302008; - --bui-bg-success: #042713; - --bui-bg-info: #132049; - --bui-fg-primary: var(--bui-white); - --bui-fg-secondary: oklch(0% 0 0 / .5); - --bui-fg-disabled: oklch(0% 0 0 / .28); - --bui-fg-solid: #101821; - --bui-fg-solid-disabled: #6191cc; - --bui-fg-danger-on-bg: #fca5a5; - --bui-fg-warning-on-bg: #fdba74; - --bui-fg-success-on-bg: #86efac; - --bui-fg-info-on-bg: #a3cfff; - --bui-fg-danger: #ff5a30; - --bui-fg-warning: #ffa057; - --bui-fg-success: #1ed760; - --bui-fg-info: #70b8ff; - --bui-border: #ffffff1f; - --bui-border-hover: #fff6; - --bui-border-pressed: #ffffff80; - --bui-border-disabled: #fff3; - --bui-border-info: #7ea9d6; - --bui-border-danger: #f87a7a; - --bui-border-warning: #e36d05; - --bui-border-success: #53db83; - --bui-ring: #1f5493; - --bui-scrollbar: #3636363a; - --bui-scrollbar-thumb: #575757; - --bui-shadow: none; - } -} - -@layer base { - *, :before, :after { - box-sizing: border-box; - } - - html { - -webkit-text-size-adjust: 100%; - tab-size: 4; - font-family: system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji; - line-height: 1.15; - } - - body { - margin: 0; - } - - b, strong { - font-weight: bolder; - } - - code, kbd, samp, pre { - font-family: ui-monospace, SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; - font-size: 1em; - } - - small { - font-size: 80%; - } - - sub, sup { - vertical-align: baseline; - font-size: 75%; - line-height: 0; - position: relative; - } - - sub { - bottom: -.25em; - } - - sup { - top: -.5em; - } - - table { - border-color: currentColor; - } - - button, input, optgroup, select, textarea { - margin: 0; - font-family: inherit; - font-size: 100%; - line-height: 1.15; - } - - button, [type="button"], [type="reset"], [type="submit"] { - -webkit-appearance: button; - } - - legend { - padding: 0; - } - - progress { - vertical-align: baseline; - } - - ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { - height: auto; - } - - [type="search"] { - -webkit-appearance: textfield; - outline-offset: -2px; - } - - ::-webkit-search-decoration { - -webkit-appearance: none; - } - - ::-webkit-file-upload-button { - -webkit-appearance: button; - font: inherit; - } - - summary { - display: list-item; - } - - :where(a) { - color: inherit; - text-decoration: none; - } - - @keyframes pulse { - 50% { - opacity: .5; - } - } - - body { - background-color: var(--bui-bg-neutral-0); - color: var(--bui-fg-primary); - font-family: var(--bui-font-regular); - font-weight: var(--bui-font-weight-regular); - font-size: var(--bui-font-size-3); - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - } - - [data-theme-mode="dark"] { - color-scheme: dark; - } - - [data-theme-mode="light"] { - color-scheme: light; - } -} - -@layer components; - -@layer utilities { - .bui-p { - padding: var(--p); - } - - .bui-p-0\.5 { - padding: var(--bui-space-0_5); - } - - .bui-p-1 { - padding: var(--bui-space-1); - } - - .bui-p-1\.5 { - padding: var(--bui-space-1_5); - } - - .bui-p-2 { - padding: var(--bui-space-2); - } - - .bui-p-3 { - padding: var(--bui-space-3); - } - - .bui-p-4 { - padding: var(--bui-space-4); - } - - .bui-p-5 { - padding: var(--bui-space-5); - } - - .bui-p-6 { - padding: var(--bui-space-6); - } - - .bui-p-7 { - padding: var(--bui-space-7); - } - - .bui-p-8 { - padding: var(--bui-space-8); - } - - .bui-p-9 { - padding: var(--bui-space-9); - } - - .bui-p-10 { - padding: var(--bui-space-10); - } - - .bui-p-11 { - padding: var(--bui-space-11); - } - - .bui-p-12 { - padding: var(--bui-space-12); - } - - .bui-p-13 { - padding: var(--bui-space-13); - } - - .bui-p-14 { - padding: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-p { - padding: var(--p-xs); - } - - .xs\:bui-p-0\.5 { - padding: var(--bui-space-0_5); - } - - .xs\:bui-p-1 { - padding: var(--bui-space-1); - } - - .xs\:bui-p-1\.5 { - padding: var(--bui-space-1_5); - } - - .xs\:bui-p-2 { - padding: var(--bui-space-2); - } - - .xs\:bui-p-3 { - padding: var(--bui-space-3); - } - - .xs\:bui-p-4 { - padding: var(--bui-space-4); - } - - .xs\:bui-p-5 { - padding: var(--bui-space-5); - } - - .xs\:bui-p-6 { - padding: var(--bui-space-6); - } - - .xs\:bui-p-7 { - padding: var(--bui-space-7); - } - - .xs\:bui-p-8 { - padding: var(--bui-space-8); - } - - .xs\:bui-p-9 { - padding: var(--bui-space-9); - } - - .xs\:bui-p-10 { - padding: var(--bui-space-10); - } - - .xs\:bui-p-11 { - padding: var(--bui-space-11); - } - - .xs\:bui-p-12 { - padding: var(--bui-space-12); - } - - .xs\:bui-p-13 { - padding: var(--bui-space-13); - } - - .xs\:bui-p-14 { - padding: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-p { - padding: var(--p-sm); - } - - .sm\:bui-p-0\.5 { - padding: var(--bui-space-0_5); - } - - .sm\:bui-p-1 { - padding: var(--bui-space-1); - } - - .sm\:bui-p-1\.5 { - padding: var(--bui-space-1_5); - } - - .sm\:bui-p-2 { - padding: var(--bui-space-2); - } - - .sm\:bui-p-3 { - padding: var(--bui-space-3); - } - - .sm\:bui-p-4 { - padding: var(--bui-space-4); - } - - .sm\:bui-p-5 { - padding: var(--bui-space-5); - } - - .sm\:bui-p-6 { - padding: var(--bui-space-6); - } - - .sm\:bui-p-7 { - padding: var(--bui-space-7); - } - - .sm\:bui-p-8 { - padding: var(--bui-space-8); - } - - .sm\:bui-p-9 { - padding: var(--bui-space-9); - } - - .sm\:bui-p-10 { - padding: var(--bui-space-10); - } - - .sm\:bui-p-11 { - padding: var(--bui-space-11); - } - - .sm\:bui-p-12 { - padding: var(--bui-space-12); - } - - .sm\:bui-p-13 { - padding: var(--bui-space-13); - } - - .sm\:bui-p-14 { - padding: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-p { - padding: var(--p-md); - } - - .md\:bui-p-0\.5 { - padding: var(--bui-space-0_5); - } - - .md\:bui-p-1 { - padding: var(--bui-space-1); - } - - .md\:bui-p-1\.5 { - padding: var(--bui-space-1_5); - } - - .md\:bui-p-2 { - padding: var(--bui-space-2); - } - - .md\:bui-p-3 { - padding: var(--bui-space-3); - } - - .md\:bui-p-4 { - padding: var(--bui-space-4); - } - - .md\:bui-p-5 { - padding: var(--bui-space-5); - } - - .md\:bui-p-6 { - padding: var(--bui-space-6); - } - - .md\:bui-p-7 { - padding: var(--bui-space-7); - } - - .md\:bui-p-8 { - padding: var(--bui-space-8); - } - - .md\:bui-p-9 { - padding: var(--bui-space-9); - } - - .md\:bui-p-10 { - padding: var(--bui-space-10); - } - - .md\:bui-p-11 { - padding: var(--bui-space-11); - } - - .md\:bui-p-12 { - padding: var(--bui-space-12); - } - - .md\:bui-p-13 { - padding: var(--bui-space-13); - } - - .md\:bui-p-14 { - padding: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-p { - padding: var(--p-lg); - } - - .lg\:bui-p-0\.5 { - padding: var(--bui-space-0_5); - } - - .lg\:bui-p-1 { - padding: var(--bui-space-1); - } - - .lg\:bui-p-1\.5 { - padding: var(--bui-space-1_5); - } - - .lg\:bui-p-2 { - padding: var(--bui-space-2); - } - - .lg\:bui-p-3 { - padding: var(--bui-space-3); - } - - .lg\:bui-p-4 { - padding: var(--bui-space-4); - } - - .lg\:bui-p-5 { - padding: var(--bui-space-5); - } - - .lg\:bui-p-6 { - padding: var(--bui-space-6); - } - - .lg\:bui-p-7 { - padding: var(--bui-space-7); - } - - .lg\:bui-p-8 { - padding: var(--bui-space-8); - } - - .lg\:bui-p-9 { - padding: var(--bui-space-9); - } - - .lg\:bui-p-10 { - padding: var(--bui-space-10); - } - - .lg\:bui-p-11 { - padding: var(--bui-space-11); - } - - .lg\:bui-p-12 { - padding: var(--bui-space-12); - } - - .lg\:bui-p-13 { - padding: var(--bui-space-13); - } - - .lg\:bui-p-14 { - padding: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-p { - padding: var(--p-xl); - } - - .xl\:bui-p-0\.5 { - padding: var(--bui-space-0_5); - } - - .xl\:bui-p-1 { - padding: var(--bui-space-1); - } - - .xl\:bui-p-1\.5 { - padding: var(--bui-space-1_5); - } - - .xl\:bui-p-2 { - padding: var(--bui-space-2); - } - - .xl\:bui-p-3 { - padding: var(--bui-space-3); - } - - .xl\:bui-p-4 { - padding: var(--bui-space-4); - } - - .xl\:bui-p-5 { - padding: var(--bui-space-5); - } - - .xl\:bui-p-6 { - padding: var(--bui-space-6); - } - - .xl\:bui-p-7 { - padding: var(--bui-space-7); - } - - .xl\:bui-p-8 { - padding: var(--bui-space-8); - } - - .xl\:bui-p-9 { - padding: var(--bui-space-9); - } - - .xl\:bui-p-10 { - padding: var(--bui-space-10); - } - - .xl\:bui-p-11 { - padding: var(--bui-space-11); - } - - .xl\:bui-p-12 { - padding: var(--bui-space-12); - } - - .xl\:bui-p-13 { - padding: var(--bui-space-13); - } - - .xl\:bui-p-14 { - padding: var(--bui-space-14); - } - } - - .bui-pl { - padding-left: var(--pl); - } - - .bui-pl-0\.5 { - padding-left: var(--bui-space-0_5); - } - - .bui-pl-1 { - padding-left: var(--bui-space-1); - } - - .bui-pl-1\.5 { - padding-left: var(--bui-space-1_5); - } - - .bui-pl-2 { - padding-left: var(--bui-space-2); - } - - .bui-pl-3 { - padding-left: var(--bui-space-3); - } - - .bui-pl-4 { - padding-left: var(--bui-space-4); - } - - .bui-pl-5 { - padding-left: var(--bui-space-5); - } - - .bui-pl-6 { - padding-left: var(--bui-space-6); - } - - .bui-pl-7 { - padding-left: var(--bui-space-7); - } - - .bui-pl-8 { - padding-left: var(--bui-space-8); - } - - .bui-pl-9 { - padding-left: var(--bui-space-9); - } - - .bui-pl-10 { - padding-left: var(--bui-space-10); - } - - .bui-pl-11 { - padding-left: var(--bui-space-11); - } - - .bui-pl-12 { - padding-left: var(--bui-space-12); - } - - .bui-pl-13 { - padding-left: var(--bui-space-13); - } - - .bui-pl-14 { - padding-left: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-pl { - padding-left: var(--pl-xs); - } - - .xs\:bui-pl-0\.5 { - padding-left: var(--bui-space-0_5); - } - - .xs\:bui-pl-1 { - padding-left: var(--bui-space-1); - } - - .xs\:bui-pl-1\.5 { - padding-left: var(--bui-space-1_5); - } - - .xs\:bui-pl-2 { - padding-left: var(--bui-space-2); - } - - .xs\:bui-pl-3 { - padding-left: var(--bui-space-3); - } - - .xs\:bui-pl-4 { - padding-left: var(--bui-space-4); - } - - .xs\:bui-pl-5 { - padding-left: var(--bui-space-5); - } - - .xs\:bui-pl-6 { - padding-left: var(--bui-space-6); - } - - .xs\:bui-pl-7 { - padding-left: var(--bui-space-7); - } - - .xs\:bui-pl-8 { - padding-left: var(--bui-space-8); - } - - .xs\:bui-pl-9 { - padding-left: var(--bui-space-9); - } - - .xs\:bui-pl-10 { - padding-left: var(--bui-space-10); - } - - .xs\:bui-pl-11 { - padding-left: var(--bui-space-11); - } - - .xs\:bui-pl-12 { - padding-left: var(--bui-space-12); - } - - .xs\:bui-pl-13 { - padding-left: var(--bui-space-13); - } - - .xs\:bui-pl-14 { - padding-left: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-pl { - padding-left: var(--pl-sm); - } - - .sm\:bui-pl-0\.5 { - padding-left: var(--bui-space-0_5); - } - - .sm\:bui-pl-1 { - padding-left: var(--bui-space-1); - } - - .sm\:bui-pl-1\.5 { - padding-left: var(--bui-space-1_5); - } - - .sm\:bui-pl-2 { - padding-left: var(--bui-space-2); - } - - .sm\:bui-pl-3 { - padding-left: var(--bui-space-3); - } - - .sm\:bui-pl-4 { - padding-left: var(--bui-space-4); - } - - .sm\:bui-pl-5 { - padding-left: var(--bui-space-5); - } - - .sm\:bui-pl-6 { - padding-left: var(--bui-space-6); - } - - .sm\:bui-pl-7 { - padding-left: var(--bui-space-7); - } - - .sm\:bui-pl-8 { - padding-left: var(--bui-space-8); - } - - .sm\:bui-pl-9 { - padding-left: var(--bui-space-9); - } - - .sm\:bui-pl-10 { - padding-left: var(--bui-space-10); - } - - .sm\:bui-pl-11 { - padding-left: var(--bui-space-11); - } - - .sm\:bui-pl-12 { - padding-left: var(--bui-space-12); - } - - .sm\:bui-pl-13 { - padding-left: var(--bui-space-13); - } - - .sm\:bui-pl-14 { - padding-left: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-pl { - padding-left: var(--pl-md); - } - - .md\:bui-pl-0\.5 { - padding-left: var(--bui-space-0_5); - } - - .md\:bui-pl-1 { - padding-left: var(--bui-space-1); - } - - .md\:bui-pl-1\.5 { - padding-left: var(--bui-space-1_5); - } - - .md\:bui-pl-2 { - padding-left: var(--bui-space-2); - } - - .md\:bui-pl-3 { - padding-left: var(--bui-space-3); - } - - .md\:bui-pl-4 { - padding-left: var(--bui-space-4); - } - - .md\:bui-pl-5 { - padding-left: var(--bui-space-5); - } - - .md\:bui-pl-6 { - padding-left: var(--bui-space-6); - } - - .md\:bui-pl-7 { - padding-left: var(--bui-space-7); - } - - .md\:bui-pl-8 { - padding-left: var(--bui-space-8); - } - - .md\:bui-pl-9 { - padding-left: var(--bui-space-9); - } - - .md\:bui-pl-10 { - padding-left: var(--bui-space-10); - } - - .md\:bui-pl-11 { - padding-left: var(--bui-space-11); - } - - .md\:bui-pl-12 { - padding-left: var(--bui-space-12); - } - - .md\:bui-pl-13 { - padding-left: var(--bui-space-13); - } - - .md\:bui-pl-14 { - padding-left: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-pl { - padding-left: var(--pl-lg); - } - - .lg\:bui-pl-0\.5 { - padding-left: var(--bui-space-0_5); - } - - .lg\:bui-pl-1 { - padding-left: var(--bui-space-1); - } - - .lg\:bui-pl-1\.5 { - padding-left: var(--bui-space-1_5); - } - - .lg\:bui-pl-2 { - padding-left: var(--bui-space-2); - } - - .lg\:bui-pl-3 { - padding-left: var(--bui-space-3); - } - - .lg\:bui-pl-4 { - padding-left: var(--bui-space-4); - } - - .lg\:bui-pl-5 { - padding-left: var(--bui-space-5); - } - - .lg\:bui-pl-6 { - padding-left: var(--bui-space-6); - } - - .lg\:bui-pl-7 { - padding-left: var(--bui-space-7); - } - - .lg\:bui-pl-8 { - padding-left: var(--bui-space-8); - } - - .lg\:bui-pl-9 { - padding-left: var(--bui-space-9); - } - - .lg\:bui-pl-10 { - padding-left: var(--bui-space-10); - } - - .lg\:bui-pl-11 { - padding-left: var(--bui-space-11); - } - - .lg\:bui-pl-12 { - padding-left: var(--bui-space-12); - } - - .lg\:bui-pl-13 { - padding-left: var(--bui-space-13); - } - - .lg\:bui-pl-14 { - padding-left: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-pl { - padding-left: var(--pl-xl); - } - - .xl\:bui-pl-0\.5 { - padding-left: var(--bui-space-0_5); - } - - .xl\:bui-pl-1 { - padding-left: var(--bui-space-1); - } - - .xl\:bui-pl-1\.5 { - padding-left: var(--bui-space-1_5); - } - - .xl\:bui-pl-2 { - padding-left: var(--bui-space-2); - } - - .xl\:bui-pl-3 { - padding-left: var(--bui-space-3); - } - - .xl\:bui-pl-4 { - padding-left: var(--bui-space-4); - } - - .xl\:bui-pl-5 { - padding-left: var(--bui-space-5); - } - - .xl\:bui-pl-6 { - padding-left: var(--bui-space-6); - } - - .xl\:bui-pl-7 { - padding-left: var(--bui-space-7); - } - - .xl\:bui-pl-8 { - padding-left: var(--bui-space-8); - } - - .xl\:bui-pl-9 { - padding-left: var(--bui-space-9); - } - - .xl\:bui-pl-10 { - padding-left: var(--bui-space-10); - } - - .xl\:bui-pl-11 { - padding-left: var(--bui-space-11); - } - - .xl\:bui-pl-12 { - padding-left: var(--bui-space-12); - } - - .xl\:bui-pl-13 { - padding-left: var(--bui-space-13); - } - - .xl\:bui-pl-14 { - padding-left: var(--bui-space-14); - } - } - - .bui-pr { - padding-right: var(--pr); - } - - .bui-pr-0\.5 { - padding-right: var(--bui-space-0_5); - } - - .bui-pr-1 { - padding-right: var(--bui-space-1); - } - - .bui-pr-1\.5 { - padding-right: var(--bui-space-1_5); - } - - .bui-pr-2 { - padding-right: var(--bui-space-2); - } - - .bui-pr-3 { - padding-right: var(--bui-space-3); - } - - .bui-pr-4 { - padding-right: var(--bui-space-4); - } - - .bui-pr-5 { - padding-right: var(--bui-space-5); - } - - .bui-pr-6 { - padding-right: var(--bui-space-6); - } - - .bui-pr-7 { - padding-right: var(--bui-space-7); - } - - .bui-pr-8 { - padding-right: var(--bui-space-8); - } - - .bui-pr-9 { - padding-right: var(--bui-space-9); - } - - .bui-pr-10 { - padding-right: var(--bui-space-10); - } - - .bui-pr-11 { - padding-right: var(--bui-space-11); - } - - .bui-pr-12 { - padding-right: var(--bui-space-12); - } - - .bui-pr-13 { - padding-right: var(--bui-space-13); - } - - .bui-pr-14 { - padding-right: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-pr { - padding-right: var(--pr-xs); - } - - .xs\:bui-pr-0\.5 { - padding-right: var(--bui-space-0_5); - } - - .xs\:bui-pr-1 { - padding-right: var(--bui-space-1); - } - - .xs\:bui-pr-1\.5 { - padding-right: var(--bui-space-1_5); - } - - .xs\:bui-pr-2 { - padding-right: var(--bui-space-2); - } - - .xs\:bui-pr-3 { - padding-right: var(--bui-space-3); - } - - .xs\:bui-pr-4 { - padding-right: var(--bui-space-4); - } - - .xs\:bui-pr-5 { - padding-right: var(--bui-space-5); - } - - .xs\:bui-pr-6 { - padding-right: var(--bui-space-6); - } - - .xs\:bui-pr-7 { - padding-right: var(--bui-space-7); - } - - .xs\:bui-pr-8 { - padding-right: var(--bui-space-8); - } - - .xs\:bui-pr-9 { - padding-right: var(--bui-space-9); - } - - .xs\:bui-pr-10 { - padding-right: var(--bui-space-10); - } - - .xs\:bui-pr-11 { - padding-right: var(--bui-space-11); - } - - .xs\:bui-pr-12 { - padding-right: var(--bui-space-12); - } - - .xs\:bui-pr-13 { - padding-right: var(--bui-space-13); - } - - .xs\:bui-pr-14 { - padding-right: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-pr { - padding-right: var(--pr-sm); - } - - .sm\:bui-pr-0\.5 { - padding-right: var(--bui-space-0_5); - } - - .sm\:bui-pr-1 { - padding-right: var(--bui-space-1); - } - - .sm\:bui-pr-1\.5 { - padding-right: var(--bui-space-1_5); - } - - .sm\:bui-pr-2 { - padding-right: var(--bui-space-2); - } - - .sm\:bui-pr-3 { - padding-right: var(--bui-space-3); - } - - .sm\:bui-pr-4 { - padding-right: var(--bui-space-4); - } - - .sm\:bui-pr-5 { - padding-right: var(--bui-space-5); - } - - .sm\:bui-pr-6 { - padding-right: var(--bui-space-6); - } - - .sm\:bui-pr-7 { - padding-right: var(--bui-space-7); - } - - .sm\:bui-pr-8 { - padding-right: var(--bui-space-8); - } - - .sm\:bui-pr-9 { - padding-right: var(--bui-space-9); - } - - .sm\:bui-pr-10 { - padding-right: var(--bui-space-10); - } - - .sm\:bui-pr-11 { - padding-right: var(--bui-space-11); - } - - .sm\:bui-pr-12 { - padding-right: var(--bui-space-12); - } - - .sm\:bui-pr-13 { - padding-right: var(--bui-space-13); - } - - .sm\:bui-pr-14 { - padding-right: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-pr { - padding-right: var(--pr-md); - } - - .md\:bui-pr-0\.5 { - padding-right: var(--bui-space-0_5); - } - - .md\:bui-pr-1 { - padding-right: var(--bui-space-1); - } - - .md\:bui-pr-1\.5 { - padding-right: var(--bui-space-1_5); - } - - .md\:bui-pr-2 { - padding-right: var(--bui-space-2); - } - - .md\:bui-pr-3 { - padding-right: var(--bui-space-3); - } - - .md\:bui-pr-4 { - padding-right: var(--bui-space-4); - } - - .md\:bui-pr-5 { - padding-right: var(--bui-space-5); - } - - .md\:bui-pr-6 { - padding-right: var(--bui-space-6); - } - - .md\:bui-pr-7 { - padding-right: var(--bui-space-7); - } - - .md\:bui-pr-8 { - padding-right: var(--bui-space-8); - } - - .md\:bui-pr-9 { - padding-right: var(--bui-space-9); - } - - .md\:bui-pr-10 { - padding-right: var(--bui-space-10); - } - - .md\:bui-pr-11 { - padding-right: var(--bui-space-11); - } - - .md\:bui-pr-12 { - padding-right: var(--bui-space-12); - } - - .md\:bui-pr-13 { - padding-right: var(--bui-space-13); - } - - .md\:bui-pr-14 { - padding-right: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-pr { - padding-right: var(--pr-lg); - } - - .lg\:bui-pr-0\.5 { - padding-right: var(--bui-space-0_5); - } - - .lg\:bui-pr-1 { - padding-right: var(--bui-space-1); - } - - .lg\:bui-pr-1\.5 { - padding-right: var(--bui-space-1_5); - } - - .lg\:bui-pr-2 { - padding-right: var(--bui-space-2); - } - - .lg\:bui-pr-3 { - padding-right: var(--bui-space-3); - } - - .lg\:bui-pr-4 { - padding-right: var(--bui-space-4); - } - - .lg\:bui-pr-5 { - padding-right: var(--bui-space-5); - } - - .lg\:bui-pr-6 { - padding-right: var(--bui-space-6); - } - - .lg\:bui-pr-7 { - padding-right: var(--bui-space-7); - } - - .lg\:bui-pr-8 { - padding-right: var(--bui-space-8); - } - - .lg\:bui-pr-9 { - padding-right: var(--bui-space-9); - } - - .lg\:bui-pr-10 { - padding-right: var(--bui-space-10); - } - - .lg\:bui-pr-11 { - padding-right: var(--bui-space-11); - } - - .lg\:bui-pr-12 { - padding-right: var(--bui-space-12); - } - - .lg\:bui-pr-13 { - padding-right: var(--bui-space-13); - } - - .lg\:bui-pr-14 { - padding-right: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-pr { - padding-right: var(--pr-xl); - } - - .xl\:bui-pr-0\.5 { - padding-right: var(--bui-space-0_5); - } - - .xl\:bui-pr-1 { - padding-right: var(--bui-space-1); - } - - .xl\:bui-pr-1\.5 { - padding-right: var(--bui-space-1_5); - } - - .xl\:bui-pr-2 { - padding-right: var(--bui-space-2); - } - - .xl\:bui-pr-3 { - padding-right: var(--bui-space-3); - } - - .xl\:bui-pr-4 { - padding-right: var(--bui-space-4); - } - - .xl\:bui-pr-5 { - padding-right: var(--bui-space-5); - } - - .xl\:bui-pr-6 { - padding-right: var(--bui-space-6); - } - - .xl\:bui-pr-7 { - padding-right: var(--bui-space-7); - } - - .xl\:bui-pr-8 { - padding-right: var(--bui-space-8); - } - - .xl\:bui-pr-9 { - padding-right: var(--bui-space-9); - } - - .xl\:bui-pr-10 { - padding-right: var(--bui-space-10); - } - - .xl\:bui-pr-11 { - padding-right: var(--bui-space-11); - } - - .xl\:bui-pr-12 { - padding-right: var(--bui-space-12); - } - - .xl\:bui-pr-13 { - padding-right: var(--bui-space-13); - } - - .xl\:bui-pr-14 { - padding-right: var(--bui-space-14); - } - } - - .bui-pt { - padding-top: var(--pt); - } - - .bui-pt-0\.5 { - padding-top: var(--bui-space-0_5); - } - - .bui-pt-1 { - padding-top: var(--bui-space-1); - } - - .bui-pt-1\.5 { - padding-top: var(--bui-space-1_5); - } - - .bui-pt-2 { - padding-top: var(--bui-space-2); - } - - .bui-pt-3 { - padding-top: var(--bui-space-3); - } - - .bui-pt-4 { - padding-top: var(--bui-space-4); - } - - .bui-pt-5 { - padding-top: var(--bui-space-5); - } - - .bui-pt-6 { - padding-top: var(--bui-space-6); - } - - .bui-pt-7 { - padding-top: var(--bui-space-7); - } - - .bui-pt-8 { - padding-top: var(--bui-space-8); - } - - .bui-pt-9 { - padding-top: var(--bui-space-9); - } - - .bui-pt-10 { - padding-top: var(--bui-space-10); - } - - .bui-pt-11 { - padding-top: var(--bui-space-11); - } - - .bui-pt-12 { - padding-top: var(--bui-space-12); - } - - .bui-pt-13 { - padding-top: var(--bui-space-13); - } - - .bui-pt-14 { - padding-top: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-pt { - padding-top: var(--pt-xs); - } - - .xs\:bui-pt-0\.5 { - padding-top: var(--bui-space-0_5); - } - - .xs\:bui-pt-1 { - padding-top: var(--bui-space-1); - } - - .xs\:bui-pt-1\.5 { - padding-top: var(--bui-space-1_5); - } - - .xs\:bui-pt-2 { - padding-top: var(--bui-space-2); - } - - .xs\:bui-pt-3 { - padding-top: var(--bui-space-3); - } - - .xs\:bui-pt-4 { - padding-top: var(--bui-space-4); - } - - .xs\:bui-pt-5 { - padding-top: var(--bui-space-5); - } - - .xs\:bui-pt-6 { - padding-top: var(--bui-space-6); - } - - .xs\:bui-pt-7 { - padding-top: var(--bui-space-7); - } - - .xs\:bui-pt-8 { - padding-top: var(--bui-space-8); - } - - .xs\:bui-pt-9 { - padding-top: var(--bui-space-9); - } - - .xs\:bui-pt-10 { - padding-top: var(--bui-space-10); - } - - .xs\:bui-pt-11 { - padding-top: var(--bui-space-11); - } - - .xs\:bui-pt-12 { - padding-top: var(--bui-space-12); - } - - .xs\:bui-pt-13 { - padding-top: var(--bui-space-13); - } - - .xs\:bui-pt-14 { - padding-top: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-pt { - padding-top: var(--pt-sm); - } - - .sm\:bui-pt-0\.5 { - padding-top: var(--bui-space-0_5); - } - - .sm\:bui-pt-1 { - padding-top: var(--bui-space-1); - } - - .sm\:bui-pt-1\.5 { - padding-top: var(--bui-space-1_5); - } - - .sm\:bui-pt-2 { - padding-top: var(--bui-space-2); - } - - .sm\:bui-pt-3 { - padding-top: var(--bui-space-3); - } - - .sm\:bui-pt-4 { - padding-top: var(--bui-space-4); - } - - .sm\:bui-pt-5 { - padding-top: var(--bui-space-5); - } - - .sm\:bui-pt-6 { - padding-top: var(--bui-space-6); - } - - .sm\:bui-pt-7 { - padding-top: var(--bui-space-7); - } - - .sm\:bui-pt-8 { - padding-top: var(--bui-space-8); - } - - .sm\:bui-pt-9 { - padding-top: var(--bui-space-9); - } - - .sm\:bui-pt-10 { - padding-top: var(--bui-space-10); - } - - .sm\:bui-pt-11 { - padding-top: var(--bui-space-11); - } - - .sm\:bui-pt-12 { - padding-top: var(--bui-space-12); - } - - .sm\:bui-pt-13 { - padding-top: var(--bui-space-13); - } - - .sm\:bui-pt-14 { - padding-top: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-pt { - padding-top: var(--pt-md); - } - - .md\:bui-pt-0\.5 { - padding-top: var(--bui-space-0_5); - } - - .md\:bui-pt-1 { - padding-top: var(--bui-space-1); - } - - .md\:bui-pt-1\.5 { - padding-top: var(--bui-space-1_5); - } - - .md\:bui-pt-2 { - padding-top: var(--bui-space-2); - } - - .md\:bui-pt-3 { - padding-top: var(--bui-space-3); - } - - .md\:bui-pt-4 { - padding-top: var(--bui-space-4); - } - - .md\:bui-pt-5 { - padding-top: var(--bui-space-5); - } - - .md\:bui-pt-6 { - padding-top: var(--bui-space-6); - } - - .md\:bui-pt-7 { - padding-top: var(--bui-space-7); - } - - .md\:bui-pt-8 { - padding-top: var(--bui-space-8); - } - - .md\:bui-pt-9 { - padding-top: var(--bui-space-9); - } - - .md\:bui-pt-10 { - padding-top: var(--bui-space-10); - } - - .md\:bui-pt-11 { - padding-top: var(--bui-space-11); - } - - .md\:bui-pt-12 { - padding-top: var(--bui-space-12); - } - - .md\:bui-pt-13 { - padding-top: var(--bui-space-13); - } - - .md\:bui-pt-14 { - padding-top: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-pt { - padding-top: var(--pt-lg); - } - - .lg\:bui-pt-0\.5 { - padding-top: var(--bui-space-0_5); - } - - .lg\:bui-pt-1 { - padding-top: var(--bui-space-1); - } - - .lg\:bui-pt-1\.5 { - padding-top: var(--bui-space-1_5); - } - - .lg\:bui-pt-2 { - padding-top: var(--bui-space-2); - } - - .lg\:bui-pt-3 { - padding-top: var(--bui-space-3); - } - - .lg\:bui-pt-4 { - padding-top: var(--bui-space-4); - } - - .lg\:bui-pt-5 { - padding-top: var(--bui-space-5); - } - - .lg\:bui-pt-6 { - padding-top: var(--bui-space-6); - } - - .lg\:bui-pt-7 { - padding-top: var(--bui-space-7); - } - - .lg\:bui-pt-8 { - padding-top: var(--bui-space-8); - } - - .lg\:bui-pt-9 { - padding-top: var(--bui-space-9); - } - - .lg\:bui-pt-10 { - padding-top: var(--bui-space-10); - } - - .lg\:bui-pt-11 { - padding-top: var(--bui-space-11); - } - - .lg\:bui-pt-12 { - padding-top: var(--bui-space-12); - } - - .lg\:bui-pt-13 { - padding-top: var(--bui-space-13); - } - - .lg\:bui-pt-14 { - padding-top: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-pt { - padding-top: var(--pt-xl); - } - - .xl\:bui-pt-0\.5 { - padding-top: var(--bui-space-0_5); - } - - .xl\:bui-pt-1 { - padding-top: var(--bui-space-1); - } - - .xl\:bui-pt-1\.5 { - padding-top: var(--bui-space-1_5); - } - - .xl\:bui-pt-2 { - padding-top: var(--bui-space-2); - } - - .xl\:bui-pt-3 { - padding-top: var(--bui-space-3); - } - - .xl\:bui-pt-4 { - padding-top: var(--bui-space-4); - } - - .xl\:bui-pt-5 { - padding-top: var(--bui-space-5); - } - - .xl\:bui-pt-6 { - padding-top: var(--bui-space-6); - } - - .xl\:bui-pt-7 { - padding-top: var(--bui-space-7); - } - - .xl\:bui-pt-8 { - padding-top: var(--bui-space-8); - } - - .xl\:bui-pt-9 { - padding-top: var(--bui-space-9); - } - - .xl\:bui-pt-10 { - padding-top: var(--bui-space-10); - } - - .xl\:bui-pt-11 { - padding-top: var(--bui-space-11); - } - - .xl\:bui-pt-12 { - padding-top: var(--bui-space-12); - } - - .xl\:bui-pt-13 { - padding-top: var(--bui-space-13); - } - - .xl\:bui-pt-14 { - padding-top: var(--bui-space-14); - } - } - - .bui-pb { - padding-bottom: var(--pb); - } - - .bui-pb-0\.5 { - padding-bottom: var(--bui-space-0_5); - } - - .bui-pb-1 { - padding-bottom: var(--bui-space-1); - } - - .bui-pb-1\.5 { - padding-bottom: var(--bui-space-1_5); - } - - .bui-pb-2 { - padding-bottom: var(--bui-space-2); - } - - .bui-pb-3 { - padding-bottom: var(--bui-space-3); - } - - .bui-pb-4 { - padding-bottom: var(--bui-space-4); - } - - .bui-pb-5 { - padding-bottom: var(--bui-space-5); - } - - .bui-pb-6 { - padding-bottom: var(--bui-space-6); - } - - .bui-pb-7 { - padding-bottom: var(--bui-space-7); - } - - .bui-pb-8 { - padding-bottom: var(--bui-space-8); - } - - .bui-pb-9 { - padding-bottom: var(--bui-space-9); - } - - .bui-pb-10 { - padding-bottom: var(--bui-space-10); - } - - .bui-pb-11 { - padding-bottom: var(--bui-space-11); - } - - .bui-pb-12 { - padding-bottom: var(--bui-space-12); - } - - .bui-pb-13 { - padding-bottom: var(--bui-space-13); - } - - .bui-pb-14 { - padding-bottom: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-pb { - padding-bottom: var(--pb-xs); - } - - .xs\:bui-pb-0\.5 { - padding-bottom: var(--bui-space-0_5); - } - - .xs\:bui-pb-1 { - padding-bottom: var(--bui-space-1); - } - - .xs\:bui-pb-1\.5 { - padding-bottom: var(--bui-space-1_5); - } - - .xs\:bui-pb-2 { - padding-bottom: var(--bui-space-2); - } - - .xs\:bui-pb-3 { - padding-bottom: var(--bui-space-3); - } - - .xs\:bui-pb-4 { - padding-bottom: var(--bui-space-4); - } - - .xs\:bui-pb-5 { - padding-bottom: var(--bui-space-5); - } - - .xs\:bui-pb-6 { - padding-bottom: var(--bui-space-6); - } - - .xs\:bui-pb-7 { - padding-bottom: var(--bui-space-7); - } - - .xs\:bui-pb-8 { - padding-bottom: var(--bui-space-8); - } - - .xs\:bui-pb-9 { - padding-bottom: var(--bui-space-9); - } - - .xs\:bui-pb-10 { - padding-bottom: var(--bui-space-10); - } - - .xs\:bui-pb-11 { - padding-bottom: var(--bui-space-11); - } - - .xs\:bui-pb-12 { - padding-bottom: var(--bui-space-12); - } - - .xs\:bui-pb-13 { - padding-bottom: var(--bui-space-13); - } - - .xs\:bui-pb-14 { - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-pb { - padding-bottom: var(--pb-sm); - } - - .sm\:bui-pb-0\.5 { - padding-bottom: var(--bui-space-0_5); - } - - .sm\:bui-pb-1 { - padding-bottom: var(--bui-space-1); - } - - .sm\:bui-pb-1\.5 { - padding-bottom: var(--bui-space-1_5); - } - - .sm\:bui-pb-2 { - padding-bottom: var(--bui-space-2); - } - - .sm\:bui-pb-3 { - padding-bottom: var(--bui-space-3); - } - - .sm\:bui-pb-4 { - padding-bottom: var(--bui-space-4); - } - - .sm\:bui-pb-5 { - padding-bottom: var(--bui-space-5); - } - - .sm\:bui-pb-6 { - padding-bottom: var(--bui-space-6); - } - - .sm\:bui-pb-7 { - padding-bottom: var(--bui-space-7); - } - - .sm\:bui-pb-8 { - padding-bottom: var(--bui-space-8); - } - - .sm\:bui-pb-9 { - padding-bottom: var(--bui-space-9); - } - - .sm\:bui-pb-10 { - padding-bottom: var(--bui-space-10); - } - - .sm\:bui-pb-11 { - padding-bottom: var(--bui-space-11); - } - - .sm\:bui-pb-12 { - padding-bottom: var(--bui-space-12); - } - - .sm\:bui-pb-13 { - padding-bottom: var(--bui-space-13); - } - - .sm\:bui-pb-14 { - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-pb { - padding-bottom: var(--pb-md); - } - - .md\:bui-pb-0\.5 { - padding-bottom: var(--bui-space-0_5); - } - - .md\:bui-pb-1 { - padding-bottom: var(--bui-space-1); - } - - .md\:bui-pb-1\.5 { - padding-bottom: var(--bui-space-1_5); - } - - .md\:bui-pb-2 { - padding-bottom: var(--bui-space-2); - } - - .md\:bui-pb-3 { - padding-bottom: var(--bui-space-3); - } - - .md\:bui-pb-4 { - padding-bottom: var(--bui-space-4); - } - - .md\:bui-pb-5 { - padding-bottom: var(--bui-space-5); - } - - .md\:bui-pb-6 { - padding-bottom: var(--bui-space-6); - } - - .md\:bui-pb-7 { - padding-bottom: var(--bui-space-7); - } - - .md\:bui-pb-8 { - padding-bottom: var(--bui-space-8); - } - - .md\:bui-pb-9 { - padding-bottom: var(--bui-space-9); - } - - .md\:bui-pb-10 { - padding-bottom: var(--bui-space-10); - } - - .md\:bui-pb-11 { - padding-bottom: var(--bui-space-11); - } - - .md\:bui-pb-12 { - padding-bottom: var(--bui-space-12); - } - - .md\:bui-pb-13 { - padding-bottom: var(--bui-space-13); - } - - .md\:bui-pb-14 { - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-pb { - padding-bottom: var(--pb-lg); - } - - .lg\:bui-pb-0\.5 { - padding-bottom: var(--bui-space-0_5); - } - - .lg\:bui-pb-1 { - padding-bottom: var(--bui-space-1); - } - - .lg\:bui-pb-1\.5 { - padding-bottom: var(--bui-space-1_5); - } - - .lg\:bui-pb-2 { - padding-bottom: var(--bui-space-2); - } - - .lg\:bui-pb-3 { - padding-bottom: var(--bui-space-3); - } - - .lg\:bui-pb-4 { - padding-bottom: var(--bui-space-4); - } - - .lg\:bui-pb-5 { - padding-bottom: var(--bui-space-5); - } - - .lg\:bui-pb-6 { - padding-bottom: var(--bui-space-6); - } - - .lg\:bui-pb-7 { - padding-bottom: var(--bui-space-7); - } - - .lg\:bui-pb-8 { - padding-bottom: var(--bui-space-8); - } - - .lg\:bui-pb-9 { - padding-bottom: var(--bui-space-9); - } - - .lg\:bui-pb-10 { - padding-bottom: var(--bui-space-10); - } - - .lg\:bui-pb-11 { - padding-bottom: var(--bui-space-11); - } - - .lg\:bui-pb-12 { - padding-bottom: var(--bui-space-12); - } - - .lg\:bui-pb-13 { - padding-bottom: var(--bui-space-13); - } - - .lg\:bui-pb-14 { - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-pb { - padding-bottom: var(--pb-xl); - } - - .xl\:bui-pb-0\.5 { - padding-bottom: var(--bui-space-0_5); - } - - .xl\:bui-pb-1 { - padding-bottom: var(--bui-space-1); - } - - .xl\:bui-pb-1\.5 { - padding-bottom: var(--bui-space-1_5); - } - - .xl\:bui-pb-2 { - padding-bottom: var(--bui-space-2); - } - - .xl\:bui-pb-3 { - padding-bottom: var(--bui-space-3); - } - - .xl\:bui-pb-4 { - padding-bottom: var(--bui-space-4); - } - - .xl\:bui-pb-5 { - padding-bottom: var(--bui-space-5); - } - - .xl\:bui-pb-6 { - padding-bottom: var(--bui-space-6); - } - - .xl\:bui-pb-7 { - padding-bottom: var(--bui-space-7); - } - - .xl\:bui-pb-8 { - padding-bottom: var(--bui-space-8); - } - - .xl\:bui-pb-9 { - padding-bottom: var(--bui-space-9); - } - - .xl\:bui-pb-10 { - padding-bottom: var(--bui-space-10); - } - - .xl\:bui-pb-11 { - padding-bottom: var(--bui-space-11); - } - - .xl\:bui-pb-12 { - padding-bottom: var(--bui-space-12); - } - - .xl\:bui-pb-13 { - padding-bottom: var(--bui-space-13); - } - - .xl\:bui-pb-14 { - padding-bottom: var(--bui-space-14); - } - } - - .bui-py { - padding-top: var(--py); - padding-bottom: var(--py); - } - - .bui-py-0\.5 { - padding-top: var(--bui-space-0_5); - padding-bottom: var(--bui-space-0_5); - } - - .bui-py-1 { - padding-top: var(--bui-space-1); - padding-bottom: var(--bui-space-1); - } - - .bui-py-1\.5 { - padding-top: var(--bui-space-1_5); - padding-bottom: var(--bui-space-1_5); - } - - .bui-py-2 { - padding-top: var(--bui-space-2); - padding-bottom: var(--bui-space-2); - } - - .bui-py-3 { - padding-top: var(--bui-space-3); - padding-bottom: var(--bui-space-3); - } - - .bui-py-4 { - padding-top: var(--bui-space-4); - padding-bottom: var(--bui-space-4); - } - - .bui-py-5 { - padding-top: var(--bui-space-5); - padding-bottom: var(--bui-space-5); - } - - .bui-py-6 { - padding-top: var(--bui-space-6); - padding-bottom: var(--bui-space-6); - } - - .bui-py-7 { - padding-top: var(--bui-space-7); - padding-bottom: var(--bui-space-7); - } - - .bui-py-8 { - padding-top: var(--bui-space-8); - padding-bottom: var(--bui-space-8); - } - - .bui-py-9 { - padding-top: var(--bui-space-9); - padding-bottom: var(--bui-space-9); - } - - .bui-py-10 { - padding-top: var(--bui-space-10); - padding-bottom: var(--bui-space-10); - } - - .bui-py-11 { - padding-top: var(--bui-space-11); - padding-bottom: var(--bui-space-11); - } - - .bui-py-12 { - padding-top: var(--bui-space-12); - padding-bottom: var(--bui-space-12); - } - - .bui-py-13 { - padding-top: var(--bui-space-13); - padding-bottom: var(--bui-space-13); - } - - .bui-py-14 { - padding-top: var(--bui-space-14); - padding-bottom: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-py { - padding-top: var(--py-xs); - padding-bottom: var(--py-xs); - } - - .xs\:bui-py-0\.5 { - padding-top: var(--bui-space-0_5); - padding-bottom: var(--bui-space-0_5); - } - - .xs\:bui-py-1 { - padding-top: var(--bui-space-1); - padding-bottom: var(--bui-space-1); - } - - .xs\:bui-py-1\.5 { - padding-top: var(--bui-space-1_5); - padding-bottom: var(--bui-space-1_5); - } - - .xs\:bui-py-2 { - padding-top: var(--bui-space-2); - padding-bottom: var(--bui-space-2); - } - - .xs\:bui-py-3 { - padding-top: var(--bui-space-3); - padding-bottom: var(--bui-space-3); - } - - .xs\:bui-py-4 { - padding-top: var(--bui-space-4); - padding-bottom: var(--bui-space-4); - } - - .xs\:bui-py-5 { - padding-top: var(--bui-space-5); - padding-bottom: var(--bui-space-5); - } - - .xs\:bui-py-6 { - padding-top: var(--bui-space-6); - padding-bottom: var(--bui-space-6); - } - - .xs\:bui-py-7 { - padding-top: var(--bui-space-7); - padding-bottom: var(--bui-space-7); - } - - .xs\:bui-py-8 { - padding-top: var(--bui-space-8); - padding-bottom: var(--bui-space-8); - } - - .xs\:bui-py-9 { - padding-top: var(--bui-space-9); - padding-bottom: var(--bui-space-9); - } - - .xs\:bui-py-10 { - padding-top: var(--bui-space-10); - padding-bottom: var(--bui-space-10); - } - - .xs\:bui-py-11 { - padding-top: var(--bui-space-11); - padding-bottom: var(--bui-space-11); - } - - .xs\:bui-py-12 { - padding-top: var(--bui-space-12); - padding-bottom: var(--bui-space-12); - } - - .xs\:bui-py-13 { - padding-top: var(--bui-space-13); - padding-bottom: var(--bui-space-13); - } - - .xs\:bui-py-14 { - padding-top: var(--bui-space-14); - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-py { - padding-top: var(--py-sm); - padding-bottom: var(--py-sm); - } - - .sm\:bui-py-0\.5 { - padding-top: var(--bui-space-0_5); - padding-bottom: var(--bui-space-0_5); - } - - .sm\:bui-py-1 { - padding-top: var(--bui-space-1); - padding-bottom: var(--bui-space-1); - } - - .sm\:bui-py-1\.5 { - padding-top: var(--bui-space-1_5); - padding-bottom: var(--bui-space-1_5); - } - - .sm\:bui-py-2 { - padding-top: var(--bui-space-2); - padding-bottom: var(--bui-space-2); - } - - .sm\:bui-py-3 { - padding-top: var(--bui-space-3); - padding-bottom: var(--bui-space-3); - } - - .sm\:bui-py-4 { - padding-top: var(--bui-space-4); - padding-bottom: var(--bui-space-4); - } - - .sm\:bui-py-5 { - padding-top: var(--bui-space-5); - padding-bottom: var(--bui-space-5); - } - - .sm\:bui-py-6 { - padding-top: var(--bui-space-6); - padding-bottom: var(--bui-space-6); - } - - .sm\:bui-py-7 { - padding-top: var(--bui-space-7); - padding-bottom: var(--bui-space-7); - } - - .sm\:bui-py-8 { - padding-top: var(--bui-space-8); - padding-bottom: var(--bui-space-8); - } - - .sm\:bui-py-9 { - padding-top: var(--bui-space-9); - padding-bottom: var(--bui-space-9); - } - - .sm\:bui-py-10 { - padding-top: var(--bui-space-10); - padding-bottom: var(--bui-space-10); - } - - .sm\:bui-py-11 { - padding-top: var(--bui-space-11); - padding-bottom: var(--bui-space-11); - } - - .sm\:bui-py-12 { - padding-top: var(--bui-space-12); - padding-bottom: var(--bui-space-12); - } - - .sm\:bui-py-13 { - padding-top: var(--bui-space-13); - padding-bottom: var(--bui-space-13); - } - - .sm\:bui-py-14 { - padding-top: var(--bui-space-14); - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-py { - padding-top: var(--py-md); - padding-bottom: var(--py-md); - } - - .md\:bui-py-0\.5 { - padding-top: var(--bui-space-0_5); - padding-bottom: var(--bui-space-0_5); - } - - .md\:bui-py-1 { - padding-top: var(--bui-space-1); - padding-bottom: var(--bui-space-1); - } - - .md\:bui-py-1\.5 { - padding-top: var(--bui-space-1_5); - padding-bottom: var(--bui-space-1_5); - } - - .md\:bui-py-2 { - padding-top: var(--bui-space-2); - padding-bottom: var(--bui-space-2); - } - - .md\:bui-py-3 { - padding-top: var(--bui-space-3); - padding-bottom: var(--bui-space-3); - } - - .md\:bui-py-4 { - padding-top: var(--bui-space-4); - padding-bottom: var(--bui-space-4); - } - - .md\:bui-py-5 { - padding-top: var(--bui-space-5); - padding-bottom: var(--bui-space-5); - } - - .md\:bui-py-6 { - padding-top: var(--bui-space-6); - padding-bottom: var(--bui-space-6); - } - - .md\:bui-py-7 { - padding-top: var(--bui-space-7); - padding-bottom: var(--bui-space-7); - } - - .md\:bui-py-8 { - padding-top: var(--bui-space-8); - padding-bottom: var(--bui-space-8); - } - - .md\:bui-py-9 { - padding-top: var(--bui-space-9); - padding-bottom: var(--bui-space-9); - } - - .md\:bui-py-10 { - padding-top: var(--bui-space-10); - padding-bottom: var(--bui-space-10); - } - - .md\:bui-py-11 { - padding-top: var(--bui-space-11); - padding-bottom: var(--bui-space-11); - } - - .md\:bui-py-12 { - padding-top: var(--bui-space-12); - padding-bottom: var(--bui-space-12); - } - - .md\:bui-py-13 { - padding-top: var(--bui-space-13); - padding-bottom: var(--bui-space-13); - } - - .md\:bui-py-14 { - padding-top: var(--bui-space-14); - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-py { - padding-top: var(--py-lg); - padding-bottom: var(--py-lg); - } - - .lg\:bui-py-0\.5 { - padding-top: var(--bui-space-0_5); - padding-bottom: var(--bui-space-0_5); - } - - .lg\:bui-py-1 { - padding-top: var(--bui-space-1); - padding-bottom: var(--bui-space-1); - } - - .lg\:bui-py-1\.5 { - padding-top: var(--bui-space-1_5); - padding-bottom: var(--bui-space-1_5); - } - - .lg\:bui-py-2 { - padding-top: var(--bui-space-2); - padding-bottom: var(--bui-space-2); - } - - .lg\:bui-py-3 { - padding-top: var(--bui-space-3); - padding-bottom: var(--bui-space-3); - } - - .lg\:bui-py-4 { - padding-top: var(--bui-space-4); - padding-bottom: var(--bui-space-4); - } - - .lg\:bui-py-5 { - padding-top: var(--bui-space-5); - padding-bottom: var(--bui-space-5); - } - - .lg\:bui-py-6 { - padding-top: var(--bui-space-6); - padding-bottom: var(--bui-space-6); - } - - .lg\:bui-py-7 { - padding-top: var(--bui-space-7); - padding-bottom: var(--bui-space-7); - } - - .lg\:bui-py-8 { - padding-top: var(--bui-space-8); - padding-bottom: var(--bui-space-8); - } - - .lg\:bui-py-9 { - padding-top: var(--bui-space-9); - padding-bottom: var(--bui-space-9); - } - - .lg\:bui-py-10 { - padding-top: var(--bui-space-10); - padding-bottom: var(--bui-space-10); - } - - .lg\:bui-py-11 { - padding-top: var(--bui-space-11); - padding-bottom: var(--bui-space-11); - } - - .lg\:bui-py-12 { - padding-top: var(--bui-space-12); - padding-bottom: var(--bui-space-12); - } - - .lg\:bui-py-13 { - padding-top: var(--bui-space-13); - padding-bottom: var(--bui-space-13); - } - - .lg\:bui-py-14 { - padding-top: var(--bui-space-14); - padding-bottom: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-py { - padding-top: var(--py-xl); - padding-bottom: var(--py-xl); - } - - .xl\:bui-py-0\.5 { - padding-top: var(--bui-space-0_5); - padding-bottom: var(--bui-space-0_5); - } - - .xl\:bui-py-1 { - padding-top: var(--bui-space-1); - padding-bottom: var(--bui-space-1); - } - - .xl\:bui-py-1\.5 { - padding-top: var(--bui-space-1_5); - padding-bottom: var(--bui-space-1_5); - } - - .xl\:bui-py-2 { - padding-top: var(--bui-space-2); - padding-bottom: var(--bui-space-2); - } - - .xl\:bui-py-3 { - padding-top: var(--bui-space-3); - padding-bottom: var(--bui-space-3); - } - - .xl\:bui-py-4 { - padding-top: var(--bui-space-4); - padding-bottom: var(--bui-space-4); - } - - .xl\:bui-py-5 { - padding-top: var(--bui-space-5); - padding-bottom: var(--bui-space-5); - } - - .xl\:bui-py-6 { - padding-top: var(--bui-space-6); - padding-bottom: var(--bui-space-6); - } - - .xl\:bui-py-7 { - padding-top: var(--bui-space-7); - padding-bottom: var(--bui-space-7); - } - - .xl\:bui-py-8 { - padding-top: var(--bui-space-8); - padding-bottom: var(--bui-space-8); - } - - .xl\:bui-py-9 { - padding-top: var(--bui-space-9); - padding-bottom: var(--bui-space-9); - } - - .xl\:bui-py-10 { - padding-top: var(--bui-space-10); - padding-bottom: var(--bui-space-10); - } - - .xl\:bui-py-11 { - padding-top: var(--bui-space-11); - padding-bottom: var(--bui-space-11); - } - - .xl\:bui-py-12 { - padding-top: var(--bui-space-12); - padding-bottom: var(--bui-space-12); - } - - .xl\:bui-py-13 { - padding-top: var(--bui-space-13); - padding-bottom: var(--bui-space-13); - } - - .xl\:bui-py-14 { - padding-top: var(--bui-space-14); - padding-bottom: var(--bui-space-14); - } - } - - .bui-px { - padding-left: var(--px); - padding-right: var(--px); - } - - .bui-px-0\.5 { - padding-left: var(--bui-space-0_5); - padding-right: var(--bui-space-0_5); - } - - .bui-px-1 { - padding-left: var(--bui-space-1); - padding-right: var(--bui-space-1); - } - - .bui-px-1\.5 { - padding-left: var(--bui-space-1_5); - padding-right: var(--bui-space-1_5); - } - - .bui-px-2 { - padding-left: var(--bui-space-2); - padding-right: var(--bui-space-2); - } - - .bui-px-3 { - padding-left: var(--bui-space-3); - padding-right: var(--bui-space-3); - } - - .bui-px-4 { - padding-left: var(--bui-space-4); - padding-right: var(--bui-space-4); - } - - .bui-px-5 { - padding-left: var(--bui-space-5); - padding-right: var(--bui-space-5); - } - - .bui-px-6 { - padding-left: var(--bui-space-6); - padding-right: var(--bui-space-6); - } - - .bui-px-7 { - padding-left: var(--bui-space-7); - padding-right: var(--bui-space-7); - } - - .bui-px-8 { - padding-left: var(--bui-space-8); - padding-right: var(--bui-space-8); - } - - .bui-px-9 { - padding-left: var(--bui-space-9); - padding-right: var(--bui-space-9); - } - - .bui-px-10 { - padding-left: var(--bui-space-10); - padding-right: var(--bui-space-10); - } - - .bui-px-11 { - padding-left: var(--bui-space-11); - padding-right: var(--bui-space-11); - } - - .bui-px-12 { - padding-left: var(--bui-space-12); - padding-right: var(--bui-space-12); - } - - .bui-px-13 { - padding-left: var(--bui-space-13); - padding-right: var(--bui-space-13); - } - - .bui-px-14 { - padding-left: var(--bui-space-14); - padding-right: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-px { - padding-left: var(--px-xs); - padding-right: var(--px-xs); - } - - .xs\:bui-px-0\.5 { - padding-left: var(--bui-space-0_5); - padding-right: var(--bui-space-0_5); - } - - .xs\:bui-px-1 { - padding-left: var(--bui-space-1); - padding-right: var(--bui-space-1); - } - - .xs\:bui-px-1\.5 { - padding-left: var(--bui-space-1_5); - padding-right: var(--bui-space-1_5); - } - - .xs\:bui-px-2 { - padding-left: var(--bui-space-2); - padding-right: var(--bui-space-2); - } - - .xs\:bui-px-3 { - padding-left: var(--bui-space-3); - padding-right: var(--bui-space-3); - } - - .xs\:bui-px-4 { - padding-left: var(--bui-space-4); - padding-right: var(--bui-space-4); - } - - .xs\:bui-px-5 { - padding-left: var(--bui-space-5); - padding-right: var(--bui-space-5); - } - - .xs\:bui-px-6 { - padding-left: var(--bui-space-6); - padding-right: var(--bui-space-6); - } - - .xs\:bui-px-7 { - padding-left: var(--bui-space-7); - padding-right: var(--bui-space-7); - } - - .xs\:bui-px-8 { - padding-left: var(--bui-space-8); - padding-right: var(--bui-space-8); - } - - .xs\:bui-px-9 { - padding-left: var(--bui-space-9); - padding-right: var(--bui-space-9); - } - - .xs\:bui-px-10 { - padding-left: var(--bui-space-10); - padding-right: var(--bui-space-10); - } - - .xs\:bui-px-11 { - padding-left: var(--bui-space-11); - padding-right: var(--bui-space-11); - } - - .xs\:bui-px-12 { - padding-left: var(--bui-space-12); - padding-right: var(--bui-space-12); - } - - .xs\:bui-px-13 { - padding-left: var(--bui-space-13); - padding-right: var(--bui-space-13); - } - - .xs\:bui-px-14 { - padding-left: var(--bui-space-14); - padding-right: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-px { - padding-left: var(--px-sm); - padding-right: var(--px-sm); - } - - .sm\:bui-px-0\.5 { - padding-left: var(--bui-space-0_5); - padding-right: var(--bui-space-0_5); - } - - .sm\:bui-px-1 { - padding-left: var(--bui-space-1); - padding-right: var(--bui-space-1); - } - - .sm\:bui-px-1\.5 { - padding-left: var(--bui-space-1_5); - padding-right: var(--bui-space-1_5); - } - - .sm\:bui-px-2 { - padding-left: var(--bui-space-2); - padding-right: var(--bui-space-2); - } - - .sm\:bui-px-3 { - padding-left: var(--bui-space-3); - padding-right: var(--bui-space-3); - } - - .sm\:bui-px-4 { - padding-left: var(--bui-space-4); - padding-right: var(--bui-space-4); - } - - .sm\:bui-px-5 { - padding-left: var(--bui-space-5); - padding-right: var(--bui-space-5); - } - - .sm\:bui-px-6 { - padding-left: var(--bui-space-6); - padding-right: var(--bui-space-6); - } - - .sm\:bui-px-7 { - padding-left: var(--bui-space-7); - padding-right: var(--bui-space-7); - } - - .sm\:bui-px-8 { - padding-left: var(--bui-space-8); - padding-right: var(--bui-space-8); - } - - .sm\:bui-px-9 { - padding-left: var(--bui-space-9); - padding-right: var(--bui-space-9); - } - - .sm\:bui-px-10 { - padding-left: var(--bui-space-10); - padding-right: var(--bui-space-10); - } - - .sm\:bui-px-11 { - padding-left: var(--bui-space-11); - padding-right: var(--bui-space-11); - } - - .sm\:bui-px-12 { - padding-left: var(--bui-space-12); - padding-right: var(--bui-space-12); - } - - .sm\:bui-px-13 { - padding-left: var(--bui-space-13); - padding-right: var(--bui-space-13); - } - - .sm\:bui-px-14 { - padding-left: var(--bui-space-14); - padding-right: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-px { - padding-left: var(--px-md); - padding-right: var(--px-md); - } - - .md\:bui-px-0\.5 { - padding-left: var(--bui-space-0_5); - padding-right: var(--bui-space-0_5); - } - - .md\:bui-px-1 { - padding-left: var(--bui-space-1); - padding-right: var(--bui-space-1); - } - - .md\:bui-px-1\.5 { - padding-left: var(--bui-space-1_5); - padding-right: var(--bui-space-1_5); - } - - .md\:bui-px-2 { - padding-left: var(--bui-space-2); - padding-right: var(--bui-space-2); - } - - .md\:bui-px-3 { - padding-left: var(--bui-space-3); - padding-right: var(--bui-space-3); - } - - .md\:bui-px-4 { - padding-left: var(--bui-space-4); - padding-right: var(--bui-space-4); - } - - .md\:bui-px-5 { - padding-left: var(--bui-space-5); - padding-right: var(--bui-space-5); - } - - .md\:bui-px-6 { - padding-left: var(--bui-space-6); - padding-right: var(--bui-space-6); - } - - .md\:bui-px-7 { - padding-left: var(--bui-space-7); - padding-right: var(--bui-space-7); - } - - .md\:bui-px-8 { - padding-left: var(--bui-space-8); - padding-right: var(--bui-space-8); - } - - .md\:bui-px-9 { - padding-left: var(--bui-space-9); - padding-right: var(--bui-space-9); - } - - .md\:bui-px-10 { - padding-left: var(--bui-space-10); - padding-right: var(--bui-space-10); - } - - .md\:bui-px-11 { - padding-left: var(--bui-space-11); - padding-right: var(--bui-space-11); - } - - .md\:bui-px-12 { - padding-left: var(--bui-space-12); - padding-right: var(--bui-space-12); - } - - .md\:bui-px-13 { - padding-left: var(--bui-space-13); - padding-right: var(--bui-space-13); - } - - .md\:bui-px-14 { - padding-left: var(--bui-space-14); - padding-right: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-px { - padding-left: var(--px-lg); - padding-right: var(--px-lg); - } - - .lg\:bui-px-0\.5 { - padding-left: var(--bui-space-0_5); - padding-right: var(--bui-space-0_5); - } - - .lg\:bui-px-1 { - padding-left: var(--bui-space-1); - padding-right: var(--bui-space-1); - } - - .lg\:bui-px-1\.5 { - padding-left: var(--bui-space-1_5); - padding-right: var(--bui-space-1_5); - } - - .lg\:bui-px-2 { - padding-left: var(--bui-space-2); - padding-right: var(--bui-space-2); - } - - .lg\:bui-px-3 { - padding-left: var(--bui-space-3); - padding-right: var(--bui-space-3); - } - - .lg\:bui-px-4 { - padding-left: var(--bui-space-4); - padding-right: var(--bui-space-4); - } - - .lg\:bui-px-5 { - padding-left: var(--bui-space-5); - padding-right: var(--bui-space-5); - } - - .lg\:bui-px-6 { - padding-left: var(--bui-space-6); - padding-right: var(--bui-space-6); - } - - .lg\:bui-px-7 { - padding-left: var(--bui-space-7); - padding-right: var(--bui-space-7); - } - - .lg\:bui-px-8 { - padding-left: var(--bui-space-8); - padding-right: var(--bui-space-8); - } - - .lg\:bui-px-9 { - padding-left: var(--bui-space-9); - padding-right: var(--bui-space-9); - } - - .lg\:bui-px-10 { - padding-left: var(--bui-space-10); - padding-right: var(--bui-space-10); - } - - .lg\:bui-px-11 { - padding-left: var(--bui-space-11); - padding-right: var(--bui-space-11); - } - - .lg\:bui-px-12 { - padding-left: var(--bui-space-12); - padding-right: var(--bui-space-12); - } - - .lg\:bui-px-13 { - padding-left: var(--bui-space-13); - padding-right: var(--bui-space-13); - } - - .lg\:bui-px-14 { - padding-left: var(--bui-space-14); - padding-right: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-px { - padding-left: var(--px-xl); - padding-right: var(--px-xl); - } - - .xl\:bui-px-0\.5 { - padding-left: var(--bui-space-0_5); - padding-right: var(--bui-space-0_5); - } - - .xl\:bui-px-1 { - padding-left: var(--bui-space-1); - padding-right: var(--bui-space-1); - } - - .xl\:bui-px-1\.5 { - padding-left: var(--bui-space-1_5); - padding-right: var(--bui-space-1_5); - } - - .xl\:bui-px-2 { - padding-left: var(--bui-space-2); - padding-right: var(--bui-space-2); - } - - .xl\:bui-px-3 { - padding-left: var(--bui-space-3); - padding-right: var(--bui-space-3); - } - - .xl\:bui-px-4 { - padding-left: var(--bui-space-4); - padding-right: var(--bui-space-4); - } - - .xl\:bui-px-5 { - padding-left: var(--bui-space-5); - padding-right: var(--bui-space-5); - } - - .xl\:bui-px-6 { - padding-left: var(--bui-space-6); - padding-right: var(--bui-space-6); - } - - .xl\:bui-px-7 { - padding-left: var(--bui-space-7); - padding-right: var(--bui-space-7); - } - - .xl\:bui-px-8 { - padding-left: var(--bui-space-8); - padding-right: var(--bui-space-8); - } - - .xl\:bui-px-9 { - padding-left: var(--bui-space-9); - padding-right: var(--bui-space-9); - } - - .xl\:bui-px-10 { - padding-left: var(--bui-space-10); - padding-right: var(--bui-space-10); - } - - .xl\:bui-px-11 { - padding-left: var(--bui-space-11); - padding-right: var(--bui-space-11); - } - - .xl\:bui-px-12 { - padding-left: var(--bui-space-12); - padding-right: var(--bui-space-12); - } - - .xl\:bui-px-13 { - padding-left: var(--bui-space-13); - padding-right: var(--bui-space-13); - } - - .xl\:bui-px-14 { - padding-left: var(--bui-space-14); - padding-right: var(--bui-space-14); - } - } - - .bui-m { - margin: var(--m); - } - - .bui-m-0\.5 { - margin: var(--bui-space-0_5); - } - - .bui-m-1 { - margin: var(--bui-space-1); - } - - .bui-m-1\.5 { - margin: var(--bui-space-1_5); - } - - .bui-m-2 { - margin: var(--bui-space-2); - } - - .bui-m-3 { - margin: var(--bui-space-3); - } - - .bui-m-4 { - margin: var(--bui-space-4); - } - - .bui-m-5 { - margin: var(--bui-space-5); - } - - .bui-m-6 { - margin: var(--bui-space-6); - } - - .bui-m-7 { - margin: var(--bui-space-7); - } - - .bui-m-8 { - margin: var(--bui-space-8); - } - - .bui-m-9 { - margin: var(--bui-space-9); - } - - .bui-m-10 { - margin: var(--bui-space-10); - } - - .bui-m-11 { - margin: var(--bui-space-11); - } - - .bui-m-12 { - margin: var(--bui-space-12); - } - - .bui-m-13 { - margin: var(--bui-space-13); - } - - .bui-m-14 { - margin: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-m { - margin: var(--p-xs); - } - - .xs\:bui-m-0\.5 { - margin: var(--bui-space-0_5); - } - - .xs\:bui-m-1 { - margin: var(--bui-space-1); - } - - .xs\:bui-m-1\.5 { - margin: var(--bui-space-1_5); - } - - .xs\:bui-m-2 { - margin: var(--bui-space-2); - } - - .xs\:bui-m-3 { - margin: var(--bui-space-3); - } - - .xs\:bui-m-4 { - margin: var(--bui-space-4); - } - - .xs\:bui-m-5 { - margin: var(--bui-space-5); - } - - .xs\:bui-m-6 { - margin: var(--bui-space-6); - } - - .xs\:bui-m-7 { - margin: var(--bui-space-7); - } - - .xs\:bui-m-8 { - margin: var(--bui-space-8); - } - - .xs\:bui-m-9 { - margin: var(--bui-space-9); - } - - .xs\:bui-m-10 { - margin: var(--bui-space-10); - } - - .xs\:bui-m-11 { - margin: var(--bui-space-11); - } - - .xs\:bui-m-12 { - margin: var(--bui-space-12); - } - - .xs\:bui-m-13 { - margin: var(--bui-space-13); - } - - .xs\:bui-m-14 { - margin: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-m { - margin: var(--p-sm); - } - - .sm\:bui-m-0\.5 { - margin: var(--bui-space-0_5); - } - - .sm\:bui-m-1 { - margin: var(--bui-space-1); - } - - .sm\:bui-m-1\.5 { - margin: var(--bui-space-1_5); - } - - .sm\:bui-m-2 { - margin: var(--bui-space-2); - } - - .sm\:bui-m-3 { - margin: var(--bui-space-3); - } - - .sm\:bui-m-4 { - margin: var(--bui-space-4); - } - - .sm\:bui-m-5 { - margin: var(--bui-space-5); - } - - .sm\:bui-m-6 { - margin: var(--bui-space-6); - } - - .sm\:bui-m-7 { - margin: var(--bui-space-7); - } - - .sm\:bui-m-8 { - margin: var(--bui-space-8); - } - - .sm\:bui-m-9 { - margin: var(--bui-space-9); - } - - .sm\:bui-m-10 { - margin: var(--bui-space-10); - } - - .sm\:bui-m-11 { - margin: var(--bui-space-11); - } - - .sm\:bui-m-12 { - margin: var(--bui-space-12); - } - - .sm\:bui-m-13 { - margin: var(--bui-space-13); - } - - .sm\:bui-m-14 { - margin: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-m { - margin: var(--p-md); - } - - .md\:bui-m-0\.5 { - margin: var(--bui-space-0_5); - } - - .md\:bui-m-1 { - margin: var(--bui-space-1); - } - - .md\:bui-m-1\.5 { - margin: var(--bui-space-1_5); - } - - .md\:bui-m-2 { - margin: var(--bui-space-2); - } - - .md\:bui-m-3 { - margin: var(--bui-space-3); - } - - .md\:bui-m-4 { - margin: var(--bui-space-4); - } - - .md\:bui-m-5 { - margin: var(--bui-space-5); - } - - .md\:bui-m-6 { - margin: var(--bui-space-6); - } - - .md\:bui-m-7 { - margin: var(--bui-space-7); - } - - .md\:bui-m-8 { - margin: var(--bui-space-8); - } - - .md\:bui-m-9 { - margin: var(--bui-space-9); - } - - .md\:bui-m-10 { - margin: var(--bui-space-10); - } - - .md\:bui-m-11 { - margin: var(--bui-space-11); - } - - .md\:bui-m-12 { - margin: var(--bui-space-12); - } - - .md\:bui-m-13 { - margin: var(--bui-space-13); - } - - .md\:bui-m-14 { - margin: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-m { - margin: var(--p-lg); - } - - .lg\:bui-m-0\.5 { - margin: var(--bui-space-0_5); - } - - .lg\:bui-m-1 { - margin: var(--bui-space-1); - } - - .lg\:bui-m-1\.5 { - margin: var(--bui-space-1_5); - } - - .lg\:bui-m-2 { - margin: var(--bui-space-2); - } - - .lg\:bui-m-3 { - margin: var(--bui-space-3); - } - - .lg\:bui-m-4 { - margin: var(--bui-space-4); - } - - .lg\:bui-m-5 { - margin: var(--bui-space-5); - } - - .lg\:bui-m-6 { - margin: var(--bui-space-6); - } - - .lg\:bui-m-7 { - margin: var(--bui-space-7); - } - - .lg\:bui-m-8 { - margin: var(--bui-space-8); - } - - .lg\:bui-m-9 { - margin: var(--bui-space-9); - } - - .lg\:bui-m-10 { - margin: var(--bui-space-10); - } - - .lg\:bui-m-11 { - margin: var(--bui-space-11); - } - - .lg\:bui-m-12 { - margin: var(--bui-space-12); - } - - .lg\:bui-m-13 { - margin: var(--bui-space-13); - } - - .lg\:bui-m-14 { - margin: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-m { - margin: var(--p-xl); - } - - .xl\:bui-m-0\.5 { - margin: var(--bui-space-0_5); - } - - .xl\:bui-m-1 { - margin: var(--bui-space-1); - } - - .xl\:bui-m-1\.5 { - margin: var(--bui-space-1_5); - } - - .xl\:bui-m-2 { - margin: var(--bui-space-2); - } - - .xl\:bui-m-3 { - margin: var(--bui-space-3); - } - - .xl\:bui-m-4 { - margin: var(--bui-space-4); - } - - .xl\:bui-m-5 { - margin: var(--bui-space-5); - } - - .xl\:bui-m-6 { - margin: var(--bui-space-6); - } - - .xl\:bui-m-7 { - margin: var(--bui-space-7); - } - - .xl\:bui-m-8 { - margin: var(--bui-space-8); - } - - .xl\:bui-m-9 { - margin: var(--bui-space-9); - } - - .xl\:bui-m-10 { - margin: var(--bui-space-10); - } - - .xl\:bui-m-11 { - margin: var(--bui-space-11); - } - - .xl\:bui-m-12 { - margin: var(--bui-space-12); - } - - .xl\:bui-m-13 { - margin: var(--bui-space-13); - } - - .xl\:bui-m-14 { - margin: var(--bui-space-14); - } - } - - .bui-ml { - margin-left: var(--ml); - } - - .bui-ml-0\.5 { - margin-left: var(--bui-space-0_5); - } - - .bui-ml-1 { - margin-left: var(--bui-space-1); - } - - .bui-ml-1\.5 { - margin-left: var(--bui-space-1_5); - } - - .bui-ml-2 { - margin-left: var(--bui-space-2); - } - - .bui-ml-3 { - margin-left: var(--bui-space-3); - } - - .bui-ml-4 { - margin-left: var(--bui-space-4); - } - - .bui-ml-5 { - margin-left: var(--bui-space-5); - } - - .bui-ml-6 { - margin-left: var(--bui-space-6); - } - - .bui-ml-7 { - margin-left: var(--bui-space-7); - } - - .bui-ml-8 { - margin-left: var(--bui-space-8); - } - - .bui-ml-9 { - margin-left: var(--bui-space-9); - } - - .bui-ml-10 { - margin-left: var(--bui-space-10); - } - - .bui-ml-11 { - margin-left: var(--bui-space-11); - } - - .bui-ml-12 { - margin-left: var(--bui-space-12); - } - - .bui-ml-13 { - margin-left: var(--bui-space-13); - } - - .bui-ml-14 { - margin-left: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-ml { - margin-left: var(--ml-xs); - } - - .xs\:bui-ml-0\.5 { - margin-left: var(--bui-space-0_5); - } - - .xs\:bui-ml-1 { - margin-left: var(--bui-space-1); - } - - .xs\:bui-ml-1\.5 { - margin-left: var(--bui-space-1_5); - } - - .xs\:bui-ml-2 { - margin-left: var(--bui-space-2); - } - - .xs\:bui-ml-3 { - margin-left: var(--bui-space-3); - } - - .xs\:bui-ml-4 { - margin-left: var(--bui-space-4); - } - - .xs\:bui-ml-5 { - margin-left: var(--bui-space-5); - } - - .xs\:bui-ml-6 { - margin-left: var(--bui-space-6); - } - - .xs\:bui-ml-7 { - margin-left: var(--bui-space-7); - } - - .xs\:bui-ml-8 { - margin-left: var(--bui-space-8); - } - - .xs\:bui-ml-9 { - margin-left: var(--bui-space-9); - } - - .xs\:bui-ml-10 { - margin-left: var(--bui-space-10); - } - - .xs\:bui-ml-11 { - margin-left: var(--bui-space-11); - } - - .xs\:bui-ml-12 { - margin-left: var(--bui-space-12); - } - - .xs\:bui-ml-13 { - margin-left: var(--bui-space-13); - } - - .xs\:bui-ml-14 { - margin-left: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-ml { - margin-left: var(--ml-sm); - } - - .sm\:bui-ml-0\.5 { - margin-left: var(--bui-space-0_5); - } - - .sm\:bui-ml-1 { - margin-left: var(--bui-space-1); - } - - .sm\:bui-ml-1\.5 { - margin-left: var(--bui-space-1_5); - } - - .sm\:bui-ml-2 { - margin-left: var(--bui-space-2); - } - - .sm\:bui-ml-3 { - margin-left: var(--bui-space-3); - } - - .sm\:bui-ml-4 { - margin-left: var(--bui-space-4); - } - - .sm\:bui-ml-5 { - margin-left: var(--bui-space-5); - } - - .sm\:bui-ml-6 { - margin-left: var(--bui-space-6); - } - - .sm\:bui-ml-7 { - margin-left: var(--bui-space-7); - } - - .sm\:bui-ml-8 { - margin-left: var(--bui-space-8); - } - - .sm\:bui-ml-9 { - margin-left: var(--bui-space-9); - } - - .sm\:bui-ml-10 { - margin-left: var(--bui-space-10); - } - - .sm\:bui-ml-11 { - margin-left: var(--bui-space-11); - } - - .sm\:bui-ml-12 { - margin-left: var(--bui-space-12); - } - - .sm\:bui-ml-13 { - margin-left: var(--bui-space-13); - } - - .sm\:bui-ml-14 { - margin-left: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-ml { - margin-left: var(--ml-md); - } - - .md\:bui-ml-0\.5 { - margin-left: var(--bui-space-0_5); - } - - .md\:bui-ml-1 { - margin-left: var(--bui-space-1); - } - - .md\:bui-ml-1\.5 { - margin-left: var(--bui-space-1_5); - } - - .md\:bui-ml-2 { - margin-left: var(--bui-space-2); - } - - .md\:bui-ml-3 { - margin-left: var(--bui-space-3); - } - - .md\:bui-ml-4 { - margin-left: var(--bui-space-4); - } - - .md\:bui-ml-5 { - margin-left: var(--bui-space-5); - } - - .md\:bui-ml-6 { - margin-left: var(--bui-space-6); - } - - .md\:bui-ml-7 { - margin-left: var(--bui-space-7); - } - - .md\:bui-ml-8 { - margin-left: var(--bui-space-8); - } - - .md\:bui-ml-9 { - margin-left: var(--bui-space-9); - } - - .md\:bui-ml-10 { - margin-left: var(--bui-space-10); - } - - .md\:bui-ml-11 { - margin-left: var(--bui-space-11); - } - - .md\:bui-ml-12 { - margin-left: var(--bui-space-12); - } - - .md\:bui-ml-13 { - margin-left: var(--bui-space-13); - } - - .md\:bui-ml-14 { - margin-left: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-ml { - margin-left: var(--ml-lg); - } - - .lg\:bui-ml-0\.5 { - margin-left: var(--bui-space-0_5); - } - - .lg\:bui-ml-1 { - margin-left: var(--bui-space-1); - } - - .lg\:bui-ml-1\.5 { - margin-left: var(--bui-space-1_5); - } - - .lg\:bui-ml-2 { - margin-left: var(--bui-space-2); - } - - .lg\:bui-ml-3 { - margin-left: var(--bui-space-3); - } - - .lg\:bui-ml-4 { - margin-left: var(--bui-space-4); - } - - .lg\:bui-ml-5 { - margin-left: var(--bui-space-5); - } - - .lg\:bui-ml-6 { - margin-left: var(--bui-space-6); - } - - .lg\:bui-ml-7 { - margin-left: var(--bui-space-7); - } - - .lg\:bui-ml-8 { - margin-left: var(--bui-space-8); - } - - .lg\:bui-ml-9 { - margin-left: var(--bui-space-9); - } - - .lg\:bui-ml-10 { - margin-left: var(--bui-space-10); - } - - .lg\:bui-ml-11 { - margin-left: var(--bui-space-11); - } - - .lg\:bui-ml-12 { - margin-left: var(--bui-space-12); - } - - .lg\:bui-ml-13 { - margin-left: var(--bui-space-13); - } - - .lg\:bui-ml-14 { - margin-left: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-ml { - margin-left: var(--ml-xl); - } - - .xl\:bui-ml-0\.5 { - margin-left: var(--bui-space-0_5); - } - - .xl\:bui-ml-1 { - margin-left: var(--bui-space-1); - } - - .xl\:bui-ml-1\.5 { - margin-left: var(--bui-space-1_5); - } - - .xl\:bui-ml-2 { - margin-left: var(--bui-space-2); - } - - .xl\:bui-ml-3 { - margin-left: var(--bui-space-3); - } - - .xl\:bui-ml-4 { - margin-left: var(--bui-space-4); - } - - .xl\:bui-ml-5 { - margin-left: var(--bui-space-5); - } - - .xl\:bui-ml-6 { - margin-left: var(--bui-space-6); - } - - .xl\:bui-ml-7 { - margin-left: var(--bui-space-7); - } - - .xl\:bui-ml-8 { - margin-left: var(--bui-space-8); - } - - .xl\:bui-ml-9 { - margin-left: var(--bui-space-9); - } - - .xl\:bui-ml-10 { - margin-left: var(--bui-space-10); - } - - .xl\:bui-ml-11 { - margin-left: var(--bui-space-11); - } - - .xl\:bui-ml-12 { - margin-left: var(--bui-space-12); - } - - .xl\:bui-ml-13 { - margin-left: var(--bui-space-13); - } - - .xl\:bui-ml-14 { - margin-left: var(--bui-space-14); - } - } - - .bui-mr { - margin-right: var(--mr); - } - - .bui-mr-0\.5 { - margin-right: var(--bui-space-0_5); - } - - .bui-mr-1 { - margin-right: var(--bui-space-1); - } - - .bui-mr-1\.5 { - margin-right: var(--bui-space-1_5); - } - - .bui-mr-2 { - margin-right: var(--bui-space-2); - } - - .bui-mr-3 { - margin-right: var(--bui-space-3); - } - - .bui-mr-4 { - margin-right: var(--bui-space-4); - } - - .bui-mr-5 { - margin-right: var(--bui-space-5); - } - - .bui-mr-6 { - margin-right: var(--bui-space-6); - } - - .bui-mr-7 { - margin-right: var(--bui-space-7); - } - - .bui-mr-8 { - margin-right: var(--bui-space-8); - } - - .bui-mr-9 { - margin-right: var(--bui-space-9); - } - - .bui-mr-10 { - margin-right: var(--bui-space-10); - } - - .bui-mr-11 { - margin-right: var(--bui-space-11); - } - - .bui-mr-12 { - margin-right: var(--bui-space-12); - } - - .bui-mr-13 { - margin-right: var(--bui-space-13); - } - - .bui-mr-14 { - margin-right: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-mr { - margin-right: var(--mr-xs); - } - - .xs\:bui-mr-0\.5 { - margin-right: var(--bui-space-0_5); - } - - .xs\:bui-mr-1 { - margin-right: var(--bui-space-1); - } - - .xs\:bui-mr-1\.5 { - margin-right: var(--bui-space-1_5); - } - - .xs\:bui-mr-2 { - margin-right: var(--bui-space-2); - } - - .xs\:bui-mr-3 { - margin-right: var(--bui-space-3); - } - - .xs\:bui-mr-4 { - margin-right: var(--bui-space-4); - } - - .xs\:bui-mr-5 { - margin-right: var(--bui-space-5); - } - - .xs\:bui-mr-6 { - margin-right: var(--bui-space-6); - } - - .xs\:bui-mr-7 { - margin-right: var(--bui-space-7); - } - - .xs\:bui-mr-8 { - margin-right: var(--bui-space-8); - } - - .xs\:bui-mr-9 { - margin-right: var(--bui-space-9); - } - - .xs\:bui-mr-10 { - margin-right: var(--bui-space-10); - } - - .xs\:bui-mr-11 { - margin-right: var(--bui-space-11); - } - - .xs\:bui-mr-12 { - margin-right: var(--bui-space-12); - } - - .xs\:bui-mr-13 { - margin-right: var(--bui-space-13); - } - - .xs\:bui-mr-14 { - margin-right: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-mr { - margin-right: var(--mr-sm); - } - - .sm\:bui-mr-0\.5 { - margin-right: var(--bui-space-0_5); - } - - .sm\:bui-mr-1 { - margin-right: var(--bui-space-1); - } - - .sm\:bui-mr-1\.5 { - margin-right: var(--bui-space-1_5); - } - - .sm\:bui-mr-2 { - margin-right: var(--bui-space-2); - } - - .sm\:bui-mr-3 { - margin-right: var(--bui-space-3); - } - - .sm\:bui-mr-4 { - margin-right: var(--bui-space-4); - } - - .sm\:bui-mr-5 { - margin-right: var(--bui-space-5); - } - - .sm\:bui-mr-6 { - margin-right: var(--bui-space-6); - } - - .sm\:bui-mr-7 { - margin-right: var(--bui-space-7); - } - - .sm\:bui-mr-8 { - margin-right: var(--bui-space-8); - } - - .sm\:bui-mr-9 { - margin-right: var(--bui-space-9); - } - - .sm\:bui-mr-10 { - margin-right: var(--bui-space-10); - } - - .sm\:bui-mr-11 { - margin-right: var(--bui-space-11); - } - - .sm\:bui-mr-12 { - margin-right: var(--bui-space-12); - } - - .sm\:bui-mr-13 { - margin-right: var(--bui-space-13); - } - - .sm\:bui-mr-14 { - margin-right: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-mr { - margin-right: var(--mr-md); - } - - .md\:bui-mr-0\.5 { - margin-right: var(--bui-space-0_5); - } - - .md\:bui-mr-1 { - margin-right: var(--bui-space-1); - } - - .md\:bui-mr-1\.5 { - margin-right: var(--bui-space-1_5); - } - - .md\:bui-mr-2 { - margin-right: var(--bui-space-2); - } - - .md\:bui-mr-3 { - margin-right: var(--bui-space-3); - } - - .md\:bui-mr-4 { - margin-right: var(--bui-space-4); - } - - .md\:bui-mr-5 { - margin-right: var(--bui-space-5); - } - - .md\:bui-mr-6 { - margin-right: var(--bui-space-6); - } - - .md\:bui-mr-7 { - margin-right: var(--bui-space-7); - } - - .md\:bui-mr-8 { - margin-right: var(--bui-space-8); - } - - .md\:bui-mr-9 { - margin-right: var(--bui-space-9); - } - - .md\:bui-mr-10 { - margin-right: var(--bui-space-10); - } - - .md\:bui-mr-11 { - margin-right: var(--bui-space-11); - } - - .md\:bui-mr-12 { - margin-right: var(--bui-space-12); - } - - .md\:bui-mr-13 { - margin-right: var(--bui-space-13); - } - - .md\:bui-mr-14 { - margin-right: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-mr { - margin-right: var(--mr-lg); - } - - .lg\:bui-mr-0\.5 { - margin-right: var(--bui-space-0_5); - } - - .lg\:bui-mr-1 { - margin-right: var(--bui-space-1); - } - - .lg\:bui-mr-1\.5 { - margin-right: var(--bui-space-1_5); - } - - .lg\:bui-mr-2 { - margin-right: var(--bui-space-2); - } - - .lg\:bui-mr-3 { - margin-right: var(--bui-space-3); - } - - .lg\:bui-mr-4 { - margin-right: var(--bui-space-4); - } - - .lg\:bui-mr-5 { - margin-right: var(--bui-space-5); - } - - .lg\:bui-mr-6 { - margin-right: var(--bui-space-6); - } - - .lg\:bui-mr-7 { - margin-right: var(--bui-space-7); - } - - .lg\:bui-mr-8 { - margin-right: var(--bui-space-8); - } - - .lg\:bui-mr-9 { - margin-right: var(--bui-space-9); - } - - .lg\:bui-mr-10 { - margin-right: var(--bui-space-10); - } - - .lg\:bui-mr-11 { - margin-right: var(--bui-space-11); - } - - .lg\:bui-mr-12 { - margin-right: var(--bui-space-12); - } - - .lg\:bui-mr-13 { - margin-right: var(--bui-space-13); - } - - .lg\:bui-mr-14 { - margin-right: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-mr { - margin-right: var(--mr-xl); - } - - .xl\:bui-mr-0\.5 { - margin-right: var(--bui-space-0_5); - } - - .xl\:bui-mr-1 { - margin-right: var(--bui-space-1); - } - - .xl\:bui-mr-1\.5 { - margin-right: var(--bui-space-1_5); - } - - .xl\:bui-mr-2 { - margin-right: var(--bui-space-2); - } - - .xl\:bui-mr-3 { - margin-right: var(--bui-space-3); - } - - .xl\:bui-mr-4 { - margin-right: var(--bui-space-4); - } - - .xl\:bui-mr-5 { - margin-right: var(--bui-space-5); - } - - .xl\:bui-mr-6 { - margin-right: var(--bui-space-6); - } - - .xl\:bui-mr-7 { - margin-right: var(--bui-space-7); - } - - .xl\:bui-mr-8 { - margin-right: var(--bui-space-8); - } - - .xl\:bui-mr-9 { - margin-right: var(--bui-space-9); - } - - .xl\:bui-mr-10 { - margin-right: var(--bui-space-10); - } - - .xl\:bui-mr-11 { - margin-right: var(--bui-space-11); - } - - .xl\:bui-mr-12 { - margin-right: var(--bui-space-12); - } - - .xl\:bui-mr-13 { - margin-right: var(--bui-space-13); - } - - .xl\:bui-mr-14 { - margin-right: var(--bui-space-14); - } - } - - .bui-mt { - margin-top: var(--mt); - } - - .bui-mt-0\.5 { - margin-top: var(--bui-space-0_5); - } - - .bui-mt-1 { - margin-top: var(--bui-space-1); - } - - .bui-mt-1\.5 { - margin-top: var(--bui-space-1_5); - } - - .bui-mt-2 { - margin-top: var(--bui-space-2); - } - - .bui-mt-3 { - margin-top: var(--bui-space-3); - } - - .bui-mt-4 { - margin-top: var(--bui-space-4); - } - - .bui-mt-5 { - margin-top: var(--bui-space-5); - } - - .bui-mt-6 { - margin-top: var(--bui-space-6); - } - - .bui-mt-7 { - margin-top: var(--bui-space-7); - } - - .bui-mt-8 { - margin-top: var(--bui-space-8); - } - - .bui-mt-9 { - margin-top: var(--bui-space-9); - } - - .bui-mt-10 { - margin-top: var(--bui-space-10); - } - - .bui-mt-11 { - margin-top: var(--bui-space-11); - } - - .bui-mt-12 { - margin-top: var(--bui-space-12); - } - - .bui-mt-13 { - margin-top: var(--bui-space-13); - } - - .bui-mt-14 { - margin-top: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-mt { - margin-top: var(--mt-xs); - } - - .xs\:bui-mt-0\.5 { - margin-top: var(--bui-space-0_5); - } - - .xs\:bui-mt-1 { - margin-top: var(--bui-space-1); - } - - .xs\:bui-mt-1\.5 { - margin-top: var(--bui-space-1_5); - } - - .xs\:bui-mt-2 { - margin-top: var(--bui-space-2); - } - - .xs\:bui-mt-3 { - margin-top: var(--bui-space-3); - } - - .xs\:bui-mt-4 { - margin-top: var(--bui-space-4); - } - - .xs\:bui-mt-5 { - margin-top: var(--bui-space-5); - } - - .xs\:bui-mt-6 { - margin-top: var(--bui-space-6); - } - - .xs\:bui-mt-7 { - margin-top: var(--bui-space-7); - } - - .xs\:bui-mt-8 { - margin-top: var(--bui-space-8); - } - - .xs\:bui-mt-9 { - margin-top: var(--bui-space-9); - } - - .xs\:bui-mt-10 { - margin-top: var(--bui-space-10); - } - - .xs\:bui-mt-11 { - margin-top: var(--bui-space-11); - } - - .xs\:bui-mt-12 { - margin-top: var(--bui-space-12); - } - - .xs\:bui-mt-13 { - margin-top: var(--bui-space-13); - } - - .xs\:bui-mt-14 { - margin-top: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-mt { - margin-top: var(--mt-sm); - } - - .sm\:bui-mt-0\.5 { - margin-top: var(--bui-space-0_5); - } - - .sm\:bui-mt-1 { - margin-top: var(--bui-space-1); - } - - .sm\:bui-mt-1\.5 { - margin-top: var(--bui-space-1_5); - } - - .sm\:bui-mt-2 { - margin-top: var(--bui-space-2); - } - - .sm\:bui-mt-3 { - margin-top: var(--bui-space-3); - } - - .sm\:bui-mt-4 { - margin-top: var(--bui-space-4); - } - - .sm\:bui-mt-5 { - margin-top: var(--bui-space-5); - } - - .sm\:bui-mt-6 { - margin-top: var(--bui-space-6); - } - - .sm\:bui-mt-7 { - margin-top: var(--bui-space-7); - } - - .sm\:bui-mt-8 { - margin-top: var(--bui-space-8); - } - - .sm\:bui-mt-9 { - margin-top: var(--bui-space-9); - } - - .sm\:bui-mt-10 { - margin-top: var(--bui-space-10); - } - - .sm\:bui-mt-11 { - margin-top: var(--bui-space-11); - } - - .sm\:bui-mt-12 { - margin-top: var(--bui-space-12); - } - - .sm\:bui-mt-13 { - margin-top: var(--bui-space-13); - } - - .sm\:bui-mt-14 { - margin-top: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-mt { - margin-top: var(--mt-md); - } - - .md\:bui-mt-0\.5 { - margin-top: var(--bui-space-0_5); - } - - .md\:bui-mt-1 { - margin-top: var(--bui-space-1); - } - - .md\:bui-mt-1\.5 { - margin-top: var(--bui-space-1_5); - } - - .md\:bui-mt-2 { - margin-top: var(--bui-space-2); - } - - .md\:bui-mt-3 { - margin-top: var(--bui-space-3); - } - - .md\:bui-mt-4 { - margin-top: var(--bui-space-4); - } - - .md\:bui-mt-5 { - margin-top: var(--bui-space-5); - } - - .md\:bui-mt-6 { - margin-top: var(--bui-space-6); - } - - .md\:bui-mt-7 { - margin-top: var(--bui-space-7); - } - - .md\:bui-mt-8 { - margin-top: var(--bui-space-8); - } - - .md\:bui-mt-9 { - margin-top: var(--bui-space-9); - } - - .md\:bui-mt-10 { - margin-top: var(--bui-space-10); - } - - .md\:bui-mt-11 { - margin-top: var(--bui-space-11); - } - - .md\:bui-mt-12 { - margin-top: var(--bui-space-12); - } - - .md\:bui-mt-13 { - margin-top: var(--bui-space-13); - } - - .md\:bui-mt-14 { - margin-top: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-mt { - margin-top: var(--mt-lg); - } - - .lg\:bui-mt-0\.5 { - margin-top: var(--bui-space-0_5); - } - - .lg\:bui-mt-1 { - margin-top: var(--bui-space-1); - } - - .lg\:bui-mt-1\.5 { - margin-top: var(--bui-space-1_5); - } - - .lg\:bui-mt-2 { - margin-top: var(--bui-space-2); - } - - .lg\:bui-mt-3 { - margin-top: var(--bui-space-3); - } - - .lg\:bui-mt-4 { - margin-top: var(--bui-space-4); - } - - .lg\:bui-mt-5 { - margin-top: var(--bui-space-5); - } - - .lg\:bui-mt-6 { - margin-top: var(--bui-space-6); - } - - .lg\:bui-mt-7 { - margin-top: var(--bui-space-7); - } - - .lg\:bui-mt-8 { - margin-top: var(--bui-space-8); - } - - .lg\:bui-mt-9 { - margin-top: var(--bui-space-9); - } - - .lg\:bui-mt-10 { - margin-top: var(--bui-space-10); - } - - .lg\:bui-mt-11 { - margin-top: var(--bui-space-11); - } - - .lg\:bui-mt-12 { - margin-top: var(--bui-space-12); - } - - .lg\:bui-mt-13 { - margin-top: var(--bui-space-13); - } - - .lg\:bui-mt-14 { - margin-top: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-mt { - margin-top: var(--mt-xl); - } - - .xl\:bui-mt-0\.5 { - margin-top: var(--bui-space-0_5); - } - - .xl\:bui-mt-1 { - margin-top: var(--bui-space-1); - } - - .xl\:bui-mt-1\.5 { - margin-top: var(--bui-space-1_5); - } - - .xl\:bui-mt-2 { - margin-top: var(--bui-space-2); - } - - .xl\:bui-mt-3 { - margin-top: var(--bui-space-3); - } - - .xl\:bui-mt-4 { - margin-top: var(--bui-space-4); - } - - .xl\:bui-mt-5 { - margin-top: var(--bui-space-5); - } - - .xl\:bui-mt-6 { - margin-top: var(--bui-space-6); - } - - .xl\:bui-mt-7 { - margin-top: var(--bui-space-7); - } - - .xl\:bui-mt-8 { - margin-top: var(--bui-space-8); - } - - .xl\:bui-mt-9 { - margin-top: var(--bui-space-9); - } - - .xl\:bui-mt-10 { - margin-top: var(--bui-space-10); - } - - .xl\:bui-mt-11 { - margin-top: var(--bui-space-11); - } - - .xl\:bui-mt-12 { - margin-top: var(--bui-space-12); - } - - .xl\:bui-mt-13 { - margin-top: var(--bui-space-13); - } - - .xl\:bui-mt-14 { - margin-top: var(--bui-space-14); - } - } - - .bui-mb { - margin-bottom: var(--mb); - } - - .bui-mb-0\.5 { - margin-bottom: var(--bui-space-0_5); - } - - .bui-mb-1 { - margin-bottom: var(--bui-space-1); - } - - .bui-mb-1\.5 { - margin-bottom: var(--bui-space-1_5); - } - - .bui-mb-2 { - margin-bottom: var(--bui-space-2); - } - - .bui-mb-3 { - margin-bottom: var(--bui-space-3); - } - - .bui-mb-4 { - margin-bottom: var(--bui-space-4); - } - - .bui-mb-5 { - margin-bottom: var(--bui-space-5); - } - - .bui-mb-6 { - margin-bottom: var(--bui-space-6); - } - - .bui-mb-7 { - margin-bottom: var(--bui-space-7); - } - - .bui-mb-8 { - margin-bottom: var(--bui-space-8); - } - - .bui-mb-9 { - margin-bottom: var(--bui-space-9); - } - - .bui-mb-10 { - margin-bottom: var(--bui-space-10); - } - - .bui-mb-11 { - margin-bottom: var(--bui-space-11); - } - - .bui-mb-12 { - margin-bottom: var(--bui-space-12); - } - - .bui-mb-13 { - margin-bottom: var(--bui-space-13); - } - - .bui-mb-14 { - margin-bottom: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-mb { - margin-bottom: var(--mb-xs); - } - - .xs\:bui-mb-0\.5 { - margin-bottom: var(--bui-space-0_5); - } - - .xs\:bui-mb-1 { - margin-bottom: var(--bui-space-1); - } - - .xs\:bui-mb-1\.5 { - margin-bottom: var(--bui-space-1_5); - } - - .xs\:bui-mb-2 { - margin-bottom: var(--bui-space-2); - } - - .xs\:bui-mb-3 { - margin-bottom: var(--bui-space-3); - } - - .xs\:bui-mb-4 { - margin-bottom: var(--bui-space-4); - } - - .xs\:bui-mb-5 { - margin-bottom: var(--bui-space-5); - } - - .xs\:bui-mb-6 { - margin-bottom: var(--bui-space-6); - } - - .xs\:bui-mb-7 { - margin-bottom: var(--bui-space-7); - } - - .xs\:bui-mb-8 { - margin-bottom: var(--bui-space-8); - } - - .xs\:bui-mb-9 { - margin-bottom: var(--bui-space-9); - } - - .xs\:bui-mb-10 { - margin-bottom: var(--bui-space-10); - } - - .xs\:bui-mb-11 { - margin-bottom: var(--bui-space-11); - } - - .xs\:bui-mb-12 { - margin-bottom: var(--bui-space-12); - } - - .xs\:bui-mb-13 { - margin-bottom: var(--bui-space-13); - } - - .xs\:bui-mb-14 { - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-mb { - margin-bottom: var(--mb-sm); - } - - .sm\:bui-mb-0\.5 { - margin-bottom: var(--bui-space-0_5); - } - - .sm\:bui-mb-1 { - margin-bottom: var(--bui-space-1); - } - - .sm\:bui-mb-1\.5 { - margin-bottom: var(--bui-space-1_5); - } - - .sm\:bui-mb-2 { - margin-bottom: var(--bui-space-2); - } - - .sm\:bui-mb-3 { - margin-bottom: var(--bui-space-3); - } - - .sm\:bui-mb-4 { - margin-bottom: var(--bui-space-4); - } - - .sm\:bui-mb-5 { - margin-bottom: var(--bui-space-5); - } - - .sm\:bui-mb-6 { - margin-bottom: var(--bui-space-6); - } - - .sm\:bui-mb-7 { - margin-bottom: var(--bui-space-7); - } - - .sm\:bui-mb-8 { - margin-bottom: var(--bui-space-8); - } - - .sm\:bui-mb-9 { - margin-bottom: var(--bui-space-9); - } - - .sm\:bui-mb-10 { - margin-bottom: var(--bui-space-10); - } - - .sm\:bui-mb-11 { - margin-bottom: var(--bui-space-11); - } - - .sm\:bui-mb-12 { - margin-bottom: var(--bui-space-12); - } - - .sm\:bui-mb-13 { - margin-bottom: var(--bui-space-13); - } - - .sm\:bui-mb-14 { - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-mb { - margin-bottom: var(--mb-md); - } - - .md\:bui-mb-0\.5 { - margin-bottom: var(--bui-space-0_5); - } - - .md\:bui-mb-1 { - margin-bottom: var(--bui-space-1); - } - - .md\:bui-mb-1\.5 { - margin-bottom: var(--bui-space-1_5); - } - - .md\:bui-mb-2 { - margin-bottom: var(--bui-space-2); - } - - .md\:bui-mb-3 { - margin-bottom: var(--bui-space-3); - } - - .md\:bui-mb-4 { - margin-bottom: var(--bui-space-4); - } - - .md\:bui-mb-5 { - margin-bottom: var(--bui-space-5); - } - - .md\:bui-mb-6 { - margin-bottom: var(--bui-space-6); - } - - .md\:bui-mb-7 { - margin-bottom: var(--bui-space-7); - } - - .md\:bui-mb-8 { - margin-bottom: var(--bui-space-8); - } - - .md\:bui-mb-9 { - margin-bottom: var(--bui-space-9); - } - - .md\:bui-mb-10 { - margin-bottom: var(--bui-space-10); - } - - .md\:bui-mb-11 { - margin-bottom: var(--bui-space-11); - } - - .md\:bui-mb-12 { - margin-bottom: var(--bui-space-12); - } - - .md\:bui-mb-13 { - margin-bottom: var(--bui-space-13); - } - - .md\:bui-mb-14 { - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-mb { - margin-bottom: var(--mb-lg); - } - - .lg\:bui-mb-0\.5 { - margin-bottom: var(--bui-space-0_5); - } - - .lg\:bui-mb-1 { - margin-bottom: var(--bui-space-1); - } - - .lg\:bui-mb-1\.5 { - margin-bottom: var(--bui-space-1_5); - } - - .lg\:bui-mb-2 { - margin-bottom: var(--bui-space-2); - } - - .lg\:bui-mb-3 { - margin-bottom: var(--bui-space-3); - } - - .lg\:bui-mb-4 { - margin-bottom: var(--bui-space-4); - } - - .lg\:bui-mb-5 { - margin-bottom: var(--bui-space-5); - } - - .lg\:bui-mb-6 { - margin-bottom: var(--bui-space-6); - } - - .lg\:bui-mb-7 { - margin-bottom: var(--bui-space-7); - } - - .lg\:bui-mb-8 { - margin-bottom: var(--bui-space-8); - } - - .lg\:bui-mb-9 { - margin-bottom: var(--bui-space-9); - } - - .lg\:bui-mb-10 { - margin-bottom: var(--bui-space-10); - } - - .lg\:bui-mb-11 { - margin-bottom: var(--bui-space-11); - } - - .lg\:bui-mb-12 { - margin-bottom: var(--bui-space-12); - } - - .lg\:bui-mb-13 { - margin-bottom: var(--bui-space-13); - } - - .lg\:bui-mb-14 { - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-mb { - margin-bottom: var(--mb-xl); - } - - .xl\:bui-mb-0\.5 { - margin-bottom: var(--bui-space-0_5); - } - - .xl\:bui-mb-1 { - margin-bottom: var(--bui-space-1); - } - - .xl\:bui-mb-1\.5 { - margin-bottom: var(--bui-space-1_5); - } - - .xl\:bui-mb-2 { - margin-bottom: var(--bui-space-2); - } - - .xl\:bui-mb-3 { - margin-bottom: var(--bui-space-3); - } - - .xl\:bui-mb-4 { - margin-bottom: var(--bui-space-4); - } - - .xl\:bui-mb-5 { - margin-bottom: var(--bui-space-5); - } - - .xl\:bui-mb-6 { - margin-bottom: var(--bui-space-6); - } - - .xl\:bui-mb-7 { - margin-bottom: var(--bui-space-7); - } - - .xl\:bui-mb-8 { - margin-bottom: var(--bui-space-8); - } - - .xl\:bui-mb-9 { - margin-bottom: var(--bui-space-9); - } - - .xl\:bui-mb-10 { - margin-bottom: var(--bui-space-10); - } - - .xl\:bui-mb-11 { - margin-bottom: var(--bui-space-11); - } - - .xl\:bui-mb-12 { - margin-bottom: var(--bui-space-12); - } - - .xl\:bui-mb-13 { - margin-bottom: var(--bui-space-13); - } - - .xl\:bui-mb-14 { - margin-bottom: var(--bui-space-14); - } - } - - .bui-my { - margin-top: var(--my); - margin-bottom: var(--my); - } - - .bui-my-0\.5 { - margin-top: var(--bui-space-0_5); - margin-bottom: var(--bui-space-0_5); - } - - .bui-my-1 { - margin-top: var(--bui-space-1); - margin-bottom: var(--bui-space-1); - } - - .bui-my-1\.5 { - margin-top: var(--bui-space-1_5); - margin-bottom: var(--bui-space-1_5); - } - - .bui-my-2 { - margin-top: var(--bui-space-2); - margin-bottom: var(--bui-space-2); - } - - .bui-my-3 { - margin-top: var(--bui-space-3); - margin-bottom: var(--bui-space-3); - } - - .bui-my-4 { - margin-top: var(--bui-space-4); - margin-bottom: var(--bui-space-4); - } - - .bui-my-5 { - margin-top: var(--bui-space-5); - margin-bottom: var(--bui-space-5); - } - - .bui-my-6 { - margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); - } - - .bui-my-7 { - margin-top: var(--bui-space-7); - margin-bottom: var(--bui-space-7); - } - - .bui-my-8 { - margin-top: var(--bui-space-8); - margin-bottom: var(--bui-space-8); - } - - .bui-my-9 { - margin-top: var(--bui-space-9); - margin-bottom: var(--bui-space-9); - } - - .bui-my-10 { - margin-top: var(--bui-space-10); - margin-bottom: var(--bui-space-10); - } - - .bui-my-11 { - margin-top: var(--bui-space-11); - margin-bottom: var(--bui-space-11); - } - - .bui-my-12 { - margin-top: var(--bui-space-12); - margin-bottom: var(--bui-space-12); - } - - .bui-my-13 { - margin-top: var(--bui-space-13); - margin-bottom: var(--bui-space-13); - } - - .bui-my-14 { - margin-top: var(--bui-space-14); - margin-bottom: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-my { - margin-top: var(--my-xs); - margin-bottom: var(--my-xs); - } - - .xs\:bui-my-0\.5 { - margin-top: var(--bui-space-0_5); - margin-bottom: var(--bui-space-0_5); - } - - .xs\:bui-my-1 { - margin-top: var(--bui-space-1); - margin-bottom: var(--bui-space-1); - } - - .xs\:bui-my-1\.5 { - margin-top: var(--bui-space-1_5); - margin-bottom: var(--bui-space-1_5); - } - - .xs\:bui-my-2 { - margin-top: var(--bui-space-2); - margin-bottom: var(--bui-space-2); - } - - .xs\:bui-my-3 { - margin-top: var(--bui-space-3); - margin-bottom: var(--bui-space-3); - } - - .xs\:bui-my-4 { - margin-top: var(--bui-space-4); - margin-bottom: var(--bui-space-4); - } - - .xs\:bui-my-5 { - margin-top: var(--bui-space-5); - margin-bottom: var(--bui-space-5); - } - - .xs\:bui-my-6 { - margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); - } - - .xs\:bui-my-7 { - margin-top: var(--bui-space-7); - margin-bottom: var(--bui-space-7); - } - - .xs\:bui-my-8 { - margin-top: var(--bui-space-8); - margin-bottom: var(--bui-space-8); - } - - .xs\:bui-my-9 { - margin-top: var(--bui-space-9); - margin-bottom: var(--bui-space-9); - } - - .xs\:bui-my-10 { - margin-top: var(--bui-space-10); - margin-bottom: var(--bui-space-10); - } - - .xs\:bui-my-11 { - margin-top: var(--bui-space-11); - margin-bottom: var(--bui-space-11); - } - - .xs\:bui-my-12 { - margin-top: var(--bui-space-12); - margin-bottom: var(--bui-space-12); - } - - .xs\:bui-my-13 { - margin-top: var(--bui-space-13); - margin-bottom: var(--bui-space-13); - } - - .xs\:bui-my-14 { - margin-top: var(--bui-space-14); - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-my { - margin-top: var(--my-sm); - margin-bottom: var(--my-sm); - } - - .sm\:bui-my-0\.5 { - margin-top: var(--bui-space-0_5); - margin-bottom: var(--bui-space-0_5); - } - - .sm\:bui-my-1 { - margin-top: var(--bui-space-1); - margin-bottom: var(--bui-space-1); - } - - .sm\:bui-my-1\.5 { - margin-top: var(--bui-space-1_5); - margin-bottom: var(--bui-space-1_5); - } - - .sm\:bui-my-2 { - margin-top: var(--bui-space-2); - margin-bottom: var(--bui-space-2); - } - - .sm\:bui-my-3 { - margin-top: var(--bui-space-3); - margin-bottom: var(--bui-space-3); - } - - .sm\:bui-my-4 { - margin-top: var(--bui-space-4); - margin-bottom: var(--bui-space-4); - } - - .sm\:bui-my-5 { - margin-top: var(--bui-space-5); - margin-bottom: var(--bui-space-5); - } - - .sm\:bui-my-6 { - margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); - } - - .sm\:bui-my-7 { - margin-top: var(--bui-space-7); - margin-bottom: var(--bui-space-7); - } - - .sm\:bui-my-8 { - margin-top: var(--bui-space-8); - margin-bottom: var(--bui-space-8); - } - - .sm\:bui-my-9 { - margin-top: var(--bui-space-9); - margin-bottom: var(--bui-space-9); - } - - .sm\:bui-my-10 { - margin-top: var(--bui-space-10); - margin-bottom: var(--bui-space-10); - } - - .sm\:bui-my-11 { - margin-top: var(--bui-space-11); - margin-bottom: var(--bui-space-11); - } - - .sm\:bui-my-12 { - margin-top: var(--bui-space-12); - margin-bottom: var(--bui-space-12); - } - - .sm\:bui-my-13 { - margin-top: var(--bui-space-13); - margin-bottom: var(--bui-space-13); - } - - .sm\:bui-my-14 { - margin-top: var(--bui-space-14); - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-my { - margin-top: var(--my-md); - margin-bottom: var(--my-md); - } - - .md\:bui-my-0\.5 { - margin-top: var(--bui-space-0_5); - margin-bottom: var(--bui-space-0_5); - } - - .md\:bui-my-1 { - margin-top: var(--bui-space-1); - margin-bottom: var(--bui-space-1); - } - - .md\:bui-my-1\.5 { - margin-top: var(--bui-space-1_5); - margin-bottom: var(--bui-space-1_5); - } - - .md\:bui-my-2 { - margin-top: var(--bui-space-2); - margin-bottom: var(--bui-space-2); - } - - .md\:bui-my-3 { - margin-top: var(--bui-space-3); - margin-bottom: var(--bui-space-3); - } - - .md\:bui-my-4 { - margin-top: var(--bui-space-4); - margin-bottom: var(--bui-space-4); - } - - .md\:bui-my-5 { - margin-top: var(--bui-space-5); - margin-bottom: var(--bui-space-5); - } - - .md\:bui-my-6 { - margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); - } - - .md\:bui-my-7 { - margin-top: var(--bui-space-7); - margin-bottom: var(--bui-space-7); - } - - .md\:bui-my-8 { - margin-top: var(--bui-space-8); - margin-bottom: var(--bui-space-8); - } - - .md\:bui-my-9 { - margin-top: var(--bui-space-9); - margin-bottom: var(--bui-space-9); - } - - .md\:bui-my-10 { - margin-top: var(--bui-space-10); - margin-bottom: var(--bui-space-10); - } - - .md\:bui-my-11 { - margin-top: var(--bui-space-11); - margin-bottom: var(--bui-space-11); - } - - .md\:bui-my-12 { - margin-top: var(--bui-space-12); - margin-bottom: var(--bui-space-12); - } - - .md\:bui-my-13 { - margin-top: var(--bui-space-13); - margin-bottom: var(--bui-space-13); - } - - .md\:bui-my-14 { - margin-top: var(--bui-space-14); - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-my { - margin-top: var(--my-lg); - margin-bottom: var(--my-lg); - } - - .lg\:bui-my-0\.5 { - margin-top: var(--bui-space-0_5); - margin-bottom: var(--bui-space-0_5); - } - - .lg\:bui-my-1 { - margin-top: var(--bui-space-1); - margin-bottom: var(--bui-space-1); - } - - .lg\:bui-my-1\.5 { - margin-top: var(--bui-space-1_5); - margin-bottom: var(--bui-space-1_5); - } - - .lg\:bui-my-2 { - margin-top: var(--bui-space-2); - margin-bottom: var(--bui-space-2); - } - - .lg\:bui-my-3 { - margin-top: var(--bui-space-3); - margin-bottom: var(--bui-space-3); - } - - .lg\:bui-my-4 { - margin-top: var(--bui-space-4); - margin-bottom: var(--bui-space-4); - } - - .lg\:bui-my-5 { - margin-top: var(--bui-space-5); - margin-bottom: var(--bui-space-5); - } - - .lg\:bui-my-6 { - margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); - } - - .lg\:bui-my-7 { - margin-top: var(--bui-space-7); - margin-bottom: var(--bui-space-7); - } - - .lg\:bui-my-8 { - margin-top: var(--bui-space-8); - margin-bottom: var(--bui-space-8); - } - - .lg\:bui-my-9 { - margin-top: var(--bui-space-9); - margin-bottom: var(--bui-space-9); - } - - .lg\:bui-my-10 { - margin-top: var(--bui-space-10); - margin-bottom: var(--bui-space-10); - } - - .lg\:bui-my-11 { - margin-top: var(--bui-space-11); - margin-bottom: var(--bui-space-11); - } - - .lg\:bui-my-12 { - margin-top: var(--bui-space-12); - margin-bottom: var(--bui-space-12); - } - - .lg\:bui-my-13 { - margin-top: var(--bui-space-13); - margin-bottom: var(--bui-space-13); - } - - .lg\:bui-my-14 { - margin-top: var(--bui-space-14); - margin-bottom: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-my { - margin-top: var(--my-xl); - margin-bottom: var(--my-xl); - } - - .xl\:bui-my-0\.5 { - margin-top: var(--bui-space-0_5); - margin-bottom: var(--bui-space-0_5); - } - - .xl\:bui-my-1 { - margin-top: var(--bui-space-1); - margin-bottom: var(--bui-space-1); - } - - .xl\:bui-my-1\.5 { - margin-top: var(--bui-space-1_5); - margin-bottom: var(--bui-space-1_5); - } - - .xl\:bui-my-2 { - margin-top: var(--bui-space-2); - margin-bottom: var(--bui-space-2); - } - - .xl\:bui-my-3 { - margin-top: var(--bui-space-3); - margin-bottom: var(--bui-space-3); - } - - .xl\:bui-my-4 { - margin-top: var(--bui-space-4); - margin-bottom: var(--bui-space-4); - } - - .xl\:bui-my-5 { - margin-top: var(--bui-space-5); - margin-bottom: var(--bui-space-5); - } - - .xl\:bui-my-6 { - margin-top: var(--bui-space-6); - margin-bottom: var(--bui-space-6); - } - - .xl\:bui-my-7 { - margin-top: var(--bui-space-7); - margin-bottom: var(--bui-space-7); - } - - .xl\:bui-my-8 { - margin-top: var(--bui-space-8); - margin-bottom: var(--bui-space-8); - } - - .xl\:bui-my-9 { - margin-top: var(--bui-space-9); - margin-bottom: var(--bui-space-9); - } - - .xl\:bui-my-10 { - margin-top: var(--bui-space-10); - margin-bottom: var(--bui-space-10); - } - - .xl\:bui-my-11 { - margin-top: var(--bui-space-11); - margin-bottom: var(--bui-space-11); - } - - .xl\:bui-my-12 { - margin-top: var(--bui-space-12); - margin-bottom: var(--bui-space-12); - } - - .xl\:bui-my-13 { - margin-top: var(--bui-space-13); - margin-bottom: var(--bui-space-13); - } - - .xl\:bui-my-14 { - margin-top: var(--bui-space-14); - margin-bottom: var(--bui-space-14); - } - } - - .bui-mx { - margin-left: var(--mx); - margin-right: var(--mx); - } - - .bui-mx-0\.5 { - margin-left: var(--bui-space-0_5); - margin-right: var(--bui-space-0_5); - } - - .bui-mx-1 { - margin-left: var(--bui-space-1); - margin-right: var(--bui-space-1); - } - - .bui-mx-1\.5 { - margin-left: var(--bui-space-1_5); - margin-right: var(--bui-space-1_5); - } - - .bui-mx-2 { - margin-left: var(--bui-space-2); - margin-right: var(--bui-space-2); - } - - .bui-mx-3 { - margin-left: var(--bui-space-3); - margin-right: var(--bui-space-3); - } - - .bui-mx-4 { - margin-left: var(--bui-space-4); - margin-right: var(--bui-space-4); - } - - .bui-mx-5 { - margin-left: var(--bui-space-5); - margin-right: var(--bui-space-5); - } - - .bui-mx-6 { - margin-left: var(--bui-space-6); - margin-right: var(--bui-space-6); - } - - .bui-mx-7 { - margin-left: var(--bui-space-7); - margin-right: var(--bui-space-7); - } - - .bui-mx-8 { - margin-left: var(--bui-space-8); - margin-right: var(--bui-space-8); - } - - .bui-mx-9 { - margin-left: var(--bui-space-9); - margin-right: var(--bui-space-9); - } - - .bui-mx-10 { - margin-left: var(--bui-space-10); - margin-right: var(--bui-space-10); - } - - .bui-mx-11 { - margin-left: var(--bui-space-11); - margin-right: var(--bui-space-11); - } - - .bui-mx-12 { - margin-left: var(--bui-space-12); - margin-right: var(--bui-space-12); - } - - .bui-mx-13 { - margin-left: var(--bui-space-13); - margin-right: var(--bui-space-13); - } - - .bui-mx-14 { - margin-left: var(--bui-space-14); - margin-right: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-mx { - margin-left: var(--mx-xs); - margin-right: var(--mx-xs); - } - - .xs\:bui-mx-0\.5 { - margin-left: var(--bui-space-0_5); - margin-right: var(--bui-space-0_5); - } - - .xs\:bui-mx-1 { - margin-left: var(--bui-space-1); - margin-right: var(--bui-space-1); - } - - .xs\:bui-mx-1\.5 { - margin-left: var(--bui-space-1_5); - margin-right: var(--bui-space-1_5); - } - - .xs\:bui-mx-2 { - margin-left: var(--bui-space-2); - margin-right: var(--bui-space-2); - } - - .xs\:bui-mx-3 { - margin-left: var(--bui-space-3); - margin-right: var(--bui-space-3); - } - - .xs\:bui-mx-4 { - margin-left: var(--bui-space-4); - margin-right: var(--bui-space-4); - } - - .xs\:bui-mx-5 { - margin-left: var(--bui-space-5); - margin-right: var(--bui-space-5); - } - - .xs\:bui-mx-6 { - margin-left: var(--bui-space-6); - margin-right: var(--bui-space-6); - } - - .xs\:bui-mx-7 { - margin-left: var(--bui-space-7); - margin-right: var(--bui-space-7); - } - - .xs\:bui-mx-8 { - margin-left: var(--bui-space-8); - margin-right: var(--bui-space-8); - } - - .xs\:bui-mx-9 { - margin-left: var(--bui-space-9); - margin-right: var(--bui-space-9); - } - - .xs\:bui-mx-10 { - margin-left: var(--bui-space-10); - margin-right: var(--bui-space-10); - } - - .xs\:bui-mx-11 { - margin-left: var(--bui-space-11); - margin-right: var(--bui-space-11); - } - - .xs\:bui-mx-12 { - margin-left: var(--bui-space-12); - margin-right: var(--bui-space-12); - } - - .xs\:bui-mx-13 { - margin-left: var(--bui-space-13); - margin-right: var(--bui-space-13); - } - - .xs\:bui-mx-14 { - margin-left: var(--bui-space-14); - margin-right: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-mx { - margin-left: var(--mx-sm); - margin-right: var(--mx-sm); - } - - .sm\:bui-mx-0\.5 { - margin-left: var(--bui-space-0_5); - margin-right: var(--bui-space-0_5); - } - - .sm\:bui-mx-1 { - margin-left: var(--bui-space-1); - margin-right: var(--bui-space-1); - } - - .sm\:bui-mx-1\.5 { - margin-left: var(--bui-space-1_5); - margin-right: var(--bui-space-1_5); - } - - .sm\:bui-mx-2 { - margin-left: var(--bui-space-2); - margin-right: var(--bui-space-2); - } - - .sm\:bui-mx-3 { - margin-left: var(--bui-space-3); - margin-right: var(--bui-space-3); - } - - .sm\:bui-mx-4 { - margin-left: var(--bui-space-4); - margin-right: var(--bui-space-4); - } - - .sm\:bui-mx-5 { - margin-left: var(--bui-space-5); - margin-right: var(--bui-space-5); - } - - .sm\:bui-mx-6 { - margin-left: var(--bui-space-6); - margin-right: var(--bui-space-6); - } - - .sm\:bui-mx-7 { - margin-left: var(--bui-space-7); - margin-right: var(--bui-space-7); - } - - .sm\:bui-mx-8 { - margin-left: var(--bui-space-8); - margin-right: var(--bui-space-8); - } - - .sm\:bui-mx-9 { - margin-left: var(--bui-space-9); - margin-right: var(--bui-space-9); - } - - .sm\:bui-mx-10 { - margin-left: var(--bui-space-10); - margin-right: var(--bui-space-10); - } - - .sm\:bui-mx-11 { - margin-left: var(--bui-space-11); - margin-right: var(--bui-space-11); - } - - .sm\:bui-mx-12 { - margin-left: var(--bui-space-12); - margin-right: var(--bui-space-12); - } - - .sm\:bui-mx-13 { - margin-left: var(--bui-space-13); - margin-right: var(--bui-space-13); - } - - .sm\:bui-mx-14 { - margin-left: var(--bui-space-14); - margin-right: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-mx { - margin-left: var(--mx-md); - margin-right: var(--mx-md); - } - - .md\:bui-mx-0\.5 { - margin-left: var(--bui-space-0_5); - margin-right: var(--bui-space-0_5); - } - - .md\:bui-mx-1 { - margin-left: var(--bui-space-1); - margin-right: var(--bui-space-1); - } - - .md\:bui-mx-1\.5 { - margin-left: var(--bui-space-1_5); - margin-right: var(--bui-space-1_5); - } - - .md\:bui-mx-2 { - margin-left: var(--bui-space-2); - margin-right: var(--bui-space-2); - } - - .md\:bui-mx-3 { - margin-left: var(--bui-space-3); - margin-right: var(--bui-space-3); - } - - .md\:bui-mx-4 { - margin-left: var(--bui-space-4); - margin-right: var(--bui-space-4); - } - - .md\:bui-mx-5 { - margin-left: var(--bui-space-5); - margin-right: var(--bui-space-5); - } - - .md\:bui-mx-6 { - margin-left: var(--bui-space-6); - margin-right: var(--bui-space-6); - } - - .md\:bui-mx-7 { - margin-left: var(--bui-space-7); - margin-right: var(--bui-space-7); - } - - .md\:bui-mx-8 { - margin-left: var(--bui-space-8); - margin-right: var(--bui-space-8); - } - - .md\:bui-mx-9 { - margin-left: var(--bui-space-9); - margin-right: var(--bui-space-9); - } - - .md\:bui-mx-10 { - margin-left: var(--bui-space-10); - margin-right: var(--bui-space-10); - } - - .md\:bui-mx-11 { - margin-left: var(--bui-space-11); - margin-right: var(--bui-space-11); - } - - .md\:bui-mx-12 { - margin-left: var(--bui-space-12); - margin-right: var(--bui-space-12); - } - - .md\:bui-mx-13 { - margin-left: var(--bui-space-13); - margin-right: var(--bui-space-13); - } - - .md\:bui-mx-14 { - margin-left: var(--bui-space-14); - margin-right: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-mx { - margin-left: var(--mx-lg); - margin-right: var(--mx-lg); - } - - .lg\:bui-mx-0\.5 { - margin-left: var(--bui-space-0_5); - margin-right: var(--bui-space-0_5); - } - - .lg\:bui-mx-1 { - margin-left: var(--bui-space-1); - margin-right: var(--bui-space-1); - } - - .lg\:bui-mx-1\.5 { - margin-left: var(--bui-space-1_5); - margin-right: var(--bui-space-1_5); - } - - .lg\:bui-mx-2 { - margin-left: var(--bui-space-2); - margin-right: var(--bui-space-2); - } - - .lg\:bui-mx-3 { - margin-left: var(--bui-space-3); - margin-right: var(--bui-space-3); - } - - .lg\:bui-mx-4 { - margin-left: var(--bui-space-4); - margin-right: var(--bui-space-4); - } - - .lg\:bui-mx-5 { - margin-left: var(--bui-space-5); - margin-right: var(--bui-space-5); - } - - .lg\:bui-mx-6 { - margin-left: var(--bui-space-6); - margin-right: var(--bui-space-6); - } - - .lg\:bui-mx-7 { - margin-left: var(--bui-space-7); - margin-right: var(--bui-space-7); - } - - .lg\:bui-mx-8 { - margin-left: var(--bui-space-8); - margin-right: var(--bui-space-8); - } - - .lg\:bui-mx-9 { - margin-left: var(--bui-space-9); - margin-right: var(--bui-space-9); - } - - .lg\:bui-mx-10 { - margin-left: var(--bui-space-10); - margin-right: var(--bui-space-10); - } - - .lg\:bui-mx-11 { - margin-left: var(--bui-space-11); - margin-right: var(--bui-space-11); - } - - .lg\:bui-mx-12 { - margin-left: var(--bui-space-12); - margin-right: var(--bui-space-12); - } - - .lg\:bui-mx-13 { - margin-left: var(--bui-space-13); - margin-right: var(--bui-space-13); - } - - .lg\:bui-mx-14 { - margin-left: var(--bui-space-14); - margin-right: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-mx { - margin-left: var(--mx-xl); - margin-right: var(--mx-xl); - } - - .xl\:bui-mx-0\.5 { - margin-left: var(--bui-space-0_5); - margin-right: var(--bui-space-0_5); - } - - .xl\:bui-mx-1 { - margin-left: var(--bui-space-1); - margin-right: var(--bui-space-1); - } - - .xl\:bui-mx-1\.5 { - margin-left: var(--bui-space-1_5); - margin-right: var(--bui-space-1_5); - } - - .xl\:bui-mx-2 { - margin-left: var(--bui-space-2); - margin-right: var(--bui-space-2); - } - - .xl\:bui-mx-3 { - margin-left: var(--bui-space-3); - margin-right: var(--bui-space-3); - } - - .xl\:bui-mx-4 { - margin-left: var(--bui-space-4); - margin-right: var(--bui-space-4); - } - - .xl\:bui-mx-5 { - margin-left: var(--bui-space-5); - margin-right: var(--bui-space-5); - } - - .xl\:bui-mx-6 { - margin-left: var(--bui-space-6); - margin-right: var(--bui-space-6); - } - - .xl\:bui-mx-7 { - margin-left: var(--bui-space-7); - margin-right: var(--bui-space-7); - } - - .xl\:bui-mx-8 { - margin-left: var(--bui-space-8); - margin-right: var(--bui-space-8); - } - - .xl\:bui-mx-9 { - margin-left: var(--bui-space-9); - margin-right: var(--bui-space-9); - } - - .xl\:bui-mx-10 { - margin-left: var(--bui-space-10); - margin-right: var(--bui-space-10); - } - - .xl\:bui-mx-11 { - margin-left: var(--bui-space-11); - margin-right: var(--bui-space-11); - } - - .xl\:bui-mx-12 { - margin-left: var(--bui-space-12); - margin-right: var(--bui-space-12); - } - - .xl\:bui-mx-13 { - margin-left: var(--bui-space-13); - margin-right: var(--bui-space-13); - } - - .xl\:bui-mx-14 { - margin-left: var(--bui-space-14); - margin-right: var(--bui-space-14); - } - } - - .bui-display-none { - display: none; - } - - .bui-display-inline { - display: inline; - } - - .bui-display-inline-block { - display: inline-block; - } - - .bui-display-block { - display: block; - } - - @media (width >= 640px) { - .xs\:bui-display-none { - display: none; - } - - .xs\:bui-display-inline { - display: inline; - } - - .xs\:bui-display-inline-block { - display: inline-block; - } - - .xs\:bui-display-block { - display: block; - } - } - - @media (width >= 768px) { - .sm\:bui-display-none { - display: none; - } - - .sm\:bui-display-inline { - display: inline; - } - - .sm\:bui-display-inline-block { - display: inline-block; - } - - .sm\:bui-display-block { - display: block; - } - } - - @media (width >= 1024px) { - .md\:bui-display-none { - display: none; - } - - .md\:bui-display-inline { - display: inline; - } - - .md\:bui-display-inline-block { - display: inline-block; - } - - .md\:bui-display-block { - display: block; - } - } - - @media (width >= 1280px) { - .lg\:bui-display-none { - display: none; - } - - .lg\:bui-display-inline { - display: inline; - } - - .lg\:bui-display-inline-block { - display: inline-block; - } - - .lg\:bui-display-block { - display: block; - } - } - - @media (width >= 1536px) { - .xl\:bui-display-none { - display: none; - } - - .xl\:bui-display-inline { - display: inline; - } - - .xl\:bui-display-inline-block { - display: inline-block; - } - - .xl\:bui-display-block { - display: block; - } - } - - .bui-w { - width: var(--width); - } - - .bui-min-w { - min-width: var(--min-width); - } - - .bui-max-w { - max-width: var(--max-width); - } - - @media (width >= 640px) { - .xs\:bui-w { - width: var(--width); - } - - .xs\:bui-min-w { - min-width: var(--min-width); - } - - .xs\:bui-max-w { - max-width: var(--max-width); - } - } - - @media (width >= 768px) { - .sm\:bui-w { - width: var(--width); - } - - .sm\:bui-min-w { - min-width: var(--min-width); - } - - .sm\:bui-max-w { - max-width: var(--max-width); - } - } - - @media (width >= 1024px) { - .md\:bui-w { - width: var(--width); - } - - .md\:bui-min-w { - min-width: var(--min-width); - } - - .md\:bui-max-w { - max-width: var(--max-width); - } - } - - @media (width >= 1280px) { - .lg\:bui-w { - width: var(--width); - } - - .lg\:bui-min-w { - min-width: var(--min-width); - } - - .lg\:bui-max-w { - max-width: var(--max-width); - } - } - - @media (width >= 1536px) { - .xl\:bui-w { - width: var(--width); - } - - .xl\:bui-min-w { - min-width: var(--min-width); - } - - .xl\:bui-max-w { - max-width: var(--max-width); - } - } - - .bui-h { - height: var(--height); - } - - .bui-min-h { - min-height: var(--min-height); - } - - .bui-max-h { - max-height: var(--max-height); - } - - @media (width >= 640px) { - .xs\:bui-h { - height: var(--height); - } - - .xs\:bui-min-h { - min-height: var(--min-height); - } - - .xs\:bui-max-h { - max-height: var(--max-height); - } - } - - @media (width >= 768px) { - .sm\:bui-h { - height: var(--height); - } - - .sm\:bui-min-h { - min-height: var(--min-height); - } - - .sm\:bui-max-h { - max-height: var(--max-height); - } - } - - @media (width >= 1024px) { - .md\:bui-h { - height: var(--height); - } - - .md\:bui-min-h { - min-height: var(--min-height); - } - - .md\:bui-max-h { - max-height: var(--max-height); - } - } - - @media (width >= 1280px) { - .lg\:bui-h { - height: var(--height); - } - - .lg\:bui-min-h { - min-height: var(--min-height); - } - - .lg\:bui-max-h { - max-height: var(--max-height); - } - } - - @media (width >= 1536px) { - .xl\:bui-h { - height: var(--height); - } - - .xl\:bui-min-h { - min-height: var(--min-height); - } - - .xl\:bui-max-h { - max-height: var(--max-height); - } - } - - .bui-position-absolute { - position: absolute; - } - - .bui-position-fixed { - position: fixed; - } - - .bui-position-sticky { - position: sticky; - } - - .bui-position-relative { - position: relative; - } - - .bui-position-static { - position: static; - } - - @media (width >= 640px) { - .xs\:bui-position-absolute { - position: absolute; - } - - .xs\:bui-position-fixed { - position: fixed; - } - - .xs\:bui-position-sticky { - position: sticky; - } - - .xs\:bui-position-relative { - position: relative; - } - - .xs\:bui-position-static { - position: static; - } - } - - @media (width >= 768px) { - .sm\:bui-position-absolute { - position: absolute; - } - - .sm\:bui-position-fixed { - position: fixed; - } - - .sm\:bui-position-sticky { - position: sticky; - } - - .sm\:bui-position-relative { - position: relative; - } - - .sm\:bui-position-static { - position: static; - } - } - - @media (width >= 1024px) { - .md\:bui-position-absolute { - position: absolute; - } - - .md\:bui-position-fixed { - position: fixed; - } - - .md\:bui-position-sticky { - position: sticky; - } - - .md\:bui-position-relative { - position: relative; - } - - .md\:bui-position-static { - position: static; - } - } - - @media (width >= 1280px) { - .lg\:bui-position-absolute { - position: absolute; - } - - .lg\:bui-position-fixed { - position: fixed; - } - - .lg\:bui-position-sticky { - position: sticky; - } - - .lg\:bui-position-relative { - position: relative; - } - - .lg\:bui-position-static { - position: static; - } - } - - @media (width >= 1536px) { - .xl\:bui-position-absolute { - position: absolute; - } - - .xl\:bui-position-fixed { - position: fixed; - } - - .xl\:bui-position-sticky { - position: sticky; - } - - .xl\:bui-position-relative { - position: relative; - } - - .xl\:bui-position-static { - position: static; - } - } - - .bui-columns-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - - .bui-columns-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .bui-columns-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .bui-columns-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .bui-columns-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .bui-columns-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .bui-columns-7 { - grid-template-columns: repeat(7, minmax(0, 1fr)); - } - - .bui-columns-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .bui-columns-9 { - grid-template-columns: repeat(9, minmax(0, 1fr)); - } - - .bui-columns-10 { - grid-template-columns: repeat(10, minmax(0, 1fr)); - } - - .bui-columns-11 { - grid-template-columns: repeat(11, minmax(0, 1fr)); - } - - .bui-columns-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .bui-columns-auto { - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - } - - .bui-col-span-1 { - grid-column: span 1 / span 1; - } - - .bui-col-span-2 { - grid-column: span 2 / span 2; - } - - .bui-col-span-3 { - grid-column: span 3 / span 3; - } - - .bui-col-span-4 { - grid-column: span 4 / span 4; - } - - .bui-col-span-5 { - grid-column: span 5 / span 5; - } - - .bui-col-span-6 { - grid-column: span 6 / span 6; - } - - .bui-col-span-7 { - grid-column: span 7 / span 7; - } - - .bui-col-span-8 { - grid-column: span 8 / span 8; - } - - .bui-col-span-9 { - grid-column: span 9 / span 9; - } - - .bui-col-span-10 { - grid-column: span 10 / span 10; - } - - .bui-col-span-11 { - grid-column: span 11 / span 11; - } - - .bui-col-span-12 { - grid-column: span 12 / span 12; - } - - .bui-col-span-auto { - grid-column: span auto / span auto; - } - - .bui-col-start-1 { - grid-column-start: 1; - } - - .bui-col-start-2 { - grid-column-start: 2; - } - - .bui-col-start-3 { - grid-column-start: 3; - } - - .bui-col-start-4 { - grid-column-start: 4; - } - - .bui-col-start-5 { - grid-column-start: 5; - } - - .bui-col-start-6 { - grid-column-start: 6; - } - - .bui-col-start-7 { - grid-column-start: 7; - } - - .bui-col-start-8 { - grid-column-start: 8; - } - - .bui-col-start-9 { - grid-column-start: 9; - } - - .bui-col-start-10 { - grid-column-start: 10; - } - - .bui-col-start-11 { - grid-column-start: 11; - } - - .bui-col-start-12 { - grid-column-start: 12; - } - - .bui-col-start-13 { - grid-column-start: 13; - } - - .bui-col-start-auto { - grid-column-start: auto; - } - - .bui-col-end-1 { - grid-column-end: 1; - } - - .bui-col-end-2 { - grid-column-end: 2; - } - - .bui-col-end-3 { - grid-column-end: 3; - } - - .bui-col-end-4 { - grid-column-end: 4; - } - - .bui-col-end-5 { - grid-column-end: 5; - } - - .bui-col-end-6 { - grid-column-end: 6; - } - - .bui-col-end-7 { - grid-column-end: 7; - } - - .bui-col-end-8 { - grid-column-end: 8; - } - - .bui-col-end-9 { - grid-column-end: 9; - } - - .bui-col-end-10 { - grid-column-end: 10; - } - - .bui-col-end-11 { - grid-column-end: 11; - } - - .bui-col-end-12 { - grid-column-end: 12; - } - - .bui-col-end-13 { - grid-column-end: 13; - } - - .bui-col-end-auto { - grid-column-end: auto; - } - - .bui-row-span-1 { - grid-row: span 1 / span 1; - } - - .bui-row-span-2 { - grid-row: span 2 / span 2; - } - - .bui-row-span-3 { - grid-row: span 3 / span 3; - } - - .bui-row-span-4 { - grid-row: span 4 / span 4; - } - - .bui-row-span-5 { - grid-row: span 5 / span 5; - } - - .bui-row-span-6 { - grid-row: span 6 / span 6; - } - - .bui-row-span-7 { - grid-row: span 7 / span 7; - } - - .bui-row-span-8 { - grid-row: span 8 / span 8; - } - - .bui-row-span-9 { - grid-row: span 9 / span 9; - } - - .bui-row-span-10 { - grid-row: span 10 / span 10; - } - - .bui-row-span-11 { - grid-row: span 11 / span 11; - } - - .bui-row-span-12 { - grid-row: span 12 / span 12; - } - - .bui-row-span-auto { - grid-row: span auto / span auto; - } - - @media (width >= 640px) { - .xs\:bui-columns-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - - .xs\:bui-columns-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .xs\:bui-columns-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .xs\:bui-columns-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .xs\:bui-columns-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .xs\:bui-columns-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .xs\:bui-columns-7 { - grid-template-columns: repeat(7, minmax(0, 1fr)); - } - - .xs\:bui-columns-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .xs\:bui-columns-9 { - grid-template-columns: repeat(9, minmax(0, 1fr)); - } - - .xs\:bui-columns-10 { - grid-template-columns: repeat(10, minmax(0, 1fr)); - } - - .xs\:bui-columns-11 { - grid-template-columns: repeat(11, minmax(0, 1fr)); - } - - .xs\:bui-columns-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .xs\:bui-columns-auto { - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - } - - .xs\:bui-col-span-1 { - grid-column: span 1 / span 1; - } - - .xs\:bui-col-span-2 { - grid-column: span 2 / span 2; - } - - .xs\:bui-col-span-3 { - grid-column: span 3 / span 3; - } - - .xs\:bui-col-span-4 { - grid-column: span 4 / span 4; - } - - .xs\:bui-col-span-5 { - grid-column: span 5 / span 5; - } - - .xs\:bui-col-span-6 { - grid-column: span 6 / span 6; - } - - .xs\:bui-col-span-7 { - grid-column: span 7 / span 7; - } - - .xs\:bui-col-span-8 { - grid-column: span 8 / span 8; - } - - .xs\:bui-col-span-9 { - grid-column: span 9 / span 9; - } - - .xs\:bui-col-span-10 { - grid-column: span 10 / span 10; - } - - .xs\:bui-col-span-11 { - grid-column: span 11 / span 11; - } - - .xs\:bui-col-span-12 { - grid-column: span 12 / span 12; - } - - .xs\:bui-col-span-auto { - grid-column: span auto / span auto; - } - - .xs\:bui-col-start-1 { - grid-column-start: 1; - } - - .xs\:bui-col-start-2 { - grid-column-start: 2; - } - - .xs\:bui-col-start-3 { - grid-column-start: 3; - } - - .xs\:bui-col-start-4 { - grid-column-start: 4; - } - - .xs\:bui-col-start-5 { - grid-column-start: 5; - } - - .xs\:bui-col-start-6 { - grid-column-start: 6; - } - - .xs\:bui-col-start-7 { - grid-column-start: 7; - } - - .xs\:bui-col-start-8 { - grid-column-start: 8; - } - - .xs\:bui-col-start-9 { - grid-column-start: 9; - } - - .xs\:bui-col-start-10 { - grid-column-start: 10; - } - - .xs\:bui-col-start-11 { - grid-column-start: 11; - } - - .xs\:bui-col-start-12 { - grid-column-start: 12; - } - - .xs\:bui-col-start-13 { - grid-column-start: 13; - } - - .xs\:bui-col-start-auto { - grid-column-start: auto; - } - - .xs\:bui-col-end-1 { - grid-column-end: 1; - } - - .xs\:bui-col-end-2 { - grid-column-end: 2; - } - - .xs\:bui-col-end-3 { - grid-column-end: 3; - } - - .xs\:bui-col-end-4 { - grid-column-end: 4; - } - - .xs\:bui-col-end-5 { - grid-column-end: 5; - } - - .xs\:bui-col-end-6 { - grid-column-end: 6; - } - - .xs\:bui-col-end-7 { - grid-column-end: 7; - } - - .xs\:bui-col-end-8 { - grid-column-end: 8; - } - - .xs\:bui-col-end-9 { - grid-column-end: 9; - } - - .xs\:bui-col-end-10 { - grid-column-end: 10; - } - - .xs\:bui-col-end-11 { - grid-column-end: 11; - } - - .xs\:bui-col-end-12 { - grid-column-end: 12; - } - - .xs\:bui-col-end-13 { - grid-column-end: 13; - } - - .xs\:bui-col-end-auto { - grid-column-end: auto; - } - - .xs\:bui-row-span-1 { - grid-row: span 1 / span 1; - } - - .xs\:bui-row-span-2 { - grid-row: span 2 / span 2; - } - - .xs\:bui-row-span-3 { - grid-row: span 3 / span 3; - } - - .xs\:bui-row-span-4 { - grid-row: span 4 / span 4; - } - - .xs\:bui-row-span-5 { - grid-row: span 5 / span 5; - } - - .xs\:bui-row-span-6 { - grid-row: span 6 / span 6; - } - - .xs\:bui-row-span-7 { - grid-row: span 7 / span 7; - } - - .xs\:bui-row-span-8 { - grid-row: span 8 / span 8; - } - - .xs\:bui-row-span-9 { - grid-row: span 9 / span 9; - } - - .xs\:bui-row-span-10 { - grid-row: span 10 / span 10; - } - - .xs\:bui-row-span-11 { - grid-row: span 11 / span 11; - } - - .xs\:bui-row-span-12 { - grid-row: span 12 / span 12; - } - - .xs\:bui-row-span-auto { - grid-row: span auto / span auto; - } - } - - @media (width >= 768px) { - .sm\:bui-columns-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - - .sm\:bui-columns-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .sm\:bui-columns-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .sm\:bui-columns-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .sm\:bui-columns-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .sm\:bui-columns-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .sm\:bui-columns-7 { - grid-template-columns: repeat(7, minmax(0, 1fr)); - } - - .sm\:bui-columns-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .sm\:bui-columns-9 { - grid-template-columns: repeat(9, minmax(0, 1fr)); - } - - .sm\:bui-columns-10 { - grid-template-columns: repeat(10, minmax(0, 1fr)); - } - - .sm\:bui-columns-11 { - grid-template-columns: repeat(11, minmax(0, 1fr)); - } - - .sm\:bui-columns-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .sm\:bui-columns-auto { - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - } - - .sm\:bui-col-span-1 { - grid-column: span 1 / span 1; - } - - .sm\:bui-col-span-2 { - grid-column: span 2 / span 2; - } - - .sm\:bui-col-span-3 { - grid-column: span 3 / span 3; - } - - .sm\:bui-col-span-4 { - grid-column: span 4 / span 4; - } - - .sm\:bui-col-span-5 { - grid-column: span 5 / span 5; - } - - .sm\:bui-col-span-6 { - grid-column: span 6 / span 6; - } - - .sm\:bui-col-span-7 { - grid-column: span 7 / span 7; - } - - .sm\:bui-col-span-8 { - grid-column: span 8 / span 8; - } - - .sm\:bui-col-span-9 { - grid-column: span 9 / span 9; - } - - .sm\:bui-col-span-10 { - grid-column: span 10 / span 10; - } - - .sm\:bui-col-span-11 { - grid-column: span 11 / span 11; - } - - .sm\:bui-col-span-12 { - grid-column: span 12 / span 12; - } - - .sm\:bui-col-span-auto { - grid-column: span auto / span auto; - } - - .sm\:bui-col-start-1 { - grid-column-start: 1; - } - - .sm\:bui-col-start-2 { - grid-column-start: 2; - } - - .sm\:bui-col-start-3 { - grid-column-start: 3; - } - - .sm\:bui-col-start-4 { - grid-column-start: 4; - } - - .sm\:bui-col-start-5 { - grid-column-start: 5; - } - - .sm\:bui-col-start-6 { - grid-column-start: 6; - } - - .sm\:bui-col-start-7 { - grid-column-start: 7; - } - - .sm\:bui-col-start-8 { - grid-column-start: 8; - } - - .sm\:bui-col-start-9 { - grid-column-start: 9; - } - - .sm\:bui-col-start-10 { - grid-column-start: 10; - } - - .sm\:bui-col-start-11 { - grid-column-start: 11; - } - - .sm\:bui-col-start-12 { - grid-column-start: 12; - } - - .sm\:bui-col-start-13 { - grid-column-start: 13; - } - - .sm\:bui-col-start-auto { - grid-column-start: auto; - } - - .sm\:bui-col-end-1 { - grid-column-end: 1; - } - - .sm\:bui-col-end-2 { - grid-column-end: 2; - } - - .sm\:bui-col-end-3 { - grid-column-end: 3; - } - - .sm\:bui-col-end-4 { - grid-column-end: 4; - } - - .sm\:bui-col-end-5 { - grid-column-end: 5; - } - - .sm\:bui-col-end-6 { - grid-column-end: 6; - } - - .sm\:bui-col-end-7 { - grid-column-end: 7; - } - - .sm\:bui-col-end-8 { - grid-column-end: 8; - } - - .sm\:bui-col-end-9 { - grid-column-end: 9; - } - - .sm\:bui-col-end-10 { - grid-column-end: 10; - } - - .sm\:bui-col-end-11 { - grid-column-end: 11; - } - - .sm\:bui-col-end-12 { - grid-column-end: 12; - } - - .sm\:bui-col-end-13 { - grid-column-end: 13; - } - - .sm\:bui-col-end-auto { - grid-column-end: auto; - } - - .sm\:bui-row-span-1 { - grid-row: span 1 / span 1; - } - - .sm\:bui-row-span-2 { - grid-row: span 2 / span 2; - } - - .sm\:bui-row-span-3 { - grid-row: span 3 / span 3; - } - - .sm\:bui-row-span-4 { - grid-row: span 4 / span 4; - } - - .sm\:bui-row-span-5 { - grid-row: span 5 / span 5; - } - - .sm\:bui-row-span-6 { - grid-row: span 6 / span 6; - } - - .sm\:bui-row-span-7 { - grid-row: span 7 / span 7; - } - - .sm\:bui-row-span-8 { - grid-row: span 8 / span 8; - } - - .sm\:bui-row-span-9 { - grid-row: span 9 / span 9; - } - - .sm\:bui-row-span-10 { - grid-row: span 10 / span 10; - } - - .sm\:bui-row-span-11 { - grid-row: span 11 / span 11; - } - - .sm\:bui-row-span-12 { - grid-row: span 12 / span 12; - } - - .sm\:bui-row-span-auto { - grid-row: span auto / span auto; - } - } - - @media (width >= 1024px) { - .md\:bui-columns-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - - .md\:bui-columns-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .md\:bui-columns-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .md\:bui-columns-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .md\:bui-columns-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .md\:bui-columns-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .md\:bui-columns-7 { - grid-template-columns: repeat(7, minmax(0, 1fr)); - } - - .md\:bui-columns-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .md\:bui-columns-9 { - grid-template-columns: repeat(9, minmax(0, 1fr)); - } - - .md\:bui-columns-10 { - grid-template-columns: repeat(10, minmax(0, 1fr)); - } - - .md\:bui-columns-11 { - grid-template-columns: repeat(11, minmax(0, 1fr)); - } - - .md\:bui-columns-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .md\:bui-columns-auto { - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - } - - .md\:bui-col-span-1 { - grid-column: span 1 / span 1; - } - - .md\:bui-col-span-2 { - grid-column: span 2 / span 2; - } - - .md\:bui-col-span-3 { - grid-column: span 3 / span 3; - } - - .md\:bui-col-span-4 { - grid-column: span 4 / span 4; - } - - .md\:bui-col-span-5 { - grid-column: span 5 / span 5; - } - - .md\:bui-col-span-6 { - grid-column: span 6 / span 6; - } - - .md\:bui-col-span-7 { - grid-column: span 7 / span 7; - } - - .md\:bui-col-span-8 { - grid-column: span 8 / span 8; - } - - .md\:bui-col-span-9 { - grid-column: span 9 / span 9; - } - - .md\:bui-col-span-10 { - grid-column: span 10 / span 10; - } - - .md\:bui-col-span-11 { - grid-column: span 11 / span 11; - } - - .md\:bui-col-span-12 { - grid-column: span 12 / span 12; - } - - .md\:bui-col-span-auto { - grid-column: span auto / span auto; - } - - .md\:bui-col-start-1 { - grid-column-start: 1; - } - - .md\:bui-col-start-2 { - grid-column-start: 2; - } - - .md\:bui-col-start-3 { - grid-column-start: 3; - } - - .md\:bui-col-start-4 { - grid-column-start: 4; - } - - .md\:bui-col-start-5 { - grid-column-start: 5; - } - - .md\:bui-col-start-6 { - grid-column-start: 6; - } - - .md\:bui-col-start-7 { - grid-column-start: 7; - } - - .md\:bui-col-start-8 { - grid-column-start: 8; - } - - .md\:bui-col-start-9 { - grid-column-start: 9; - } - - .md\:bui-col-start-10 { - grid-column-start: 10; - } - - .md\:bui-col-start-11 { - grid-column-start: 11; - } - - .md\:bui-col-start-12 { - grid-column-start: 12; - } - - .md\:bui-col-start-13 { - grid-column-start: 13; - } - - .md\:bui-col-start-auto { - grid-column-start: auto; - } - - .md\:bui-col-end-1 { - grid-column-end: 1; - } - - .md\:bui-col-end-2 { - grid-column-end: 2; - } - - .md\:bui-col-end-3 { - grid-column-end: 3; - } - - .md\:bui-col-end-4 { - grid-column-end: 4; - } - - .md\:bui-col-end-5 { - grid-column-end: 5; - } - - .md\:bui-col-end-6 { - grid-column-end: 6; - } - - .md\:bui-col-end-7 { - grid-column-end: 7; - } - - .md\:bui-col-end-8 { - grid-column-end: 8; - } - - .md\:bui-col-end-9 { - grid-column-end: 9; - } - - .md\:bui-col-end-10 { - grid-column-end: 10; - } - - .md\:bui-col-end-11 { - grid-column-end: 11; - } - - .md\:bui-col-end-12 { - grid-column-end: 12; - } - - .md\:bui-col-end-13 { - grid-column-end: 13; - } - - .md\:bui-col-end-auto { - grid-column-end: auto; - } - - .md\:bui-row-span-1 { - grid-row: span 1 / span 1; - } - - .md\:bui-row-span-2 { - grid-row: span 2 / span 2; - } - - .md\:bui-row-span-3 { - grid-row: span 3 / span 3; - } - - .md\:bui-row-span-4 { - grid-row: span 4 / span 4; - } - - .md\:bui-row-span-5 { - grid-row: span 5 / span 5; - } - - .md\:bui-row-span-6 { - grid-row: span 6 / span 6; - } - - .md\:bui-row-span-7 { - grid-row: span 7 / span 7; - } - - .md\:bui-row-span-8 { - grid-row: span 8 / span 8; - } - - .md\:bui-row-span-9 { - grid-row: span 9 / span 9; - } - - .md\:bui-row-span-10 { - grid-row: span 10 / span 10; - } - - .md\:bui-row-span-11 { - grid-row: span 11 / span 11; - } - - .md\:bui-row-span-12 { - grid-row: span 12 / span 12; - } - - .md\:bui-row-span-auto { - grid-row: span auto / span auto; - } - } - - @media (width >= 1280px) { - .lg\:bui-columns-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - - .lg\:bui-columns-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .lg\:bui-columns-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .lg\:bui-columns-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .lg\:bui-columns-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .lg\:bui-columns-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .lg\:bui-columns-7 { - grid-template-columns: repeat(7, minmax(0, 1fr)); - } - - .lg\:bui-columns-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .lg\:bui-columns-9 { - grid-template-columns: repeat(9, minmax(0, 1fr)); - } - - .lg\:bui-columns-10 { - grid-template-columns: repeat(10, minmax(0, 1fr)); - } - - .lg\:bui-columns-11 { - grid-template-columns: repeat(11, minmax(0, 1fr)); - } - - .lg\:bui-columns-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .lg\:bui-columns-auto { - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - } - - .lg\:bui-col-span-1 { - grid-column: span 1 / span 1; - } - - .lg\:bui-col-span-2 { - grid-column: span 2 / span 2; - } - - .lg\:bui-col-span-3 { - grid-column: span 3 / span 3; - } - - .lg\:bui-col-span-4 { - grid-column: span 4 / span 4; - } - - .lg\:bui-col-span-5 { - grid-column: span 5 / span 5; - } - - .lg\:bui-col-span-6 { - grid-column: span 6 / span 6; - } - - .lg\:bui-col-span-7 { - grid-column: span 7 / span 7; - } - - .lg\:bui-col-span-8 { - grid-column: span 8 / span 8; - } - - .lg\:bui-col-span-9 { - grid-column: span 9 / span 9; - } - - .lg\:bui-col-span-10 { - grid-column: span 10 / span 10; - } - - .lg\:bui-col-span-11 { - grid-column: span 11 / span 11; - } - - .lg\:bui-col-span-12 { - grid-column: span 12 / span 12; - } - - .lg\:bui-col-span-auto { - grid-column: span auto / span auto; - } - - .lg\:bui-col-start-1 { - grid-column-start: 1; - } - - .lg\:bui-col-start-2 { - grid-column-start: 2; - } - - .lg\:bui-col-start-3 { - grid-column-start: 3; - } - - .lg\:bui-col-start-4 { - grid-column-start: 4; - } - - .lg\:bui-col-start-5 { - grid-column-start: 5; - } - - .lg\:bui-col-start-6 { - grid-column-start: 6; - } - - .lg\:bui-col-start-7 { - grid-column-start: 7; - } - - .lg\:bui-col-start-8 { - grid-column-start: 8; - } - - .lg\:bui-col-start-9 { - grid-column-start: 9; - } - - .lg\:bui-col-start-10 { - grid-column-start: 10; - } - - .lg\:bui-col-start-11 { - grid-column-start: 11; - } - - .lg\:bui-col-start-12 { - grid-column-start: 12; - } - - .lg\:bui-col-start-13 { - grid-column-start: 13; - } - - .lg\:bui-col-start-auto { - grid-column-start: auto; - } - - .lg\:bui-col-end-1 { - grid-column-end: 1; - } - - .lg\:bui-col-end-2 { - grid-column-end: 2; - } - - .lg\:bui-col-end-3 { - grid-column-end: 3; - } - - .lg\:bui-col-end-4 { - grid-column-end: 4; - } - - .lg\:bui-col-end-5 { - grid-column-end: 5; - } - - .lg\:bui-col-end-6 { - grid-column-end: 6; - } - - .lg\:bui-col-end-7 { - grid-column-end: 7; - } - - .lg\:bui-col-end-8 { - grid-column-end: 8; - } - - .lg\:bui-col-end-9 { - grid-column-end: 9; - } - - .lg\:bui-col-end-10 { - grid-column-end: 10; - } - - .lg\:bui-col-end-11 { - grid-column-end: 11; - } - - .lg\:bui-col-end-12 { - grid-column-end: 12; - } - - .lg\:bui-col-end-13 { - grid-column-end: 13; - } - - .lg\:bui-col-end-auto { - grid-column-end: auto; - } - - .lg\:bui-row-span-1 { - grid-row: span 1 / span 1; - } - - .lg\:bui-row-span-2 { - grid-row: span 2 / span 2; - } - - .lg\:bui-row-span-3 { - grid-row: span 3 / span 3; - } - - .lg\:bui-row-span-4 { - grid-row: span 4 / span 4; - } - - .lg\:bui-row-span-5 { - grid-row: span 5 / span 5; - } - - .lg\:bui-row-span-6 { - grid-row: span 6 / span 6; - } - - .lg\:bui-row-span-7 { - grid-row: span 7 / span 7; - } - - .lg\:bui-row-span-8 { - grid-row: span 8 / span 8; - } - - .lg\:bui-row-span-9 { - grid-row: span 9 / span 9; - } - - .lg\:bui-row-span-10 { - grid-row: span 10 / span 10; - } - - .lg\:bui-row-span-11 { - grid-row: span 11 / span 11; - } - - .lg\:bui-row-span-12 { - grid-row: span 12 / span 12; - } - - .lg\:bui-row-span-auto { - grid-row: span auto / span auto; - } - } - - @media (width >= 1536px) { - .xl\:bui-columns-1 { - grid-template-columns: repeat(1, minmax(0, 1fr)); - } - - .xl\:bui-columns-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .xl\:bui-columns-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .xl\:bui-columns-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .xl\:bui-columns-5 { - grid-template-columns: repeat(5, minmax(0, 1fr)); - } - - .xl\:bui-columns-6 { - grid-template-columns: repeat(6, minmax(0, 1fr)); - } - - .xl\:bui-columns-7 { - grid-template-columns: repeat(7, minmax(0, 1fr)); - } - - .xl\:bui-columns-8 { - grid-template-columns: repeat(8, minmax(0, 1fr)); - } - - .xl\:bui-columns-9 { - grid-template-columns: repeat(9, minmax(0, 1fr)); - } - - .xl\:bui-columns-10 { - grid-template-columns: repeat(10, minmax(0, 1fr)); - } - - .xl\:bui-columns-11 { - grid-template-columns: repeat(11, minmax(0, 1fr)); - } - - .xl\:bui-columns-12 { - grid-template-columns: repeat(12, minmax(0, 1fr)); - } - - .xl\:bui-columns-auto { - grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); - } - - .xl\:bui-col-span-1 { - grid-column: span 1 / span 1; - } - - .xl\:bui-col-span-2 { - grid-column: span 2 / span 2; - } - - .xl\:bui-col-span-3 { - grid-column: span 3 / span 3; - } - - .xl\:bui-col-span-4 { - grid-column: span 4 / span 4; - } - - .xl\:bui-col-span-5 { - grid-column: span 5 / span 5; - } - - .xl\:bui-col-span-6 { - grid-column: span 6 / span 6; - } - - .xl\:bui-col-span-7 { - grid-column: span 7 / span 7; - } - - .xl\:bui-col-span-8 { - grid-column: span 8 / span 8; - } - - .xl\:bui-col-span-9 { - grid-column: span 9 / span 9; - } - - .xl\:bui-col-span-10 { - grid-column: span 10 / span 10; - } - - .xl\:bui-col-span-11 { - grid-column: span 11 / span 11; - } - - .xl\:bui-col-span-12 { - grid-column: span 12 / span 12; - } - - .xl\:bui-col-span-auto { - grid-column: span auto / span auto; - } - - .xl\:bui-col-start-1 { - grid-column-start: 1; - } - - .xl\:bui-col-start-2 { - grid-column-start: 2; - } - - .xl\:bui-col-start-3 { - grid-column-start: 3; - } - - .xl\:bui-col-start-4 { - grid-column-start: 4; - } - - .xl\:bui-col-start-5 { - grid-column-start: 5; - } - - .xl\:bui-col-start-6 { - grid-column-start: 6; - } - - .xl\:bui-col-start-7 { - grid-column-start: 7; - } - - .xl\:bui-col-start-8 { - grid-column-start: 8; - } - - .xl\:bui-col-start-9 { - grid-column-start: 9; - } - - .xl\:bui-col-start-10 { - grid-column-start: 10; - } - - .xl\:bui-col-start-11 { - grid-column-start: 11; - } - - .xl\:bui-col-start-12 { - grid-column-start: 12; - } - - .xl\:bui-col-start-13 { - grid-column-start: 13; - } - - .xl\:bui-col-start-auto { - grid-column-start: auto; - } - - .xl\:bui-col-end-1 { - grid-column-end: 1; - } - - .xl\:bui-col-end-2 { - grid-column-end: 2; - } - - .xl\:bui-col-end-3 { - grid-column-end: 3; - } - - .xl\:bui-col-end-4 { - grid-column-end: 4; - } - - .xl\:bui-col-end-5 { - grid-column-end: 5; - } - - .xl\:bui-col-end-6 { - grid-column-end: 6; - } - - .xl\:bui-col-end-7 { - grid-column-end: 7; - } - - .xl\:bui-col-end-8 { - grid-column-end: 8; - } - - .xl\:bui-col-end-9 { - grid-column-end: 9; - } - - .xl\:bui-col-end-10 { - grid-column-end: 10; - } - - .xl\:bui-col-end-11 { - grid-column-end: 11; - } - - .xl\:bui-col-end-12 { - grid-column-end: 12; - } - - .xl\:bui-col-end-13 { - grid-column-end: 13; - } - - .xl\:bui-col-end-auto { - grid-column-end: auto; - } - - .xl\:bui-row-span-1 { - grid-row: span 1 / span 1; - } - - .xl\:bui-row-span-2 { - grid-row: span 2 / span 2; - } - - .xl\:bui-row-span-3 { - grid-row: span 3 / span 3; - } - - .xl\:bui-row-span-4 { - grid-row: span 4 / span 4; - } - - .xl\:bui-row-span-5 { - grid-row: span 5 / span 5; - } - - .xl\:bui-row-span-6 { - grid-row: span 6 / span 6; - } - - .xl\:bui-row-span-7 { - grid-row: span 7 / span 7; - } - - .xl\:bui-row-span-8 { - grid-row: span 8 / span 8; - } - - .xl\:bui-row-span-9 { - grid-row: span 9 / span 9; - } - - .xl\:bui-row-span-10 { - grid-row: span 10 / span 10; - } - - .xl\:bui-row-span-11 { - grid-row: span 11 / span 11; - } - - .xl\:bui-row-span-12 { - grid-row: span 12 / span 12; - } - - .xl\:bui-row-span-auto { - grid-row: span auto / span auto; - } - } - - .bui-gap { - gap: var(--gap); - } - - .bui-gap-0\.5 { - gap: var(--bui-space-0_5); - } - - .bui-gap-1 { - gap: var(--bui-space-1); - } - - .bui-gap-1\.5 { - gap: var(--bui-space-1_5); - } - - .bui-gap-2 { - gap: var(--bui-space-2); - } - - .bui-gap-3 { - gap: var(--bui-space-3); - } - - .bui-gap-4 { - gap: var(--bui-space-4); - } - - .bui-gap-5 { - gap: var(--bui-space-5); - } - - .bui-gap-6 { - gap: var(--bui-space-6); - } - - .bui-gap-7 { - gap: var(--bui-space-7); - } - - .bui-gap-8 { - gap: var(--bui-space-8); - } - - .bui-gap-9 { - gap: var(--bui-space-9); - } - - .bui-gap-10 { - gap: var(--bui-space-10); - } - - .bui-gap-11 { - gap: var(--bui-space-11); - } - - .bui-gap-12 { - gap: var(--bui-space-12); - } - - .bui-gap-13 { - gap: var(--bui-space-13); - } - - .bui-gap-14 { - gap: var(--bui-space-14); - } - - @media (width >= 640px) { - .xs\:bui-gap { - gap: var(--gap-xs); - } - - .xs\:bui-gap-0\.5 { - gap: var(--bui-space-0_5); - } - - .xs\:bui-gap-1 { - gap: var(--bui-space-1); - } - - .xs\:bui-gap-1\.5 { - gap: var(--bui-space-1_5); - } - - .xs\:bui-gap-2 { - gap: var(--bui-space-2); - } - - .xs\:bui-gap-3 { - gap: var(--bui-space-3); - } - - .xs\:bui-gap-4 { - gap: var(--bui-space-4); - } - - .xs\:bui-gap-5 { - gap: var(--bui-space-5); - } - - .xs\:bui-gap-6 { - gap: var(--bui-space-6); - } - - .xs\:bui-gap-7 { - gap: var(--bui-space-7); - } - - .xs\:bui-gap-8 { - gap: var(--bui-space-8); - } - - .xs\:bui-gap-9 { - gap: var(--bui-space-9); - } - - .xs\:bui-gap-10 { - gap: var(--bui-space-10); - } - - .xs\:bui-gap-11 { - gap: var(--bui-space-11); - } - - .xs\:bui-gap-12 { - gap: var(--bui-space-12); - } - - .xs\:bui-gap-13 { - gap: var(--bui-space-13); - } - - .xs\:bui-gap-14 { - gap: var(--bui-space-14); - } - } - - @media (width >= 768px) { - .sm\:bui-gap { - gap: var(--gap-sm); - } - - .sm\:bui-gap-0\.5 { - gap: var(--bui-space-0_5); - } - - .sm\:bui-gap-1 { - gap: var(--bui-space-1); - } - - .sm\:bui-gap-1\.5 { - gap: var(--bui-space-1_5); - } - - .sm\:bui-gap-2 { - gap: var(--bui-space-2); - } - - .sm\:bui-gap-3 { - gap: var(--bui-space-3); - } - - .sm\:bui-gap-4 { - gap: var(--bui-space-4); - } - - .sm\:bui-gap-5 { - gap: var(--bui-space-5); - } - - .sm\:bui-gap-6 { - gap: var(--bui-space-6); - } - - .sm\:bui-gap-7 { - gap: var(--bui-space-7); - } - - .sm\:bui-gap-8 { - gap: var(--bui-space-8); - } - - .sm\:bui-gap-9 { - gap: var(--bui-space-9); - } - - .sm\:bui-gap-10 { - gap: var(--bui-space-10); - } - - .sm\:bui-gap-11 { - gap: var(--bui-space-11); - } - - .sm\:bui-gap-12 { - gap: var(--bui-space-12); - } - - .sm\:bui-gap-13 { - gap: var(--bui-space-13); - } - - .sm\:bui-gap-14 { - gap: var(--bui-space-14); - } - } - - @media (width >= 1024px) { - .md\:bui-gap { - gap: var(--gap-md); - } - - .md\:bui-gap-0\.5 { - gap: var(--bui-space-0_5); - } - - .md\:bui-gap-1 { - gap: var(--bui-space-1); - } - - .md\:bui-gap-1\.5 { - gap: var(--bui-space-1_5); - } - - .md\:bui-gap-2 { - gap: var(--bui-space-2); - } - - .md\:bui-gap-3 { - gap: var(--bui-space-3); - } - - .md\:bui-gap-4 { - gap: var(--bui-space-4); - } - - .md\:bui-gap-5 { - gap: var(--bui-space-5); - } - - .md\:bui-gap-6 { - gap: var(--bui-space-6); - } - - .md\:bui-gap-7 { - gap: var(--bui-space-7); - } - - .md\:bui-gap-8 { - gap: var(--bui-space-8); - } - - .md\:bui-gap-9 { - gap: var(--bui-space-9); - } - - .md\:bui-gap-10 { - gap: var(--bui-space-10); - } - - .md\:bui-gap-11 { - gap: var(--bui-space-11); - } - - .md\:bui-gap-12 { - gap: var(--bui-space-12); - } - - .md\:bui-gap-13 { - gap: var(--bui-space-13); - } - - .md\:bui-gap-14 { - gap: var(--bui-space-14); - } - } - - @media (width >= 1280px) { - .lg\:bui-gap { - gap: var(--gap-lg); - } - - .lg\:bui-gap-0\.5 { - gap: var(--bui-space-0_5); - } - - .lg\:bui-gap-1 { - gap: var(--bui-space-1); - } - - .lg\:bui-gap-1\.5 { - gap: var(--bui-space-1_5); - } - - .lg\:bui-gap-2 { - gap: var(--bui-space-2); - } - - .lg\:bui-gap-3 { - gap: var(--bui-space-3); - } - - .lg\:bui-gap-4 { - gap: var(--bui-space-4); - } - - .lg\:bui-gap-5 { - gap: var(--bui-space-5); - } - - .lg\:bui-gap-6 { - gap: var(--bui-space-6); - } - - .lg\:bui-gap-7 { - gap: var(--bui-space-7); - } - - .lg\:bui-gap-8 { - gap: var(--bui-space-8); - } - - .lg\:bui-gap-9 { - gap: var(--bui-space-9); - } - - .lg\:bui-gap-10 { - gap: var(--bui-space-10); - } - - .lg\:bui-gap-11 { - gap: var(--bui-space-11); - } - - .lg\:bui-gap-12 { - gap: var(--bui-space-12); - } - - .lg\:bui-gap-13 { - gap: var(--bui-space-13); - } - - .lg\:bui-gap-14 { - gap: var(--bui-space-14); - } - } - - @media (width >= 1536px) { - .xl\:bui-gap { - gap: var(--gap-xl); - } - - .xl\:bui-gap-0\.5 { - gap: var(--bui-space-0_5); - } - - .xl\:bui-gap-1 { - gap: var(--bui-space-1); - } - - .xl\:bui-gap-1\.5 { - gap: var(--bui-space-1_5); - } - - .xl\:bui-gap-2 { - gap: var(--bui-space-2); - } - - .xl\:bui-gap-3 { - gap: var(--bui-space-3); - } - - .xl\:bui-gap-4 { - gap: var(--bui-space-4); - } - - .xl\:bui-gap-5 { - gap: var(--bui-space-5); - } - - .xl\:bui-gap-6 { - gap: var(--bui-space-6); - } - - .xl\:bui-gap-7 { - gap: var(--bui-space-7); - } - - .xl\:bui-gap-8 { - gap: var(--bui-space-8); - } - - .xl\:bui-gap-9 { - gap: var(--bui-space-9); - } - - .xl\:bui-gap-10 { - gap: var(--bui-space-10); - } - - .xl\:bui-gap-11 { - gap: var(--bui-space-11); - } - - .xl\:bui-gap-12 { - gap: var(--bui-space-12); - } - - .xl\:bui-gap-13 { - gap: var(--bui-space-13); - } - - .xl\:bui-gap-14 { - gap: var(--bui-space-14); - } - } - - .bui-align-start { - align-items: start; - } - - .bui-align-center { - align-items: center; - } - - .bui-align-end { - align-items: end; - } - - .bui-align-baseline { - align-items: baseline; - } - - .bui-align-stretch { - align-items: stretch; - } - - .bui-jc-start { - justify-content: start; - } - - .bui-jc-center { - justify-content: center; - } - - .bui-jc-end { - justify-content: end; - } - - .bui-jc-between { - justify-content: space-between; - } - - .bui-fd-row { - flex-direction: row; - } - - .bui-fd-column { - flex-direction: column; - } - - .bui-fd-row-reverse { - flex-direction: row-reverse; - } - - .bui-fd-column-reverse { - flex-direction: column-reverse; - } - - @media (width >= 640px) { - .xs\:bui-align-start { - align-items: start; - } - - .xs\:bui-align-center { - align-items: center; - } - - .xs\:bui-align-end { - align-items: end; - } - - .xs\:bui-align-stretch { - align-items: stretch; - } - - .xs\:bui-jc-start { - justify-content: start; - } - - .xs\:bui-jc-center { - justify-content: center; - } - - .xs\:bui-jc-end { - justify-content: end; - } - - .xs\:bui-jc-between { - justify-content: space-between; - } - - .xs\:bui-fd-row { - flex-direction: row; - } - - .xs\:bui-fd-column { - flex-direction: column; - } - - .xs\:bui-fd-row-reverse { - flex-direction: row-reverse; - } - - .xs\:bui-fd-column-reverse { - flex-direction: column-reverse; - } - } - - @media (width >= 768px) { - .sm\:bui-align-start { - align-items: start; - } - - .sm\:bui-align-center { - align-items: center; - } - - .sm\:bui-align-end { - align-items: end; - } - - .sm\:bui-align-stretch { - align-items: stretch; - } - - .sm\:bui-jc-start { - justify-content: start; - } - - .sm\:bui-jc-center { - justify-content: center; - } - - .sm\:bui-jc-end { - justify-content: end; - } - - .sm\:bui-jc-between { - justify-content: space-between; - } - - .sm\:bui-fd-row { - flex-direction: row; - } - - .sm\:bui-fd-column { - flex-direction: column; - } - - .sm\:bui-fd-row-reverse { - flex-direction: row-reverse; - } - - .sm\:bui-fd-column-reverse { - flex-direction: column-reverse; - } - } - - @media (width >= 1024px) { - .md\:bui-align-start { - align-items: start; - } - - .md\:bui-align-center { - align-items: center; - } - - .md\:bui-align-end { - align-items: end; - } - - .md\:bui-align-stretch { - align-items: stretch; - } - - .md\:bui-jc-start { - justify-content: start; - } - - .md\:bui-jc-center { - justify-content: center; - } - - .md\:bui-jc-end { - justify-content: end; - } - - .md\:bui-jc-between { - justify-content: space-between; - } - - .md\:bui-fd-row { - flex-direction: row; - } - - .md\:bui-fd-column { - flex-direction: column; - } - - .md\:bui-fd-row-reverse { - flex-direction: row-reverse; - } - - .md\:bui-fd-column-reverse { - flex-direction: column-reverse; - } - } - - @media (width >= 1280px) { - .lg\:bui-align-start { - align-items: start; - } - - .lg\:bui-align-center { - align-items: center; - } - - .lg\:bui-align-end { - align-items: end; - } - - .lg\:bui-align-stretch { - align-items: stretch; - } - - .lg\:bui-jc-start { - justify-content: start; - } - - .lg\:bui-jc-center { - justify-content: center; - } - - .lg\:bui-jc-end { - justify-content: end; - } - - .lg\:bui-jc-between { - justify-content: space-between; - } - - .lg\:bui-fd-row { - flex-direction: row; - } - - .lg\:bui-fd-column { - flex-direction: column; - } - - .lg\:bui-fd-row-reverse { - flex-direction: row-reverse; - } - - .lg\:bui-fd-column-reverse { - flex-direction: column-reverse; - } - } - - @media (width >= 1536px) { - .xl\:bui-align-start { - align-items: start; - } - - .xl\:bui-align-center { - align-items: center; - } - - .xl\:bui-align-end { - align-items: end; - } - - .xl\:bui-align-stretch { - align-items: stretch; - } - - .xl\:bui-jc-start { - justify-content: start; - } - - .xl\:bui-jc-center { - justify-content: center; - } - - .xl\:bui-jc-end { - justify-content: end; - } - - .xl\:bui-jc-between { - justify-content: space-between; - } - - .xl\:bui-fd-row { - flex-direction: row; - } - - .xl\:bui-fd-column { - flex-direction: column; - } - - .xl\:bui-fd-row-reverse { - flex-direction: row-reverse; - } - - .xl\:bui-fd-column-reverse { - flex-direction: column-reverse; - } - } -} diff --git a/packages/ui/package.json b/packages/ui/package.json index 7dd52b293c..a70a060dad 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,9 +5,7 @@ "role": "web-library" }, "publishConfig": { - "access": "public", - "main": "dist/index.esm.js", - "types": "dist/index.d.ts" + "access": "public" }, "keywords": [ "backstage" @@ -22,17 +20,25 @@ "sideEffects": [ "*.css" ], + "exports": { + ".": "./src/index.ts", + "./css/styles.css": "./src/css/styles.css", + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "package.json": [ + "package.json" + ] + } + }, "main": "src/index.ts", "types": "src/index.ts", "files": [ - "dist", - "css" + "dist" ], "scripts": { - "build": "yarn build:app && yarn build:css", - "build:app": "backstage-cli package build", - "build:css": "node scripts/build-css.mjs", - "build:css:watch": "node scripts/build-css.mjs --watch", + "build": "backstage-cli package build", "clean": "backstage-cli package clean", "lint": "backstage-cli package lint", "prepack": "backstage-cli package prepack", @@ -50,12 +56,9 @@ "@backstage/cli": "workspace:^", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "chalk": "^5.4.1", "eslint-plugin-storybook": "^10.3.0-alpha.1", "glob": "^11.0.1", "globals": "^15.11.0", - "lightningcss": "^1.29.1", - "mini-css-extract-plugin": "^2.9.2", "react": "^18.0.2", "react-dom": "^18.0.2", "react-router-dom": "^6.30.2", diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 2917ef98d4..f6138c3c3d 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1971,7 +1971,9 @@ export const TabsDefinition: { export interface TabsProps extends TabsProps_2 {} // @public -export const Tag: (props: TagProps) => JSX_2.Element; +export const Tag: ForwardRefExoticComponent< + TagProps & RefAttributes +>; // @public export const TagGroup: ( diff --git a/packages/ui/scripts/build-css.mjs b/packages/ui/scripts/build-css.mjs deleted file mode 100644 index ed4a4ed457..0000000000 --- a/packages/ui/scripts/build-css.mjs +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2024 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. - */ - -/* eslint-disable no-restricted-imports */ -import { transform, bundle } from 'lightningcss'; -import fs from 'node:fs'; -import chalk from 'chalk'; -/* eslint-enable no-restricted-imports */ - -const srcFile = 'src/css/styles.css'; -const distDir = 'css'; -const distFile = `${distDir}/styles.css`; - -// Check if styles.css exists -if (!fs.existsSync(srcFile)) { - console.error(`${srcFile} does not exist`); - process.exit(1); -} - -// Ensure the dist/css directory exists -if (!fs.existsSync(distDir)) { - fs.mkdirSync(distDir, { recursive: true }); -} - -const args = process.argv.slice(2); -const watchMode = args.includes('--watch'); - -async function buildCSS(logs = true) { - const css = fs.readFileSync(srcFile); - - let { code: bundleCode } = bundle({ - filename: srcFile, - }); - - const { code } = transform({ - filename: distFile, - code: bundleCode, - minify: false, - }); - - // Prepend the layer order declaration that lightningcss removes during bundling - // This is crucial to maintain the correct layer cascade order - const layerDeclaration = '@layer tokens, base, components, utilities;\n\n'; - const finalCode = layerDeclaration + code; - - fs.writeFileSync(distFile, finalCode); - if (logs) { - console.log(chalk.blue('CSS transformed and minified: ') + 'styles.css'); - console.log(chalk.green('CSS file built successfully!')); - } -} - -if (watchMode) { - fs.watch('src/css', { recursive: true }, (eventType, filename) => { - if (filename === 'styles.css') { - console.log( - chalk.yellow(`Changes detected in ${filename}, rebuilding...`), - ); - buildCSS(false).catch(console.error); - } - }); - buildCSS().catch(console.error); - console.log(chalk.yellow('Watching for CSS changes...')); -} else { - buildCSS().catch(console.error); -} diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index 481487ecac..523e5bd6c3 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -137,13 +137,26 @@ export function Table({ data !== undefined, ); + const manualColumnSizing = columnConfig.some( + col => + col.width != null || + col.minWidth != null || + col.maxWidth != null || + col.defaultWidth != null, + ); + + const wrapResizable = manualColumnSizing + ? (elem: React.ReactNode) => ( + {elem} + ) + : (elem: React.ReactNode) => <>{elem}; + return (
{liveRegionLabel} - - + {wrapResizable( ({ ); }} - - + , + )} {pagination.type === 'page' && ( (props: TagGroupProps) => { * * @public */ -export const Tag = (props: TagProps) => { - const { classNames, cleanedProps } = useStyles(TagGroupDefinition, { - size: 'small', - ...props, - }); - const { children, className, icon, size, href, ...rest } = cleanedProps; - const textValue = typeof children === 'string' ? children : undefined; +export const Tag = forwardRef( + (props: PropsWithRef, ref: Ref) => { + const { classNames, cleanedProps } = useStyles(TagGroupDefinition, { + size: 'small', + ...props, + }); + const { children, className, icon, size, href, ...rest } = cleanedProps; + const textValue = typeof children === 'string' ? children : undefined; - useRoutingRegistrationEffect(href); + useRoutingRegistrationEffect(href); - return ( - - {({ allowsRemoving }) => ( - <> - {icon && ( - - {icon} - - )} - {children as ReactNode} - {allowsRemoving && ( - - - - )} - - )} - - ); -}; + return ( + + {({ allowsRemoving }) => ( + <> + {icon && ( + + {icon} + + )} + {children as ReactNode} + {allowsRemoving && ( + + + + )} + + )} + + ); + }, +); diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 33cb3e6f76..3865a53d16 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -9,10 +9,10 @@ import { ApiFactory } from '@backstage/frontend-plugin-api'; import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; import { JSXElementConstructor } from 'react'; @@ -87,11 +87,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'consumed-apis'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -120,7 +120,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -128,11 +128,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'consuming-components'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -161,7 +161,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -169,11 +169,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'definition'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -202,7 +202,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -210,11 +210,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'has-apis'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -243,7 +243,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -251,11 +251,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'provided-apis'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -284,7 +284,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -292,11 +292,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'providing-components'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -325,7 +325,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -335,12 +335,12 @@ const _default: OverridableFrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; group: string | false | undefined; icon: string | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -396,7 +396,7 @@ const _default: OverridableFrontendPlugin< icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; }>; 'entity-content:api-docs/definition': OverridableExtensionDefinition<{ @@ -405,12 +405,12 @@ const _default: OverridableFrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; group: string | false | undefined; icon: string | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -466,7 +466,7 @@ const _default: OverridableFrontendPlugin< icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; }>; 'nav-item:api-docs': OverridableExtensionDefinition<{ diff --git a/plugins/catalog-backend-module-gitlab/src/__testUtils__/handlers.ts b/plugins/catalog-backend-module-gitlab/src/__testUtils__/handlers.ts index 16adb01c10..e5b9b39ecd 100644 --- a/plugins/catalog-backend-module-gitlab/src/__testUtils__/handlers.ts +++ b/plugins/catalog-backend-module-gitlab/src/__testUtils__/handlers.ts @@ -201,7 +201,13 @@ const httpGroupFindByEncodedPathDynamic = all_groups_response.flatMap(group => [ const httpSearchFilesInGroupDynamic = all_groups_response.map(group => { return rest.get( `${apiBaseUrl}/groups/${encodeURIComponent(group.full_path)}/search`, - (_, res, ctx) => { + (req, res, ctx) => { + const scope = req.url.searchParams.get('scope'); + + if (scope !== 'blobs') { + return res(ctx.status(400), ctx.text('400 Bad Request')); + } + const searchResults = projects_with_catalog_info_yaml .filter(project => project.path_with_namespace?.startsWith(group.full_path), diff --git a/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts b/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts index 60974d2d0f..5e10fbffd6 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/client.test.ts @@ -280,6 +280,67 @@ describe('GitLabClient', () => { }); }); + describe('listFiles', () => { + it('should call group search API with correct scope parameter', async () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader(mock.config_self_managed), + ), + logger: mockServices.logger.mock(), + }); + + const pagedRequestSpy = jest.spyOn(client as any, 'pagedRequest'); + + await client.listFiles({ + group: 'group1', + search: 'filename:catalog-info.yaml', + page: 1, + per_page: 50, + }); + + expect(pagedRequestSpy).toHaveBeenCalledWith( + '/groups/group1/search', + expect.objectContaining({ + group: 'group1', + search: 'filename:catalog-info.yaml', + scope: 'blobs', + page: 1, + per_page: 50, + }), + ); + }); + + it('should return empty items when group is missing', async () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader(mock.config_self_managed), + ), + logger: mockServices.logger.mock(), + }); + + const result = await client.listFiles({ + search: 'filename:catalog-info.yaml', + }); + + expect(result.items).toEqual([]); + }); + + it('should return empty items when search is missing', async () => { + const client = new GitLabClient({ + config: readGitLabIntegrationConfig( + new ConfigReader(mock.config_self_managed), + ), + logger: mockServices.logger.mock(), + }); + + const result = await client.listFiles({ + group: 'my-group', + }); + + expect(result.items).toEqual([]); + }); + }); + describe('get gitlab.com users', () => { it('gets all users under group', async () => { const client = new GitLabClient({ diff --git a/plugins/catalog-backend-module-gitlab/src/lib/client.ts b/plugins/catalog-backend-module-gitlab/src/lib/client.ts index 4f220deb68..c04585142d 100644 --- a/plugins/catalog-backend-module-gitlab/src/lib/client.ts +++ b/plugins/catalog-backend-module-gitlab/src/lib/client.ts @@ -185,7 +185,7 @@ export class GitLabClient { `/groups/${encodeURIComponent(options?.group)}/search`, { ...options, - scope: 'blob', + scope: 'blobs', }, ); } diff --git a/plugins/catalog-graph/report-alpha.api.md b/plugins/catalog-graph/report-alpha.api.md index fce2dc1bec..55d96f485d 100644 --- a/plugins/catalog-graph/report-alpha.api.md +++ b/plugins/catalog-graph/report-alpha.api.md @@ -8,10 +8,10 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ApiFactory } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { JSX as JSX_2 } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -91,7 +91,7 @@ const _default: OverridableFrontendPlugin< curve: 'curveStepBefore' | 'curveMonotoneX' | undefined; title: string | undefined; height: number | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { @@ -106,7 +106,7 @@ const _default: OverridableFrontendPlugin< mergeRelations?: boolean | undefined; relationPairs?: [string, string][] | undefined; unidirectional?: boolean | undefined; - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -137,7 +137,7 @@ const _default: OverridableFrontendPlugin< name: 'relations'; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; diff --git a/plugins/catalog-graph/src/alpha.test.tsx b/plugins/catalog-graph/src/alpha.test.tsx index 277f6bcc28..e0241ec7b0 100644 --- a/plugins/catalog-graph/src/alpha.test.tsx +++ b/plugins/catalog-graph/src/alpha.test.tsx @@ -21,7 +21,6 @@ import { createTestEntityPage, catalogApiMock, } from '@backstage/plugin-catalog-react/testUtils'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import catalogGraphPlugin from './alpha'; import { catalogGraphRouteRef } from './routes'; import { catalogGraphApiRef, DefaultCatalogGraphApi } from './api'; @@ -58,7 +57,7 @@ describe('catalog-graph alpha plugin', () => { '/catalog-graph': catalogGraphRouteRef, }, apis: [ - [catalogApiRef, catalogApiMock({ entities: [entity] })], + catalogApiMock({ entities: [entity] }), [catalogGraphApiRef, new DefaultCatalogGraphApi()], ], }); @@ -95,7 +94,7 @@ describe('catalog-graph alpha plugin', () => { '/catalog-graph': catalogGraphRouteRef, }, apis: [ - [catalogApiRef, catalogApiMock({ entities: [entity] })], + catalogApiMock({ entities: [entity] }), [catalogGraphApiRef, new DefaultCatalogGraphApi()], ], }); @@ -131,7 +130,7 @@ describe('catalog-graph alpha plugin', () => { '/catalog-graph': catalogGraphRouteRef, }, apis: [ - [catalogApiRef, catalogApiMock({ entities: [entity] })], + catalogApiMock({ entities: [entity] }), [catalogGraphApiRef, new DefaultCatalogGraphApi()], ], }); diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx index 4349d7005a..b8822de1c1 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/SelectedKindsFilter.test.tsx @@ -14,19 +14,12 @@ * limitations under the License. */ -import { ApiProvider } from '@backstage/core-app-api'; -import { AlertApi, alertApiRef, errorApiRef } from '@backstage/core-plugin-api'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; -import { - mockApis, - renderWithEffects, - TestApiRegistry, -} from '@backstage/test-utils'; +import { renderWithEffects } from '@backstage/test-utils'; +import { mockApis, TestApiProvider } from '@backstage/frontend-test-utils'; import { waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SelectedKindsFilter } from './SelectedKindsFilter'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; -import { translationApiRef } from '@backstage/core-plugin-api/alpha'; const catalogApi = catalogApiMock.mock({ getEntityFacets: jest.fn().mockResolvedValue({ @@ -40,28 +33,28 @@ const catalogApi = catalogApiMock.mock({ }, }), }); -const apis = TestApiRegistry.from( - [catalogApiRef, catalogApi], - [alertApiRef, {} as AlertApi], - [translationApiRef, mockApis.translation()], - [errorApiRef, { post: jest.fn() }], -); +const apis = [ + mockApis.alert(), + mockApis.translation(), + mockApis.error(), + catalogApi, +] as const; describe('', () => { it('should not explode while loading', async () => { const { baseElement } = await renderWithEffects( - + {}} /> - , + , ); expect(baseElement).toBeInTheDocument(); }); it('should render current value', async () => { await renderWithEffects( - + {}} /> - , + , ); expect(screen.getByText('API')).toBeInTheDocument(); @@ -71,9 +64,9 @@ describe('', () => { it('should select value', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -89,12 +82,12 @@ describe('', () => { it('should return undefined if all values are selected', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByLabelText('Open')); @@ -112,9 +105,9 @@ describe('', () => { it('should return all values when cleared', async () => { const onChange = jest.fn(); await renderWithEffects( - + - , + , ); await userEvent.click(screen.getByRole('combobox')); diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 5d63046df2..081cfce740 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -67,8 +67,8 @@ "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", + "@backstage/filter-predicates": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", - "@backstage/frontend-test-utils": "workspace:^", "@backstage/integration-react": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", @@ -91,6 +91,7 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-scaffolder-common": "workspace:^", "@backstage/test-utils": "workspace:^", @@ -107,12 +108,16 @@ "zod": "^3.25.76" }, "peerDependencies": { + "@backstage/frontend-test-utils": "workspace:^", "@types/react": "^17.0.0 || ^18.0.0", "react": "^17.0.0 || ^18.0.0", "react-dom": "^17.0.0 || ^18.0.0", "react-router-dom": "^6.30.2" }, "peerDependenciesMeta": { + "@backstage/frontend-test-utils": { + "optional": true + }, "@types/react": { "optional": true } diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 3ad5833a9f..74779de6db 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -10,8 +10,8 @@ import { Entity } from '@backstage/catalog-model'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { IconLinkVerticalProps } from '@backstage/core-components'; -import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; import { JSXElementConstructor } from 'react'; import { ReactElement } from 'react'; @@ -132,7 +132,7 @@ export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }, ): ExtensionDefinition; @@ -142,7 +142,7 @@ export function convertLegacyEntityContentExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); path?: string; title?: string; icon?: string | ReactElement; @@ -184,7 +184,7 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ kind: 'entity-card'; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; output: @@ -212,11 +212,11 @@ export const EntityCardBlueprint: ExtensionBlueprint<{ >; inputs: {}; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; dataRefs: { @@ -254,7 +254,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; output: | ExtensionDataRef @@ -299,12 +299,12 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ config: { path: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; group: string | false | undefined; icon: string | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -352,7 +352,7 @@ export type EntityContentGroupDefinitions = Record< export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ kind: 'entity-content-layout'; params: { - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); loader: () => Promise<(props: EntityContentLayoutProps) => JSX_2.Element>; }; output: @@ -378,10 +378,10 @@ export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ inputs: {}; config: { type: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: string | undefined; }; dataRefs: { @@ -427,10 +427,10 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ >; inputs: {}; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -445,7 +445,7 @@ export const EntityContextMenuItemBlueprint: ExtensionBlueprint<{ export type EntityContextMenuItemParams = { useProps: UseProps; icon: JSX_2.Element; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; // @alpha (undocumented) @@ -453,7 +453,7 @@ export const EntityHeaderBlueprint: ExtensionBlueprint<{ kind: 'entity-header'; params: { loader: () => Promise; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; output: | ExtensionDataRef< @@ -479,10 +479,10 @@ export const EntityHeaderBlueprint: ExtensionBlueprint<{ >; inputs: {}; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; }; dataRefs: { filterFunction: ConfigurableExtensionDataRef< @@ -503,7 +503,7 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ kind: 'entity-icon-link'; params: { useProps: () => Omit; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; output: | ExtensionDataRef< @@ -529,10 +529,10 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ config: { label: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; label?: string | undefined; title?: string | undefined; }; @@ -555,48 +555,6 @@ export const EntityIconLinkBlueprint: ExtensionBlueprint<{ }; }>; -// @alpha (undocumented) -export type EntityPredicate = - | EntityPredicateExpression - | EntityPredicatePrimitive - | { - $all: EntityPredicate[]; - } - | { - $any: EntityPredicate[]; - } - | { - $not: EntityPredicate; - }; - -// @alpha (undocumented) -export type EntityPredicateExpression = { - [KPath in string]: EntityPredicateValue; -} & { - [KPath in `$${string}`]: never; -}; - -// @alpha (undocumented) -export type EntityPredicatePrimitive = string | number | boolean; - -// @alpha -export function entityPredicateToFilterFunction( - entityPredicate: EntityPredicate, -): (value: T) => boolean; - -// @alpha (undocumented) -export type EntityPredicateValue = - | EntityPredicatePrimitive - | { - $exists: boolean; - } - | { - $in: EntityPredicatePrimitive[]; - } - | { - $contains: EntityPredicate; - }; - // @alpha (undocumented) export const EntityTableColumnTitle: ({ translationKey, diff --git a/plugins/catalog-react/report-testUtils.api.md b/plugins/catalog-react/report-testUtils.api.md index 0f7b687965..c5c5300761 100644 --- a/plugins/catalog-react/report-testUtils.api.md +++ b/plugins/catalog-react/report-testUtils.api.md @@ -11,10 +11,13 @@ import { Entity } from '@backstage/catalog-model'; import { EntityListContextProps } from '@backstage/plugin-catalog-react'; import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react/jsx-runtime'; +import { MockWithApiFactory } from '@backstage/frontend-test-utils'; import { PropsWithChildren } from 'react'; // @public -export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi; +export function catalogApiMock(options?: { + entities?: Entity[]; +}): MockWithApiFactory; // @public export namespace catalogApiMock { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts index fed6ced70f..7717b6a1f9 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityCardBlueprint.ts @@ -26,8 +26,10 @@ import { entityCardTypes, EntityCardType, } from './extensionData'; -import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; -import { EntityPredicate } from '../predicates'; +import { + FilterPredicate, + createZodV3FilterPredicateSchema, +} from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { Entity } from '@backstage/catalog-model'; @@ -52,7 +54,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ config: { schema: { filter: z => - z.union([z.string(), createEntityPredicateSchema(z)]).optional(), + z.union([z.string(), createZodV3FilterPredicateSchema(z)]).optional(), type: z => z.enum(entityCardTypes).optional(), }, }, @@ -63,7 +65,7 @@ export const EntityCardBlueprint = createExtensionBlueprint({ type, }: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }, { node, config }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index dde2517095..65896c7833 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -28,9 +28,11 @@ import { defaultEntityContentGroups, entityContentIconDataRef, } from './extensionData'; -import { EntityPredicate } from '../predicates/types'; +import { + FilterPredicate, + createZodV3FilterPredicateSchema, +} from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; -import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { Entity } from '@backstage/catalog-model'; import { ReactElement } from 'react'; @@ -63,7 +65,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ path: z => z.string().optional(), title: z => z.string().optional(), filter: z => - z.union([z.string(), createEntityPredicateSchema(z)]).optional(), + z.union([z.string(), createZodV3FilterPredicateSchema(z)]).optional(), group: z => z.literal(false).or(z.string()).optional(), icon: z => z.string().optional(), }, @@ -88,7 +90,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }, { node, config }, ) { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx index c13ea8ad56..203c075e10 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentLayoutBlueprint.tsx @@ -25,9 +25,11 @@ import { EntityCardType, } from './extensionData'; import { JSX } from 'react'; -import { EntityPredicate } from '../predicates/types'; +import { + FilterPredicate, + createZodV3FilterPredicateSchema, +} from '@backstage/filter-predicates'; import { resolveEntityFilterData } from './resolveEntityFilterData'; -import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { Entity } from '@backstage/catalog-model'; /** @alpha */ @@ -62,7 +64,7 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({ schema: { type: z => z.string().optional(), filter: z => - z.union([z.string(), createEntityPredicateSchema(z)]).optional(), + z.union([z.string(), createZodV3FilterPredicateSchema(z)]).optional(), }, }, *factory( @@ -70,7 +72,7 @@ export const EntityContentLayoutBlueprint = createExtensionBlueprint({ loader, filter, }: { - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); loader: () => Promise<(props: EntityContentLayoutProps) => JSX.Element>; }, { node, config }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx index 4b9b166196..19e46e0234 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContextMenuItemBlueprint.tsx @@ -24,11 +24,13 @@ import MenuItem from '@material-ui/core/MenuItem'; import ListItemIcon from '@material-ui/core/ListItemIcon'; import ListItemText from '@material-ui/core/ListItemText'; import { useEntityContextMenu } from '../../hooks/useEntityContextMenu'; -import { EntityPredicate } from '../predicates/types'; -import { entityPredicateToFilterFunction } from '../predicates/entityPredicateToFilterFunction'; +import { + FilterPredicate, + filterPredicateToFilterFunction, + createZodV3FilterPredicateSchema, +} from '@backstage/filter-predicates'; import type { Entity } from '@backstage/catalog-model'; import { entityFilterFunctionDataRef } from './extensionData'; -import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; /** @alpha */ export type UseProps = () => | { @@ -46,7 +48,7 @@ export type UseProps = () => export type EntityContextMenuItemParams = { useProps: UseProps; icon: JSX.Element; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; /** @alpha */ @@ -62,7 +64,7 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ }, config: { schema: { - filter: z => createEntityPredicateSchema(z).optional(), + filter: z => createZodV3FilterPredicateSchema(z).optional(), }, }, *factory(params: EntityContextMenuItemParams, { node, config }) { @@ -98,13 +100,13 @@ export const EntityContextMenuItemBlueprint = createExtensionBlueprint({ if (config.filter) { yield entityFilterFunctionDataRef( - entityPredicateToFilterFunction(config.filter), + filterPredicateToFilterFunction(config.filter), ); } else if (typeof params.filter === 'function') { yield entityFilterFunctionDataRef(params.filter); } else if (params.filter) { yield entityFilterFunctionDataRef( - entityPredicateToFilterFunction(params.filter), + filterPredicateToFilterFunction(params.filter), ); } }, diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx index b83136dcd9..401ffea316 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityHeaderBlueprint.tsx @@ -19,10 +19,12 @@ import { coreExtensionData, ExtensionBoundary, } from '@backstage/frontend-plugin-api'; -import { EntityPredicate } from '../predicates/types'; +import { + FilterPredicate, + createZodV3FilterPredicateSchema, +} from '@backstage/filter-predicates'; import { Entity } from '@backstage/catalog-model'; import { resolveEntityFilterData } from './resolveEntityFilterData'; -import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { entityFilterExpressionDataRef, entityFilterFunctionDataRef, @@ -38,7 +40,7 @@ export const EntityHeaderBlueprint = createExtensionBlueprint({ }, config: { schema: { - filter: z => createEntityPredicateSchema(z).optional(), + filter: z => createZodV3FilterPredicateSchema(z).optional(), }, }, output: [ @@ -49,7 +51,7 @@ export const EntityHeaderBlueprint = createExtensionBlueprint({ *factory( params: { loader: () => Promise; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }, { node, config }, ) { diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx index 2368945c65..c71aea1e18 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx @@ -20,8 +20,10 @@ import { createExtensionDataRef, } from '@backstage/frontend-plugin-api'; -import { EntityPredicate } from '../predicates/types'; -import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; +import { + FilterPredicate, + createZodV3FilterPredicateSchema, +} from '@backstage/filter-predicates'; import { entityFilterExpressionDataRef, @@ -54,13 +56,13 @@ export const EntityIconLinkBlueprint = createExtensionBlueprint({ schema: { label: z => z.string().optional(), title: z => z.string().optional(), - filter: z => createEntityPredicateSchema(z).optional(), + filter: z => createZodV3FilterPredicateSchema(z).optional(), }, }, *factory( params: { useProps: () => Omit; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }, { config, node }, ) { diff --git a/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts b/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts index 75b7784c18..50ca39f99d 100644 --- a/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts +++ b/plugins/catalog-react/src/alpha/blueprints/resolveEntityFilterData.ts @@ -19,15 +19,15 @@ import { entityFilterFunctionDataRef, } from './extensionData'; import { - EntityPredicate, - entityPredicateToFilterFunction, -} from '../predicates'; + FilterPredicate, + filterPredicateToFilterFunction, +} from '@backstage/filter-predicates'; import { Entity } from '@backstage/catalog-model'; import { AppNode } from '@backstage/frontend-plugin-api'; export function* resolveEntityFilterData( - filter: ((entity: Entity) => boolean) | EntityPredicate | string | undefined, - config: { filter?: EntityPredicate | string }, + filter: ((entity: Entity) => boolean) | FilterPredicate | string | undefined, + config: { filter?: FilterPredicate | string }, node: AppNode, ) { if (typeof config.filter === 'string') { @@ -38,7 +38,7 @@ export function* resolveEntityFilterData( yield entityFilterExpressionDataRef(config.filter); } else if (config.filter) { yield entityFilterFunctionDataRef( - entityPredicateToFilterFunction(config.filter), + filterPredicateToFilterFunction(config.filter), ); } else if (typeof filter === 'function') { yield entityFilterFunctionDataRef(filter); @@ -49,6 +49,6 @@ export function* resolveEntityFilterData( ); yield entityFilterExpressionDataRef(filter); } else if (filter) { - yield entityFilterFunctionDataRef(entityPredicateToFilterFunction(filter)); + yield entityFilterFunctionDataRef(filterPredicateToFilterFunction(filter)); } } diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx index 9b343db469..5e6e91e251 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityCardExtension.tsx @@ -20,7 +20,7 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { EntityCardBlueprint } from '../blueprints/EntityCardBlueprint'; import kebabCase from 'lodash/kebabCase'; -import { EntityPredicate } from '../predicates/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '../blueprints/extensionData'; @@ -29,7 +29,7 @@ export function convertLegacyEntityCardExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }, ): ExtensionDefinition { diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx index e929845a2c..6113bdeca2 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx @@ -28,7 +28,7 @@ import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; import { ComponentType, ReactElement } from 'react'; import { EntityContentBlueprint } from '../blueprints/EntityContentBlueprint'; -import { EntityPredicate } from '../predicates/types'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { Entity } from '@backstage/catalog-model'; /** @alpha */ @@ -36,7 +36,7 @@ export function convertLegacyEntityContentExtension( LegacyExtension: ComponentType<{}>, overrides?: { name?: string; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); path?: string; title?: string; icon?: string | ReactElement; diff --git a/plugins/catalog-react/src/alpha/index.ts b/plugins/catalog-react/src/alpha/index.ts index f5909c9aa2..6d2d76fd9c 100644 --- a/plugins/catalog-react/src/alpha/index.ts +++ b/plugins/catalog-react/src/alpha/index.ts @@ -16,7 +16,6 @@ export * from './blueprints'; export * from './converters'; -export * from './predicates'; export { catalogReactTranslationRef } from '../translation'; export { isOwnerOf } from '../utils/isOwnerOf'; export { useEntityPermission } from '../hooks/useEntityPermission'; diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index b2de7522b6..9fa8e6e097 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -19,6 +19,7 @@ import { Entity } from '@backstage/catalog-model'; import { ApiProvider } from '@backstage/core-app-api'; import { alertApiRef } from '@backstage/core-plugin-api'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { fireEvent, waitFor, screen, within } from '@testing-library/react'; import { capitalize } from 'lodash'; import { catalogApiRef } from '../../api'; @@ -65,12 +66,7 @@ describe('', () => { } as GetEntityFacetsResponse), }, ], - [ - alertApiRef, - { - post: jest.fn(), - }, - ], + [alertApiRef, mockApis.alert()], ); it('renders available entity kinds', async () => { diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx index 281b94251e..58e731f197 100644 --- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx @@ -23,6 +23,7 @@ import { EntityKindFilter, EntityTypeFilter } from '../../filters'; import { alertApiRef } from '@backstage/core-plugin-api'; import { ApiProvider } from '@backstage/core-app-api'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { GetEntityFacetsResponse } from '@backstage/catalog-client'; const entities: Entity[] = [ @@ -72,12 +73,7 @@ const apis = TestApiRegistry.from( } as GetEntityFacetsResponse), }, ], - [ - alertApiRef, - { - post: jest.fn(), - }, - ], + [alertApiRef, mockApis.alert()], ); describe('', () => { diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx index 595cd70fc4..eea5f95e89 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/UnregisterEntityDialog.test.tsx @@ -25,22 +25,12 @@ import { catalogApiRef } from '../../api'; import { entityRouteRef } from '../../routes'; import { screen, waitFor } from '@testing-library/react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import * as state from './useUnregisterEntityDialogState'; -import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; describe('UnregisterEntityDialog', () => { - const alertApi: AlertApi = { - post() { - return undefined; - }, - alert$() { - throw new Error('not implemented'); - }, - }; - - beforeEach(() => { - jest.spyOn(alertApi, 'post').mockImplementation(() => {}); - }); + const alertApi = mockApis.alert(); const entity = { apiVersion: 'backstage.io/v1alpha1', @@ -340,11 +330,13 @@ describe('UnregisterEntityDialog', () => { await waitFor(() => { expect(deleteEntity).toHaveBeenCalled(); expect(onConfirm).toHaveBeenCalled(); - expect(alertApi.post).toHaveBeenCalledWith({ - message: 'Removed entity n', - severity: 'success', - display: 'transient', - }); + expect(alertApi.getAlerts()).toContainEqual( + expect.objectContaining({ + message: 'Removed entity n', + severity: 'success', + display: 'transient', + }), + ); }); }); }); diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index 711fb09f8c..a5fda1bf5b 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -25,7 +25,8 @@ import { } from '@backstage/core-plugin-api'; import { translationApiRef } from '@backstage/core-plugin-api/alpha'; import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; -import { mockApis, TestApiProvider } from '@backstage/test-utils'; +import { TestApiProvider } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { useMountEffect } from '@react-hookz/web'; import { act, renderHook, waitFor } from '@testing-library/react'; import qs from 'qs'; @@ -111,7 +112,7 @@ const createWrapper = [identityApiRef, mockIdentityApi], [storageApiRef, mockApis.storage()], [starredEntitiesApiRef, new MockStarredEntitiesApi()], - [alertApiRef, { post: jest.fn() }], + [alertApiRef, mockApis.alert()], [translationApiRef, mockApis.translation()], [errorApiRef, { error$: jest.fn(), post: jest.fn() }], ]} diff --git a/plugins/catalog-react/src/testUtils/catalogApiMock.ts b/plugins/catalog-react/src/testUtils/catalogApiMock.ts index a37d2669ad..007345de22 100644 --- a/plugins/catalog-react/src/testUtils/catalogApiMock.ts +++ b/plugins/catalog-react/src/testUtils/catalogApiMock.ts @@ -14,42 +14,16 @@ * limitations under the License. */ -import { - ApiFactory, - ApiRef, - createApiFactory, -} from '@backstage/frontend-plugin-api'; +import { ApiFactory, createApiFactory } from '@backstage/frontend-plugin-api'; import { InMemoryCatalogClient } from '@backstage/catalog-client/testUtils'; import { Entity } from '@backstage/catalog-model'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { CatalogApi } from '@backstage/catalog-client'; -import { ApiMock } from '@backstage/frontend-test-utils'; - -/** @internal */ -function simpleMock( - ref: ApiRef, - mockFactory: () => jest.Mocked, -): (partialImpl?: Partial) => ApiMock { - return partialImpl => { - const mock = mockFactory(); - if (partialImpl) { - for (const [key, impl] of Object.entries(partialImpl)) { - if (typeof impl === 'function') { - (mock as any)[key].mockImplementation(impl); - } else { - (mock as any)[key] = impl; - } - } - } - return Object.assign(mock, { - factory: createApiFactory({ - api: ref, - deps: {}, - factory: () => mock, - }), - }) as ApiMock; - }; -} +import { + createApiMock, + attachMockApiFactory, + type MockWithApiFactory, +} from '@backstage/frontend-test-utils'; /** * Creates a fake catalog client that handles entities in memory storage. Note @@ -58,8 +32,11 @@ function simpleMock( * * @public */ -export function catalogApiMock(options?: { entities?: Entity[] }): CatalogApi { - return new InMemoryCatalogClient(options); +export function catalogApiMock(options?: { + entities?: Entity[]; +}): MockWithApiFactory { + const instance = new InMemoryCatalogClient(options); + return attachMockApiFactory(catalogApiRef, instance); } /** @@ -85,7 +62,7 @@ export namespace catalogApiMock { * Creates a catalog client whose methods are mock functions, possibly with * some of them overloaded by the caller. */ - export const mock = simpleMock(catalogApiRef, () => ({ + export const mock = createApiMock(catalogApiRef, () => ({ getEntities: jest.fn(), getEntitiesByRefs: jest.fn(), queryEntities: jest.fn(), diff --git a/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx b/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx index 2b15ae5fc4..4fa8465797 100644 --- a/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx +++ b/plugins/catalog-react/src/testUtils/createTestEntityPage.test.tsx @@ -23,7 +23,6 @@ import { EntityCardBlueprint, EntityContentBlueprint, } from '../alpha/blueprints'; -import { catalogApiRef } from '../api'; const mockEntity: Entity = { apiVersion: 'backstage.io/v1alpha1', @@ -386,7 +385,7 @@ describe('createTestEntityPage', () => { createTestEntityPage({ entity: mockEntity }), catalogApiCard, ], - apis: [[catalogApiRef, catalogApiMock({ entities: customEntities })]], + apis: [catalogApiMock({ entities: customEntities })], }); // Should use the user-provided catalog API with custom entities, not mockEntity diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 4ad9f94206..5815313e17 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -12,11 +12,11 @@ import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; import { EntityContentLayoutProps } from '@backstage/plugin-catalog-react/alpha'; import { EntityContextMenuItemParams } from '@backstage/plugin-catalog-react/alpha'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; @@ -43,11 +43,16 @@ export const catalogTranslationRef: TranslationRef< readonly 'indexPage.title': '{{orgName}} Catalog'; readonly 'indexPage.createButtonTitle': 'Create'; readonly 'indexPage.supportButtonContent': 'All your software catalog entities'; + readonly 'entityPage.notFoundMessage': 'There is no {{kind}} with the requested {{link}}.'; + readonly 'entityPage.notFoundLinkText': 'kind, namespace, and name'; readonly 'aboutCard.title': 'About'; + readonly 'aboutCard.unknown': 'unknown'; readonly 'aboutCard.refreshButtonTitle': 'Schedule entity refresh'; readonly 'aboutCard.editButtonTitle': 'Edit Metadata'; + readonly 'aboutCard.editButtonAriaLabel': 'Edit'; readonly 'aboutCard.createSimilarButtonTitle': 'Create something similar'; readonly 'aboutCard.refreshScheduledMessage': 'Refresh scheduled'; + readonly 'aboutCard.refreshButtonAriaLabel': 'Refresh'; readonly 'aboutCard.launchTemplate': 'Launch Template'; readonly 'aboutCard.viewTechdocs': 'View TechDocs'; readonly 'aboutCard.viewSource': 'View Source'; @@ -66,8 +71,11 @@ export const catalogTranslationRef: TranslationRef< readonly 'aboutCard.tagsField.value': 'No Tags'; readonly 'aboutCard.tagsField.label': 'Tags'; readonly 'aboutCard.targetsField.label': 'Targets'; + readonly 'searchResultItem.type': 'Type'; + readonly 'searchResultItem.kind': 'Kind'; readonly 'searchResultItem.lifecycle': 'Lifecycle'; - readonly 'searchResultItem.Owner': 'Owner'; + readonly 'searchResultItem.owner': 'Owner'; + readonly 'catalogTable.allFilters': 'All'; readonly 'catalogTable.warningPanelTitle': 'Could not fetch catalog entities.'; readonly 'catalogTable.viewActionTitle': 'View'; readonly 'catalogTable.editActionTitle': 'Edit'; @@ -84,6 +92,7 @@ export const catalogTranslationRef: TranslationRef< readonly 'entityContextMenu.inspectMenuTitle': 'Inspect entity'; readonly 'entityContextMenu.copyURLMenuTitle': 'Copy entity URL'; readonly 'entityContextMenu.unregisterMenuTitle': 'Unregister entity'; + readonly 'entityContextMenu.moreButtonAriaLabel': 'more'; readonly 'entityLabelsCard.title': 'Labels'; readonly 'entityLabelsCard.emptyDescription': 'No labels defined for this entity. You can add labels to your entity YAML as shown in the highlighted example below:'; readonly 'entityLabelsCard.readMoreButtonTitle': 'Read more'; @@ -96,6 +105,7 @@ export const catalogTranslationRef: TranslationRef< readonly 'entityNotFound.title': 'Entity was not found'; readonly 'entityNotFound.description': 'Want to help us build this? Check out our Getting Started documentation.'; readonly 'entityNotFound.docButtonTitle': 'DOCS'; + readonly 'entityTabs.tabsAriaLabel': 'Tabs'; readonly entityProcessingErrorsDescription: 'The error below originates from'; readonly entityRelationWarningDescription: "This entity has relations to other entities, which can't be found in the catalog.\n Entities not found are: "; readonly 'hasComponentsCard.title': 'Has components'; @@ -294,11 +304,11 @@ const _default: OverridableFrontendPlugin< }>; 'entity-card:catalog/about': OverridableExtensionDefinition<{ config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -356,7 +366,7 @@ const _default: OverridableFrontendPlugin< name: 'about'; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -364,11 +374,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'depends-on-components'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -397,7 +407,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -405,11 +415,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'depends-on-resources'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -438,7 +448,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -446,11 +456,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'has-components'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -479,7 +489,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -487,11 +497,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'has-resources'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -520,7 +530,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -528,11 +538,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'has-subcomponents'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -561,7 +571,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -569,11 +579,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'has-subdomains'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -602,7 +612,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -610,11 +620,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'has-systems'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -643,7 +653,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -651,11 +661,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'labels'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -684,7 +694,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -692,11 +702,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'links'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -725,7 +735,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -733,12 +743,12 @@ const _default: OverridableFrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; group: string | false | undefined; icon: string | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -852,17 +862,17 @@ const _default: OverridableFrontendPlugin< icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; }>; 'entity-context-menu-item:catalog/copy-entity-url': OverridableExtensionDefinition<{ kind: 'entity-context-menu-item'; name: 'copy-entity-url'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; }; output: | ExtensionDataRef @@ -880,10 +890,10 @@ const _default: OverridableFrontendPlugin< kind: 'entity-context-menu-item'; name: 'inspect-entity'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; }; output: | ExtensionDataRef @@ -901,10 +911,10 @@ const _default: OverridableFrontendPlugin< kind: 'entity-context-menu-item'; name: 'unregister-entity'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; }; output: | ExtensionDataRef @@ -924,10 +934,10 @@ const _default: OverridableFrontendPlugin< config: { label: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; label?: string | undefined; title?: string | undefined; }; @@ -954,7 +964,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { useProps: () => Omit; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; }>; 'nav-item:catalog': OverridableExtensionDefinition<{ diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index dbe25e9401..ee6e2fc012 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -180,11 +180,14 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent ) : ( - There is no {kind} with the requested{' '} - - kind, namespace, and name - - . + {t('entityPage.notFoundMessage', { + kind, + link: ( + + {t('entityPage.notFoundLinkText')} + + ), + })} )} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index dae400ce5d..d77cc5e444 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -18,9 +18,12 @@ import { ReactElement, useMemo } from 'react'; import Box from '@material-ui/core/Box'; import Tabs from '@material-ui/core/Tabs'; import { makeStyles } from '@material-ui/core/styles'; -import { EntityTabsGroup } from './EntityTabsGroup'; +import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha'; +import { EntityTabsGroup } from './EntityTabsGroup'; +import { catalogTranslationRef } from '../../translation'; + /** @public */ export type HeaderTabsClassKey = | 'tabsWrapper' @@ -81,6 +84,7 @@ type EntityTabsListProps = { export function EntityTabsList(props: EntityTabsListProps) { const styles = useStyles(); + const { t } = useTranslationRef(catalogTranslationRef); const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props; @@ -108,7 +112,7 @@ export function EntityTabsList(props: EntityTabsListProps) { textColor="inherit" variant="scrollable" scrollButtons="auto" - aria-label="tabs" + aria-label={t('entityTabs.tabsAriaLabel')} value={selectedItem?.group ?? selectedItem?.id} > {Object.entries(groups).map(([id, tabGroup]) => ( diff --git a/plugins/catalog/src/alpha/translation.ts b/plugins/catalog/src/alpha/translation.ts index 498104db45..f346c408b9 100644 --- a/plugins/catalog/src/alpha/translation.ts +++ b/plugins/catalog/src/alpha/translation.ts @@ -25,15 +25,22 @@ export const catalogTranslationRef = createTranslationRef({ createButtonTitle: 'Create', supportButtonContent: 'All your software catalog entities', }, + entityPage: { + notFoundMessage: 'There is no {{kind}} with the requested {{link}}.', + notFoundLinkText: 'kind, namespace, and name', + }, aboutCard: { title: 'About', refreshButtonTitle: 'Schedule entity refresh', editButtonTitle: 'Edit Metadata', + editButtonAriaLabel: 'Edit', createSimilarButtonTitle: 'Create something similar', refreshScheduledMessage: 'Refresh scheduled', + refreshButtonAriaLabel: 'Refresh', launchTemplate: 'Launch Template', viewTechdocs: 'View TechDocs', viewSource: 'View Source', + unknown: 'unknown', descriptionField: { label: 'Description', value: 'No description', @@ -69,8 +76,10 @@ export const catalogTranslationRef = createTranslationRef({ }, }, searchResultItem: { + kind: 'Kind', + type: 'Type', lifecycle: 'Lifecycle', - Owner: 'Owner', + owner: 'Owner', }, catalogTable: { warningPanelTitle: 'Could not fetch catalog entities.', @@ -78,6 +87,7 @@ export const catalogTranslationRef = createTranslationRef({ editActionTitle: 'Edit', starActionTitle: 'Add to favorites', unStarActionTitle: 'Remove from favorites', + allFilters: 'All', }, dependencyOfComponentsCard: { title: 'Dependency of components', @@ -97,6 +107,7 @@ export const catalogTranslationRef = createTranslationRef({ inspectMenuTitle: 'Inspect entity', copyURLMenuTitle: 'Copy entity URL', unregisterMenuTitle: 'Unregister entity', + moreButtonAriaLabel: 'more', }, entityLabelsCard: { title: 'Labels', @@ -121,6 +132,9 @@ export const catalogTranslationRef = createTranslationRef({ 'Want to help us build this? Check out our Getting Started documentation.', docButtonTitle: 'DOCS', }, + entityTabs: { + tabsAriaLabel: 'Tabs', + }, deleteEntity: { dialogTitle: 'Are you sure you want to delete this entity?', deleteButtonTitle: 'Delete', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index fd1c05ba5f..8e90659520 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -241,7 +241,7 @@ export function InternalAboutCard(props: InternalAboutCardProps) { <> {allowRefresh && canRefresh && ( @@ -250,7 +250,7 @@ export function InternalAboutCard(props: InternalAboutCardProps) { )} ({ value: { @@ -55,6 +57,7 @@ export interface AboutFieldProps { export function AboutField(props: AboutFieldProps) { const { label, value, gridSizes, children, className } = props; const classes = useStyles(); + const { t } = useTranslationRef(catalogTranslationRef); const childElements = useElementFilter(children, c => c.getElements()); @@ -64,7 +67,7 @@ export function AboutField(props: AboutFieldProps) { childElements ) : ( - {value || `unknown`} + {value || t('aboutCard.unknown')} ); return ( diff --git a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx index f33c9cd412..0e604f2d5b 100644 --- a/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx +++ b/plugins/catalog/src/components/CatalogSearchResultListItem/CatalogSearchResultListItem.tsx @@ -127,8 +127,18 @@ export function CatalogSearchResultListItem( } /> - {result.kind && } - {result.type && } + {result.kind && ( + + )} + {result.type && ( + + )} {result.lifecycle && ( )} diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 464688c590..7875431246 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -186,7 +186,9 @@ export const CatalogTable = (props: CatalogTableProps) => { const currentType = filters.type?.value || ''; const currentCount = typeof totalItems === 'number' ? `(${totalItems})` : ''; // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar - const titlePreamble = capitalize(filters.user?.value ?? 'all'); + const titlePreamble = capitalize( + filters.user?.value ?? t('catalogTable.allFilters'), + ); const title = props.title || [titlePreamble, currentType, pluralize(currentKind), currentCount] diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 2d9c392322..23dc9c1efb 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -161,7 +161,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) { <> { const apis = TestApiRegistry.from( [catalogApiRef, catalogApiMock()], - [alertApiRef, {} as AlertApi], + [alertApiRef, mockApis.alert()], [starredEntitiesApiRef, new MockStarredEntitiesApi()], [permissionApiRef, mockApis.permission()], ); diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index 65f904c285..5ee6a7f0f1 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -389,11 +389,14 @@ export const EntityLayout = (props: EntityLayoutProps) => { NotFoundComponent ) : ( - There is no {kind} with the requested{' '} - - kind, namespace, and name - - . + {t('entityPage.notFoundMessage', { + kind, + link: ( + + {t('entityPage.notFoundLinkText')} + + ), + })} )} diff --git a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx index 80d97a2e1e..40085d195d 100644 --- a/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx +++ b/plugins/catalog/src/components/EntityOrphanWarning/DeleteEntityDialog.test.tsx @@ -22,13 +22,11 @@ import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { screen, waitFor } from '@testing-library/react'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; -import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { mockApis } from '@backstage/frontend-test-utils'; describe('DeleteEntityDialog', () => { - const alertApi: jest.Mocked = { - post: jest.fn(), - alert$: jest.fn(), - }; + const alertApi = mockApis.alert(); const catalogClient = catalogApiMock.mock(); @@ -123,7 +121,9 @@ describe('DeleteEntityDialog', () => { await waitFor(() => { expect(catalogClient.removeEntityByUid).toHaveBeenCalledWith('123'); - expect(alertApi.post).toHaveBeenCalledWith({ message: 'no no no' }); + expect(alertApi.getAlerts()).toContainEqual( + expect.objectContaining({ message: 'no no no' }), + ); }); }); }); diff --git a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx index 66affd86f1..cb22475be6 100644 --- a/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx +++ b/plugins/catalog/src/components/EntitySwitch/EntitySwitch.test.tsx @@ -23,15 +23,12 @@ import { render, screen } from '@testing-library/react'; import { ReactNode, useEffect } from 'react'; import { isKind } from './conditions'; import { EntitySwitch } from './EntitySwitch'; -import { featureFlagsApiRef } from '@backstage/core-plugin-api'; -import { LocalStorageFeatureFlags } from '@backstage/core-app-api'; -import { TestApiProvider } from '@backstage/test-utils'; +import { TestApiProvider, mockApis } from '@backstage/frontend-test-utils'; + +const mockFeatureFlagsApi = mockApis.featureFlags.mock(); -const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); const Wrapper = ({ children }: { children?: ReactNode }) => ( - - {children} - + {children} ); describe('EntitySwitch', () => { diff --git a/plugins/devtools-backend/package.json b/plugins/devtools-backend/package.json index 0d95cb7d62..a0c3335e6d 100644 --- a/plugins/devtools-backend/package.json +++ b/plugins/devtools-backend/package.json @@ -39,7 +39,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/cli-common": "workspace:^", "@backstage/config": "workspace:^", diff --git a/plugins/devtools-react/package.json b/plugins/devtools-react/package.json index cff2e4afe2..1a0c857f6c 100644 --- a/plugins/devtools-react/package.json +++ b/plugins/devtools-react/package.json @@ -47,7 +47,7 @@ "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^14.0.0", + "@testing-library/react": "^16.0.0", "@types/react": "^18.0.0", "react": "^18.0.2", "react-dom": "^18.0.2", diff --git a/plugins/home-react/package.json b/plugins/home-react/package.json index 298b690841..a5df160e73 100644 --- a/plugins/home-react/package.json +++ b/plugins/home-react/package.json @@ -55,6 +55,7 @@ "test": "backstage-cli package test" }, "dependencies": { + "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", diff --git a/plugins/home-react/report-alpha.api.md b/plugins/home-react/report-alpha.api.md index fad4c5a2bc..0de77172a0 100644 --- a/plugins/home-react/report-alpha.api.md +++ b/plugins/home-react/report-alpha.api.md @@ -3,9 +3,129 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AppNode } from '@backstage/frontend-plugin-api'; +import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; +import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { JSX as JSX_2 } from 'react'; +import { ReactElement } from 'react'; +import { RJSFSchema } from '@rjsf/utils'; import { TranslationRef } from '@backstage/frontend-plugin-api'; +import { UiSchema } from '@rjsf/utils'; -// @alpha (undocumented) +// @public (undocumented) +export type CardLayout = { + width?: { + minColumns?: number; + maxColumns?: number; + defaultColumns?: number; + }; + height?: { + minRows?: number; + maxRows?: number; + defaultRows?: number; + }; +}; + +// @public (undocumented) +export type CardSettings = { + schema?: RJSFSchema; + uiSchema?: UiSchema; +}; + +// @public (undocumented) +export type ComponentParts = { + Content: (props?: any) => JSX.Element; + Actions?: () => JSX.Element; + Settings?: () => JSX.Element; + ContextProvider?: (props: any) => JSX.Element; +}; + +// @alpha +export const HomePageLayoutBlueprint: ExtensionBlueprint<{ + kind: 'home-page-layout'; + params: HomePageLayoutBlueprintParams; + output: ExtensionDataRef< + (props: HomePageLayoutProps) => JSX_2.Element, + 'home.layout.component', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + component: ConfigurableExtensionDataRef< + (props: HomePageLayoutProps) => JSX_2.Element, + 'home.layout.component', + {} + >; + }; +}>; + +// @alpha +export interface HomePageLayoutBlueprintParams { + loader: () => Promise<(props: HomePageLayoutProps) => JSX_2.Element>; +} + +// @alpha +export const homePageLayoutComponentDataRef: ConfigurableExtensionDataRef< + (props: HomePageLayoutProps) => JSX_2.Element, + 'home.layout.component', + {} +>; + +// @alpha +export interface HomePageLayoutProps { + widgets: Array; +} + +// @alpha +export const HomePageWidgetBlueprint: ExtensionBlueprint<{ + kind: 'home-page-widget'; + params: HomePageWidgetBlueprintParams; + output: ExtensionDataRef; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + widget: ConfigurableExtensionDataRef< + HomePageWidgetData, + 'home.widget.data', + {} + >; + }; +}>; + +// @alpha +export interface HomePageWidgetBlueprintParams { + componentProps?: Record; + components: () => Promise; + description?: string; + layout?: CardLayout; + name?: string; + settings?: CardSettings; + title?: string; +} + +// @alpha +export interface HomePageWidgetData { + component: ReactElement; + description?: string; + layout?: CardLayout; + name?: string; + node: AppNode; + settings?: CardSettings; + title?: string; +} + +// @alpha +export const homePageWidgetDataRef: ConfigurableExtensionDataRef< + HomePageWidgetData, + 'home.widget.data', + {} +>; + +// @alpha export const homeReactTranslationRef: TranslationRef< 'home-react', { @@ -14,6 +134,4 @@ export const homeReactTranslationRef: TranslationRef< readonly 'cardExtension.settingsButtonTitle': 'Settings'; } >; - -// (No @packageDocumentation comment for this package) ``` diff --git a/plugins/home-react/src/alpha.ts b/plugins/home-react/src/alpha.ts index 3a08699f66..00b7a7bed7 100644 --- a/plugins/home-react/src/alpha.ts +++ b/plugins/home-react/src/alpha.ts @@ -13,4 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +/** + * React components and utilities for the home plugin's new frontend system. + * + * @remarks + * This package provides React components, blueprints, and utilities for building + * customizable home pages with the new Backstage frontend system. + * + * @packageDocumentation + */ export { homeReactTranslationRef } from './translation'; +export { + HomePageWidgetBlueprint, + type HomePageWidgetBlueprintParams, +} from './alpha/blueprints/HomePageWidgetBlueprint'; +export { + HomePageLayoutBlueprint, + type HomePageLayoutBlueprintParams, +} from './alpha/blueprints/HomePageLayoutBlueprint'; +export { + homePageWidgetDataRef, + type HomePageWidgetData, + homePageLayoutComponentDataRef, + type HomePageLayoutProps, +} from './alpha/dataRefs'; +export type { ComponentParts, CardLayout, CardSettings } from './extensions'; diff --git a/plugins/home-react/src/alpha/blueprints/HomePageLayoutBlueprint.tsx b/plugins/home-react/src/alpha/blueprints/HomePageLayoutBlueprint.tsx new file mode 100644 index 0000000000..9610b79993 --- /dev/null +++ b/plugins/home-react/src/alpha/blueprints/HomePageLayoutBlueprint.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2025 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 { JSX } from 'react'; +import { + createExtensionBlueprint, + ExtensionBoundary, +} from '@backstage/frontend-plugin-api'; +import { + homePageLayoutComponentDataRef, + HomePageLayoutProps, +} from '../dataRefs'; + +/** + * Parameters for creating a home page layout extension. + * + * @alpha + */ +export interface HomePageLayoutBlueprintParams { + /** + * Async loader that returns the layout component. + * The component receives the collected widgets and renders them. + */ + loader: () => Promise<(props: HomePageLayoutProps) => JSX.Element>; +} + +/** + * Blueprint for creating custom home page layouts. + * + * A layout receives the list of installed widgets and is responsible for + * arranging them on the home page. This follows the same pattern as + * `EntityContentLayoutBlueprint` in the catalog plugin. + * + * If no layout extension is installed, the home page uses a built-in default. + * Users can install their own layout to customize how widgets are arranged. + * + * @alpha + */ +export const HomePageLayoutBlueprint = createExtensionBlueprint({ + kind: 'home-page-layout', + attachTo: { id: 'page:home', input: 'layout' }, + output: [homePageLayoutComponentDataRef], + dataRefs: { + component: homePageLayoutComponentDataRef, + }, + *factory({ loader }: HomePageLayoutBlueprintParams, { node }) { + yield homePageLayoutComponentDataRef( + ExtensionBoundary.lazyComponent(node, loader), + ); + }, +}); diff --git a/plugins/home-react/src/alpha/blueprints/HomePageWidgetBlueprint.tsx b/plugins/home-react/src/alpha/blueprints/HomePageWidgetBlueprint.tsx new file mode 100644 index 0000000000..4cd38754fa --- /dev/null +++ b/plugins/home-react/src/alpha/blueprints/HomePageWidgetBlueprint.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2025 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 { lazy, ReactElement } from 'react'; +import { + createExtensionBlueprint, + ExtensionBoundary, +} from '@backstage/frontend-plugin-api'; +import { attachComponentData } from '@backstage/core-plugin-api'; +import { + CardExtension, + CardExtensionProps, + CardLayout, + CardSettings, + ComponentParts, +} from '../../extensions'; +import { homePageWidgetDataRef } from '../dataRefs'; + +/** + * Parameters for creating a home page widget extension. + * + * @alpha + */ +export interface HomePageWidgetBlueprintParams { + /** + * Optional name for the widget. If not provided, the extension will use only its kind + * in the extension ID. + */ + name?: string; + /** + * Optional title displayed for the widget, used as the default card heading. + */ + title?: string; + /** + * Optional description shown in the widget catalog when adding new cards. + */ + description?: string; + /** + * Component parts rendered within the card. + */ + components: () => Promise; + /** + * Layout hints used by the customizable grid. + */ + layout?: CardLayout; + /** + * Schema used to configure widget settings. + */ + settings?: CardSettings; + /** + * Default props forwarded to the rendered widget component. + */ + componentProps?: Record; +} + +const DEFAULT_WIDGET_ATTACH_POINT = { + id: 'page:home', + input: 'widgets', +} as const; + +/** + * Creates widgets that can be installed into the home page grid. + * + * @alpha + */ +export const HomePageWidgetBlueprint = createExtensionBlueprint({ + kind: 'home-page-widget', + attachTo: DEFAULT_WIDGET_ATTACH_POINT, + dataRefs: { + widget: homePageWidgetDataRef, + }, + output: [homePageWidgetDataRef], + *factory(params: HomePageWidgetBlueprintParams, { node }) { + const isCustomizable = params.settings?.schema !== undefined; + const widgetName = params.name ?? node.spec.id; + const LazyCard = lazy(() => + params.components().then(parts => ({ + default: (props: CardExtensionProps>) => ( + + ), + })), + ); + + const Widget = ( + props: CardExtensionProps>, + ): ReactElement => ( + + + + ); + + attachComponentData(Widget, 'core.extensionName', widgetName); + attachComponentData(Widget, 'title', params.title); + attachComponentData(Widget, 'description', params.description); + attachComponentData(Widget, 'home.widget.config', { + layout: params.layout, + settings: params.settings, + }); + + yield homePageWidgetDataRef({ + node, + component: , + name: widgetName, + title: params.title, + description: params.description, + layout: params.layout, + settings: params.settings, + }); + }, +}); diff --git a/plugins/home-react/src/alpha/dataRefs.ts b/plugins/home-react/src/alpha/dataRefs.ts new file mode 100644 index 0000000000..b626289541 --- /dev/null +++ b/plugins/home-react/src/alpha/dataRefs.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2025 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 { + createExtensionDataRef, + type AppNode, +} from '@backstage/frontend-plugin-api'; +import { JSX, ReactElement } from 'react'; +import type { CardLayout, CardSettings } from '../extensions'; + +/** + * Extension data for homepage widgets, bundling the rendered component + * with its metadata. + * + * @alpha + */ +export interface HomePageWidgetData { + /** + * The originating app node for this widget. + */ + node: AppNode; + /** + * The rendered widget component (typically a card with header, content, etc.) + */ + component: ReactElement; + /** + * Optional name identifier for the widget + */ + name?: string; + /** + * Optional title displayed in the widget header + */ + title?: string; + /** + * Optional description shown in widget catalogs or configuration UIs + */ + description?: string; + /** + * Optional layout hints for positioning and sizing + */ + layout?: CardLayout; + /** + * Optional settings schema for widget configuration + */ + settings?: CardSettings; +} + +/** + * Extension data ref for homepage widgets. + * + * This follows the pattern from FormFieldBlueprint, bundling the component + * and metadata into a single comprehensive data ref rather than outputting + * them separately. + * + * @alpha + */ +export const homePageWidgetDataRef = + createExtensionDataRef().with({ + id: 'home.widget.data', + }); + +/** + * Props provided to a home page layout component. + * + * @alpha + */ +export interface HomePageLayoutProps { + /** + * The list of widget elements and metadata to render on the home page. + */ + widgets: Array; +} + +/** + * Extension data ref for home page layout components. + * + * A layout receives the collected widgets and is responsible for arranging + * them on the home page. This follows the same pattern as + * EntityContentLayoutBlueprint in the catalog plugin. + * + * @alpha + */ +export const homePageLayoutComponentDataRef = createExtensionDataRef< + (props: HomePageLayoutProps) => JSX.Element +>().with({ + id: 'home.layout.component', +}); diff --git a/plugins/home-react/src/extensions.tsx b/plugins/home-react/src/extensions.tsx index 7795e20c73..699a3f9c9f 100644 --- a/plugins/home-react/src/extensions.tsx +++ b/plugins/home-react/src/extensions.tsx @@ -121,7 +121,7 @@ type CardExtensionComponentProps = CardExtensionProps & overrideTitle?: string; }; -function CardExtension(props: CardExtensionComponentProps) { +export function CardExtension(props: CardExtensionComponentProps) { const { Renderer, Content, diff --git a/plugins/home-react/src/translation.ts b/plugins/home-react/src/translation.ts index 8de5bb1ff1..1a8fd9f1bf 100644 --- a/plugins/home-react/src/translation.ts +++ b/plugins/home-react/src/translation.ts @@ -16,6 +16,9 @@ import { createTranslationRef } from '@backstage/frontend-plugin-api'; /** + * Translation reference for the home-react plugin. + * Contains localized text strings for home page components and settings modals. + * * @alpha */ export const homeReactTranslationRef = createTranslationRef({ diff --git a/plugins/home/README.md b/plugins/home/README.md index 242e66ff40..e2da69a32b 100644 --- a/plugins/home/README.md +++ b/plugins/home/README.md @@ -4,7 +4,7 @@ The Home plugin introduces a system for composing a Home Page for Backstage in o For App Integrators, the system is designed to be composable to give total freedom in designing a Home Page that suits the needs of the organization. From the perspective of a Component Developer who wishes to contribute with building blocks to be included in Home Pages, there's a convenient interface for bundling the different parts and exporting them with both error boundary and lazy loading handled under the surface. -## Getting started +## Installation If you have a standalone app (you didn't clone this repo), then do @@ -13,6 +13,170 @@ If you have a standalone app (you didn't clone this repo), then do yarn --cwd packages/app add @backstage/plugin-home ``` +## Getting started + +The home plugin supports both the new frontend system and the legacy system. + +### New Frontend System + +If you're using Backstage's new frontend system, add the plugin to your app: + +```ts +// packages/app/src/App.tsx +import homePlugin from '@backstage/plugin-home/alpha'; + +const app = createApp({ + features: [ + // ... other plugins + homePlugin, + // ... other plugins + ], +}); +``` + +The plugin will automatically provide: + +- A homepage at `/home` with customizable widget grid +- A "Home" navigation item in the sidebar + +#### Creating Custom Homepage Layouts + +Use the `HomePageLayoutBlueprint` from `@backstage/plugin-home-react/alpha` to +create custom homepage layouts. A layout receives the installed widgets and is +responsible for arranging them on the page. If no custom layout is installed, the +plugin provides a built-in default. + +```ts +import { HomePageLayoutBlueprint } from '@backstage/plugin-home-react/alpha'; +import { CustomHomepageGrid } from '@backstage/plugin-home'; +import { Content, Header, Page } from '@backstage/core-components'; +import { Fragment } from 'react'; + +const myHomePageLayout = HomePageLayoutBlueprint.make({ + params: { + loader: async () => + function MyHomePageLayout({ widgets }) { + return ( + +
+ + + {widgets.map((widget, index) => ( + + {widget.component} + + ))} + + + + ); + }, + }, +}); +``` + +Then register the layout in a frontend module: + +```ts +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +const homeModule = createFrontendModule({ + pluginId: 'home', + extensions: [myHomePageLayout], +}); +``` + +#### Visit Tracking (Optional) + +Visit tracking is an **optional feature** that must be explicitly enabled. When enabled, it provides intelligent storage fallbacks: + +**Enabling Visit Tracking:** + +Add the following to your `app-config.yaml`: + +```yaml +app: + extensions: + # Enable visit tracking API (disabled by default) + - api:home/visits: true + # Enable visit listener (disabled by default) + - app-root-element:home/visit-listener: true +``` + +**Storage Strategy (when enabled):** + +1. **Custom Storage API**: If you have `storageApiRef` configured (like database-backed `UserSettingsStorage`), visit data uses your custom storage +2. **Browser Local Storage Fallback**: If no custom storage is configured, automatically falls back to browser local storage + +**Note**: Visit tracking extensions are disabled by default to give users control over data collection and storage. + +## Creating Homepage Widgets + +Homepage widgets are React components that can be added to customizable home pages. The **key difference** between the new frontend system and legacy system is how these widget components are **registered and exported**: + +- **New Frontend System**: Use `HomePageWidgetBlueprint` to register widgets as extensions +- **Legacy System**: Use `createCardExtension` to export widgets as card components + +### New Frontend System + +Create widgets using the `HomePageWidgetBlueprint`: + +```ts +import { HomePageWidgetBlueprint } from '@backstage/plugin-home-react/alpha'; + +const myWidget = HomePageWidgetBlueprint.make({ + name: 'my-widget', + params: { + name: 'MyWidget', + title: 'My Custom Widget', + description: 'A custom widget for the homepage', + components: () => + import('./MyWidgetComponent').then(m => ({ + Content: m.Content, + })), + layout: { + height: { minRows: 4 }, + width: { minColumns: 3 }, + }, + settings: { + schema: { + title: 'Widget Settings', + type: 'object', + properties: { + color: { + title: 'Color', + type: 'string', + default: 'blue', + enum: ['blue', 'red', 'green'], + }, + }, + }, + }, + }, +}); +``` + +> **Example**: See [dev/index.tsx](dev/index.tsx) for a comprehensive example of creating multiple homepage widgets and layouts using the new frontend system. + +### Legacy System - Widget Registration + +In the legacy system, use the `createCardExtension` helper to create homepage widgets: + +```tsx +import { createCardExtension } from '@backstage/plugin-home-react'; + +export const MyWidget = homePlugin.provide( + createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({ + title: 'My Custom Widget', + components: () => import('./homePageComponents/MyWidget'), + }), +); +``` + +The `createCardExtension` provides error boundary and lazy loading, and accepts generics for custom props that App Integrators can configure. + +## Legacy System Setup + ### Setting up the Home Page 1. Create a Home Page Component that will be used for composition. @@ -40,28 +204,13 @@ import { homePage } from './components/home/HomePage'; // ... ``` -### Creating Components +### Creating Components (Legacy) -The Home Page can be composed with regular React components, so there's no magic in creating components to be used for composition 🪄 🎩 . However, in order to assure that your component fits into a diverse set of Home Pages, there's an extension creator for this purpose, that creates a Card-based layout, for consistency between components (read more about extensions [here](https://backstage.io/docs/plugins/composability#extensions)). The extension creator requires two fields: `title` and `components`. The `components` field is expected to be an asynchronous import that should at least contain a `Content` field. Additionally, you can optionally provide `settings`, `actions` and `contextProvider` as well. These parts will be combined to create a card, where the `content`, `actions` and `settings` will be wrapped within the `contextProvider` in order to be able to access to context and effectively communicate with one another. +In the legacy system, homepage components can be regular React components or wrapped with `createCardExtension` for additional features like error boundaries and lazy loading. Components created with `createCardExtension` are exported as card components that can be composed into homepage layouts. -Finally, the `createCardExtension` also accepts a generic, such that Component Developers can indicate to App Integrators what custom props their component will accept, such as the example below where the default category of the random jokes can be set. +### Composing a Home Page (Legacy) -```tsx -import { createCardExtension } from '@backstage/plugin-home-react'; - -export const RandomJokeHomePageComponent = homePlugin.provide( - createCardExtension<{ defaultCategory?: 'programming' | 'any' }>({ - title: 'Random Joke', - components: () => import('./homePageComponents/RandomJoke'), - }), -); -``` - -In summary: it is not necessary to use the `createCardExtension` extension creator to register a home page component, although it is convenient since it provides error boundary and lazy loading, and it also may hook into other functionality in the future. - -### Composing a Home Page - -Composing a Home Page is no different from creating a regular React Component, i.e. the App Integrator is free to include whatever content they like. However, there are components developed with the Home Page in mind, as described in the previous section. If created by the `createCardExtension` extension creator, they are rendered like so +In the legacy system, composing a Home Page is done by creating regular React components. Components created with `createCardExtension` are rendered like so: ```tsx import Grid from '@material-ui/core/Grid'; @@ -293,7 +442,7 @@ export const apis: AnyApiFactory[] = [ VisitsStorageApi.create({ storageApi, identityApi }), }), - // Or a localStorage data implementation, relies on WebStorage implementation of storageApi + // Or a local storage data implementation, relies on WebStorage implementation of storageApi createApiFactory({ api: visitsApiRef, deps: { diff --git a/plugins/home/dev/index.tsx b/plugins/home/dev/index.tsx index 341253a49e..954bf21740 100644 --- a/plugins/home/dev/index.tsx +++ b/plugins/home/dev/index.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * Copyright 2025 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,14 +13,215 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createDevApp } from '@backstage/dev-utils'; -import { homePlugin, HomepageCompositionRoot } from '../src/plugin'; -createDevApp() - .registerPlugin(homePlugin) - .addPage({ - element: , - title: 'Root Page', - path: '/', - }) - .render(); +import { Content, Header, Page } from '@backstage/core-components'; +import { createApp } from '@backstage/frontend-defaults'; +import { catalogApiRef } from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import catalogPlugin from '@backstage/plugin-catalog/alpha'; +import HomeIcon from '@material-ui/icons/Home'; +import ReactDOM from 'react-dom/client'; +import { Fragment } from 'react'; + +import { + ApiBlueprint, + createFrontendModule, +} from '@backstage/frontend-plugin-api'; +import { + HomePageWidgetBlueprint, + HomePageLayoutBlueprint, +} from '@backstage/plugin-home-react/alpha'; +import { HeaderWorldClock, WelcomeTitle, type ClockConfig } from '../src'; +import homePlugin from '../src/alpha'; +import { CustomHomepageGrid } from '../src/components'; +import type { LayoutConfiguration } from '../src/components/CustomHomepage/types'; + +const clockConfigs: ClockConfig[] = [ + { label: 'NYC', timeZone: 'America/New_York' }, + { label: 'UTC', timeZone: 'UTC' }, + { label: 'STO', timeZone: 'Europe/Stockholm' }, + { label: 'TYO', timeZone: 'Asia/Tokyo' }, +]; + +const timeFormat: Intl.DateTimeFormatOptions = { + hour: '2-digit', + minute: '2-digit', + hour12: false, +}; + +const defaultGridConfig: LayoutConfiguration[] = [ + { + component: 'HomePageToolkit', + x: 0, + y: 0, + width: 12, + height: 4, + movable: false, + resizable: false, + }, + { + component: 'HomePageStarredEntities', + x: 0, + y: 4, + width: 6, + height: 5, + }, + { + component: 'HomePageRandomJoke', + x: 6, + y: 4, + width: 6, + height: 5, + }, +]; + +const homePageLayout = HomePageLayoutBlueprint.make({ + params: { + loader: async () => + function CustomHomePageLayout({ widgets }) { + return ( + +
} pageTitleOverride="Home"> + +
+ + + {widgets.map((widget, index) => ( + + {widget.component} + + ))} + + +
+ ); + }, + }, +}); + +const homePageToolkitWidget = HomePageWidgetBlueprint.make({ + name: 'home-toolkit', + params: { + name: 'HomePageToolkit', + title: 'Toolkit', + components: () => + import('../src/homePageComponents/Toolkit').then(m => ({ + Content: m.Content, + ContextProvider: m.ContextProvider, + })), + componentProps: { + tools: [ + { + url: 'https://backstage.io', + label: 'Backstage Homepage', + icon: , + }, + ], + }, + }, +}); + +const homePageStarredEntitiesWidget = HomePageWidgetBlueprint.make({ + name: 'home-starred-entities', + params: { + name: 'HomePageStarredEntities', + title: 'Your Starred Entities', + components: () => + import('../src/homePageComponents/StarredEntities').then(m => ({ + Content: m.Content, + })), + }, +}); + +const homePageRandomJokeWidget = HomePageWidgetBlueprint.make({ + name: 'home-random-joke', + params: { + name: 'HomePageRandomJoke', + title: 'Random Joke', + description: 'Shows a random programming joke', + components: () => + import('../src/homePageComponents/RandomJoke').then(m => ({ + Content: m.Content, + Settings: m.Settings, + Actions: m.Actions, + ContextProvider: m.ContextProvider, + })), + layout: { + height: { minRows: 4 }, + width: { minColumns: 3 }, + }, + settings: { + schema: { + title: 'Random Joke settings', + type: 'object', + properties: { + defaultCategory: { + title: 'Category', + type: 'string', + enum: ['any', 'programming', 'dad'], + default: 'any', + }, + }, + }, + }, + }, +}); + +const homeDevModule = createFrontendModule({ + pluginId: 'home', + extensions: [ + homePageLayout, + homePageToolkitWidget, + homePageStarredEntitiesWidget, + homePageRandomJokeWidget, + ], +}); + +const entities = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'example', + annotations: { + 'backstage.io/managed-by-location': 'file:/path/to/catalog-info.yaml', + }, + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'guest', + }, + }, +]; + +const catalogApi = catalogApiMock({ entities }); + +const catalogPluginOverrides = createFrontendModule({ + pluginId: 'catalog', + extensions: [ + ApiBlueprint.make({ + params: defineParams => + defineParams({ + api: catalogApiRef, + deps: {}, + factory: () => catalogApi, + }), + }), + ], +}); + +const app = createApp({ + features: [ + catalogPlugin, + catalogPluginOverrides, + homePlugin, // Load the home plugin + homeDevModule, // Load the widgets and homepage content + ], +}); + +const root = app.createRoot(); +ReactDOM.createRoot(document.getElementById('root')!).render(root); diff --git a/plugins/home/package.json b/plugins/home/package.json index 8121f340c0..d08879a8b3 100644 --- a/plugins/home/package.json +++ b/plugins/home/package.json @@ -60,6 +60,7 @@ "@backstage/catalog-model": "workspace:^", "@backstage/config": "workspace:^", "@backstage/core-app-api": "workspace:^", + "@backstage/core-compat-api": "workspace:^", "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", @@ -83,6 +84,8 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/dev-utils": "workspace:^", + "@backstage/frontend-defaults": "workspace:^", + "@backstage/plugin-catalog": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^10.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/plugins/home/report-alpha.api.md b/plugins/home/report-alpha.api.md index 99e9b07463..4568fc8c2a 100644 --- a/plugins/home/report-alpha.api.md +++ b/plugins/home/report-alpha.api.md @@ -10,13 +10,17 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; +import { HomePageLayoutProps } from '@backstage/plugin-home-react/alpha'; +import { HomePageWidgetBlueprintParams } from '@backstage/plugin-home-react/alpha'; +import { HomePageWidgetData } from '@backstage/plugin-home-react/alpha'; +import { IconComponent } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; import { RouteRef } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; -// @alpha (undocumented) +// @alpha const _default: OverridableFrontendPlugin< { root: RouteRef; @@ -49,6 +53,54 @@ const _default: OverridableFrontendPlugin< element: JSX.Element; }; }>; + 'home-page-widget:home/random-joke': OverridableExtensionDefinition<{ + kind: 'home-page-widget'; + name: 'random-joke'; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: HomePageWidgetBlueprintParams; + }>; + 'home-page-widget:home/starred-entities': OverridableExtensionDefinition<{ + kind: 'home-page-widget'; + name: 'starred-entities'; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: HomePageWidgetBlueprintParams; + }>; + 'home-page-widget:home/toolkit': OverridableExtensionDefinition<{ + kind: 'home-page-widget'; + name: 'toolkit'; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: HomePageWidgetBlueprintParams; + }>; + 'nav-item:home': OverridableExtensionDefinition<{ + kind: 'nav-item'; + name: undefined; + config: {}; + configInput: {}; + output: ExtensionDataRef< + { + title: string; + icon: IconComponent; + routeRef: RouteRef; + }, + 'core.nav-item.target', + {} + >; + inputs: {}; + params: { + title: string; + icon: IconComponent; + routeRef: RouteRef; + }; + }>; 'page:home': OverridableExtensionDefinition<{ config: { path: string | undefined; @@ -67,25 +119,28 @@ const _default: OverridableFrontendPlugin< } >; inputs: { - props: ExtensionInput< - | ConfigurableExtensionDataRef< - JSX_2.Element, - 'core.reactElement', - { - optional: true; - } - > - | ConfigurableExtensionDataRef< - string, - 'title', - { - optional: true; - } - >, + widgets: ExtensionInput< + ConfigurableExtensionDataRef< + HomePageWidgetData, + 'home.widget.data', + {} + >, + { + singleton: false; + optional: false; + internal: false; + } + >; + layout: ExtensionInput< + ConfigurableExtensionDataRef< + (props: HomePageLayoutProps) => JSX_2.Element, + 'home.layout.component', + {} + >, { singleton: true; optional: true; - internal: false; + internal: true; } >; }; @@ -102,7 +157,7 @@ const _default: OverridableFrontendPlugin< >; export default _default; -// @alpha (undocumented) +// @alpha export const homeTranslationRef: TranslationRef< 'home', { @@ -135,12 +190,5 @@ export const homeTranslationRef: TranslationRef< } >; -// @alpha (undocumented) -export const titleExtensionDataRef: ConfigurableExtensionDataRef< - string, - 'title', - {} ->; - // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/home/report.api.md b/plugins/home/report.api.md index 7b15ecd8a7..17870ed481 100644 --- a/plugins/home/report.api.md +++ b/plugins/home/report.api.md @@ -344,7 +344,7 @@ export type VisitsApiQueryParams = { }>; }; -// @public (undocumented) +// @public export const visitsApiRef: ApiRef; // @public diff --git a/plugins/home/src/alpha.test.ts b/plugins/home/src/alpha.test.ts new file mode 100644 index 0000000000..caa7d45cf7 --- /dev/null +++ b/plugins/home/src/alpha.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2025 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 { mockApis } from '@backstage/test-utils'; +import homePlugin from './alpha'; +import { VisitsStorageApi, VisitsWebStorageApi } from './api'; + +// Mock localStorage for testing +const mockLocalStorage = new Map(); +Object.defineProperty(window, 'localStorage', { + value: { + getItem: (key: string) => mockLocalStorage.get(key) || null, + setItem: (key: string, value: string) => mockLocalStorage.set(key, value), + removeItem: (key: string) => mockLocalStorage.delete(key), + clear: () => mockLocalStorage.clear(), + }, +}); + +describe('Home Plugin Alpha', () => { + beforeEach(() => { + mockLocalStorage.clear(); + }); + + describe('Core Extensions (Always Enabled)', () => { + it('should export core home page extension', () => { + expect(homePlugin.getExtension('page:home')).toBeDefined(); + }); + + it('should export navigation item extension', () => { + expect(homePlugin.getExtension('nav-item:home')).toBeDefined(); + }); + }); + + describe('Optional Extensions (Disabled by Default)', () => { + it('should export visit tracking API extension', () => { + const visitTrackingExtension = homePlugin.getExtension('api:home/visits'); + expect(visitTrackingExtension).toBeDefined(); + }); + + it('should export visit listener extension', () => { + const visitListenerExtension = homePlugin.getExtension( + 'app-root-element:home/visit-listener', + ); + expect(visitListenerExtension).toBeDefined(); + }); + }); + + describe('API Implementation Classes', () => { + it('should create VisitsStorageApi with custom storage', () => { + const mockStorageApi = mockApis.storage(); + const mockIdentityApi = mockApis.identity({ + userEntityRef: 'user:default/testuser', + }); + + const visitsApi = VisitsStorageApi.create({ + storageApi: mockStorageApi, + identityApi: mockIdentityApi, + }); + + expect(visitsApi).toBeInstanceOf(VisitsStorageApi); + }); + + it('should create localStorage fallback API when no custom storage is available', () => { + const mockIdentityApi = mockApis.identity({ + userEntityRef: 'user:default/testuser', + }); + const mockErrorApi = { post: jest.fn(), error$: jest.fn() }; + + // Test that VisitsWebStorageApi can be created without custom storage + const visitsApi = VisitsWebStorageApi.create({ + identityApi: mockIdentityApi, + errorApi: mockErrorApi, + }); + + // VisitsWebStorageApi.create returns a VisitsStorageApi instance + expect(visitsApi).toBeInstanceOf(VisitsStorageApi); + }); + }); + + describe('Plugin Structure', () => { + it('should have correct plugin metadata', () => { + expect(homePlugin.id).toBe('home'); + expect(homePlugin.routes.root).toBeDefined(); + }); + + it('should include all extensions in the correct order', () => { + // Core extensions (always enabled) + expect(homePlugin.getExtension('page:home')).toBeDefined(); + expect(homePlugin.getExtension('nav-item:home')).toBeDefined(); + + // Optional extensions (disabled by default) + expect(homePlugin.getExtension('api:home/visits')).toBeDefined(); + expect( + homePlugin.getExtension('app-root-element:home/visit-listener'), + ).toBeDefined(); + }); + }); +}); diff --git a/plugins/home/src/alpha.tsx b/plugins/home/src/alpha.tsx index 6cad71d643..23e9d0e42b 100644 --- a/plugins/home/src/alpha.tsx +++ b/plugins/home/src/alpha.tsx @@ -14,60 +14,87 @@ * limitations under the License. */ +/** + * The home plugin for Backstage's new frontend system. + * + * @remarks + * This package provides the new frontend system implementation of the home plugin, + * which offers customizable home pages with widget support and optional visit tracking. + * + * @packageDocumentation + */ + +import { lazy as reactLazy } from 'react'; import { - coreExtensionData, - createExtensionDataRef, createExtensionInput, PageBlueprint, + NavItemBlueprint, createFrontendPlugin, createRouteRef, AppRootElementBlueprint, identityApiRef, storageApiRef, + errorApiRef, ApiBlueprint, + ExtensionBoundary, } from '@backstage/frontend-plugin-api'; import { VisitListener } from './components/'; -import { visitsApiRef, VisitsStorageApi } from './api'; +import { visitsApiRef, VisitsStorageApi, VisitsWebStorageApi } from './api'; +import HomeIcon from '@material-ui/icons/Home'; +import { + homePageWidgetDataRef, + homePageLayoutComponentDataRef, + HomePageLayoutBlueprint, + HomePageWidgetBlueprint, + type HomePageLayoutProps, +} from '@backstage/plugin-home-react/alpha'; const rootRouteRef = createRouteRef(); -/** - * @alpha - */ -export const titleExtensionDataRef = createExtensionDataRef().with({ - id: 'title', -}); - const homePage = PageBlueprint.makeWithOverrides({ inputs: { - props: createExtensionInput( - [ - coreExtensionData.reactElement.optional(), - titleExtensionDataRef.optional(), - ], - { - singleton: true, - optional: true, - }, - ), + widgets: createExtensionInput([homePageWidgetDataRef]), + layout: createExtensionInput([HomePageLayoutBlueprint.dataRefs.component], { + singleton: true, + optional: true, + internal: true, + }), }, - factory: (originalFactory, { inputs }) => { + factory(originalFactory, { node, inputs }) { return originalFactory({ path: '/home', routeRef: rootRouteRef, - loader: () => - import('./components/').then(m => ( - - )), + loader: async () => { + const LazyDefaultLayout = reactLazy(() => + import('./alpha/DefaultHomePageLayout').then(m => ({ + default: m.DefaultHomePageLayout, + })), + ); + + const DefaultLayoutComponent = (props: HomePageLayoutProps) => ( + + + + ); + + const Layout = + inputs.layout?.get(homePageLayoutComponentDataRef) ?? + DefaultLayoutComponent; + + const widgets = inputs.widgets.map(widget => ({ + ...widget.get(homePageWidgetDataRef), + node: widget.node, + })); + + return ; + }, }); }, }); const visitListenerAppRootElement = AppRootElementBlueprint.make({ name: 'visit-listener', + disabled: true, params: { element: , }, @@ -75,25 +102,121 @@ const visitListenerAppRootElement = AppRootElementBlueprint.make({ const visitsApi = ApiBlueprint.make({ name: 'visits', + disabled: true, params: defineParams => defineParams({ api: visitsApiRef, deps: { storageApi: storageApiRef, identityApi: identityApiRef, + errorApi: errorApiRef, + }, + factory: ({ storageApi, identityApi, errorApi }) => { + // Smart fallback: use custom storage API if available, otherwise localStorage + if (storageApi) { + return VisitsStorageApi.create({ storageApi, identityApi }); + } + return VisitsWebStorageApi.create({ identityApi, errorApi }); }, - factory: ({ storageApi, identityApi }) => - VisitsStorageApi.create({ storageApi, identityApi }), }), }); +const homeNavItem = NavItemBlueprint.make({ + params: { + title: 'Home', + routeRef: rootRouteRef, + icon: HomeIcon, + }, +}); + +const homePageToolkitWidget = HomePageWidgetBlueprint.make({ + name: 'toolkit', + params: { + name: 'HomePageToolkit', + title: 'Toolkit', + components: () => + import('./homePageComponents/Toolkit').then(m => ({ + Content: m.Content, + ContextProvider: m.ContextProvider, + })), + componentProps: { + tools: [ + { + url: 'https://backstage.io', + label: 'Backstage Docs', + icon: , + }, + ], + }, + }, +}); + +const homePageStarredEntitiesWidget = HomePageWidgetBlueprint.make({ + name: 'starred-entities', + params: { + name: 'HomePageStarredEntities', + title: 'Your Starred Entities', + components: () => + import('./homePageComponents/StarredEntities').then(m => ({ + Content: m.Content, + })), + }, +}); + +const homePageRandomJokeWidget = HomePageWidgetBlueprint.make({ + name: 'random-joke', + params: { + name: 'HomePageRandomJoke', + title: 'Random Joke', + description: 'Shows a random programming joke', + components: () => + import('./homePageComponents/RandomJoke').then(m => ({ + Content: m.Content, + Settings: m.Settings, + Actions: m.Actions, + ContextProvider: m.ContextProvider, + })), + layout: { + height: { minRows: 4 }, + width: { minColumns: 3 }, + }, + settings: { + schema: { + title: 'Random Joke settings', + type: 'object', + properties: { + defaultCategory: { + title: 'Category', + type: 'string', + enum: ['any', 'programming', 'dad'], + default: 'any', + }, + }, + }, + }, + }, +}); + /** + * Home plugin for the new frontend system. + * + * Provides core homepage functionality with optional visit tracking extensions. + * Visit tracking extensions are disabled by default and can be enabled via app-config.yaml. + * * @alpha */ export default createFrontendPlugin({ pluginId: 'home', info: { packageJson: () => import('../package.json') }, - extensions: [homePage, visitsApi, visitListenerAppRootElement], + extensions: [ + homePage, + homeNavItem, + visitsApi, + visitListenerAppRootElement, + homePageToolkitWidget, + homePageStarredEntitiesWidget, + homePageRandomJokeWidget, + ], routes: { root: rootRouteRef, }, diff --git a/plugins/home/src/alpha/DefaultHomePageLayout.tsx b/plugins/home/src/alpha/DefaultHomePageLayout.tsx new file mode 100644 index 0000000000..f8d63a635f --- /dev/null +++ b/plugins/home/src/alpha/DefaultHomePageLayout.tsx @@ -0,0 +1,39 @@ +/* + * Copyright 2025 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 { Fragment } from 'react'; +import { Content } from '@backstage/core-components'; +import type { HomePageLayoutProps } from '@backstage/plugin-home-react/alpha'; +import { CustomHomepageGrid } from '../components'; + +/** + * The default home page layout component. + * + * Renders widgets inside a `` wrapped in ``. + * This is used when no custom layout extension is installed. + */ +export function DefaultHomePageLayout(props: HomePageLayoutProps) { + const { widgets } = props; + return ( + + + {widgets.map((widget, index) => ( + {widget.component} + ))} + + + ); +} diff --git a/plugins/home/src/api/VisitsApi.ts b/plugins/home/src/api/VisitsApi.ts index 5cbd65fab3..3345128e08 100644 --- a/plugins/home/src/api/VisitsApi.ts +++ b/plugins/home/src/api/VisitsApi.ts @@ -146,7 +146,12 @@ export interface VisitsApi { ): Promise> | Record; } -/** @public */ +/** + * API reference for the visits tracking service. + * Provides functionality to track and retrieve user page visit history. + * + * @public + */ export const visitsApiRef = createApiRef({ id: 'homepage.visits', }); diff --git a/plugins/home/src/homePageComponents/QuickStart/QuickStart.stories.tsx b/plugins/home/src/homePageComponents/QuickStart/QuickStart.stories.tsx index 0f83f7915e..a32c0da1e3 100644 --- a/plugins/home/src/homePageComponents/QuickStart/QuickStart.stories.tsx +++ b/plugins/home/src/homePageComponents/QuickStart/QuickStart.stories.tsx @@ -18,6 +18,7 @@ import { QuickStartCard } from '../../plugin'; import { ComponentType, PropsWithChildren } from 'react'; import { wrapInTestApp } from '@backstage/test-utils'; import Grid from '@material-ui/core/Grid'; +import Typography from '@material-ui/core/Typography'; import OpenInNewIcon from '@material-ui/icons/OpenInNew'; import ContentImage from './static/backstageSystemModel.png'; @@ -64,11 +65,11 @@ export const Customized = () => { } cardDescription="Backstage system model will help you create new entities" additionalContent={ -

+ This is a custom description for the Quick Start card. It can be used to provide additional information or context about the Quick Start process. -

+ } /> diff --git a/plugins/home/src/translation.ts b/plugins/home/src/translation.ts index 6e8ad2c511..480e2850b9 100644 --- a/plugins/home/src/translation.ts +++ b/plugins/home/src/translation.ts @@ -16,6 +16,9 @@ import { createTranslationRef } from '@backstage/frontend-plugin-api'; /** + * Translation reference for the home plugin. + * Contains localized text strings for home page components and widgets. + * * @alpha */ export const homeTranslationRef = createTranslationRef({ diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 020c9580d2..46a576191f 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -8,9 +8,9 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api'; import { ApiFactory } from '@backstage/frontend-plugin-api'; import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha'; import { Entity } from '@backstage/catalog-model'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { JSX as JSX_2 } from 'react'; import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; @@ -93,12 +93,12 @@ const _default: OverridableFrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; group: string | false | undefined; icon: string | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -154,7 +154,7 @@ const _default: OverridableFrontendPlugin< icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; }>; 'page:kubernetes': OverridableExtensionDefinition<{ diff --git a/plugins/mcp-actions-backend/package.json b/plugins/mcp-actions-backend/package.json index b77b565836..2363c27c98 100644 --- a/plugins/mcp-actions-backend/package.json +++ b/plugins/mcp-actions-backend/package.json @@ -34,7 +34,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/errors": "workspace:^", @@ -47,6 +46,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@types/express": "^4.17.6" diff --git a/plugins/org/report-alpha.api.md b/plugins/org/report-alpha.api.md index f6c3dcff2b..781cfe605a 100644 --- a/plugins/org/report-alpha.api.md +++ b/plugins/org/report-alpha.api.md @@ -5,9 +5,9 @@ ```ts import { Entity } from '@backstage/catalog-model'; import { EntityCardType } from '@backstage/plugin-catalog-react/alpha'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { JSX as JSX_2 } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; @@ -24,11 +24,11 @@ const _default: OverridableFrontendPlugin< kind: 'entity-card'; name: 'group-profile'; config: { - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -57,7 +57,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -65,13 +65,13 @@ const _default: OverridableFrontendPlugin< config: { initialRelationAggregation: 'direct' | 'aggregated' | undefined; showAggregateMembersToggle: boolean | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { showAggregateMembersToggle?: boolean | undefined; initialRelationAggregation?: 'direct' | 'aggregated' | undefined; - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -102,7 +102,7 @@ const _default: OverridableFrontendPlugin< name: 'members-list'; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -111,14 +111,14 @@ const _default: OverridableFrontendPlugin< initialRelationAggregation: 'direct' | 'aggregated' | undefined; showAggregateMembersToggle: boolean | undefined; ownedKinds: string[] | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { showAggregateMembersToggle?: boolean | undefined; initialRelationAggregation?: 'direct' | 'aggregated' | undefined; ownedKinds?: string[] | undefined; - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -149,7 +149,7 @@ const _default: OverridableFrontendPlugin< name: 'ownership'; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; @@ -157,13 +157,13 @@ const _default: OverridableFrontendPlugin< config: { maxRelations: number | undefined; hideIcons: boolean; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; type: 'content' | 'info' | undefined; }; configInput: { hideIcons?: boolean | undefined; maxRelations?: number | undefined; - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; type?: 'content' | 'info' | undefined; }; output: @@ -194,7 +194,7 @@ const _default: OverridableFrontendPlugin< name: 'user-profile'; params: { loader: () => Promise; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); type?: EntityCardType; }; }>; diff --git a/plugins/org/src/alpha.test.tsx b/plugins/org/src/alpha.test.tsx index d643ec79ee..1a42f36e54 100644 --- a/plugins/org/src/alpha.test.tsx +++ b/plugins/org/src/alpha.test.tsx @@ -21,7 +21,6 @@ import { createTestEntityPage, catalogApiMock, } from '@backstage/plugin-catalog-react/testUtils'; -import { catalogApiRef } from '@backstage/plugin-catalog-react'; import orgPlugin from './alpha'; const EntityGroupProfileCard = orgPlugin.getExtension( @@ -230,12 +229,7 @@ describe('org plugin entity cards', () => { createTestEntityPage({ entity: groupEntity }), EntityMembersListCard, ], - apis: [ - [ - catalogApiRef, - catalogApiMock({ entities: [groupEntity, memberUser] }), - ], - ], + apis: [catalogApiMock({ entities: [groupEntity, memberUser] })], }); expect(await screen.findByText('Alice Smith')).toBeInTheDocument(); @@ -261,7 +255,7 @@ describe('org plugin entity cards', () => { createTestEntityPage({ entity: groupEntity }), EntityMembersListCard, ], - apis: [[catalogApiRef, catalogApiMock({ entities: [groupEntity] })]], + apis: [catalogApiMock({ entities: [groupEntity] })], }); expect( diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 58fedf0877..6b59166118 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -22,6 +22,7 @@ export const createGitlabGroupEnsureExistsAction: (options: { } )[]; token?: string | undefined; + description?: string | undefined; }, { groupId?: number | undefined; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts index 449c5980a4..f8004f105e 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.test.ts @@ -23,7 +23,7 @@ import { mockServices } from '@backstage/backend-test-utils'; const mockGitlabClient = { Groups: { - search: jest.fn(), + show: jest.fn(), create: jest.fn(), }, }; @@ -44,7 +44,9 @@ describe('gitlab:group:ensureExists', () => { }); it(`Should ${examples[0].description}`, async () => { - mockGitlabClient.Groups.search.mockResolvedValue([]); + mockGitlabClient.Groups.show.mockRejectedValue({ + cause: { response: { status: 404 } }, + }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 3, full_path: 'group1', @@ -75,19 +77,16 @@ describe('gitlab:group:ensureExists', () => { expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith( 'group1', 'group1', - {}, + { description: 'This is a top-level group' }, ); expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); }); it(`Should ${examples[1].description}`, async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'group1', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 1, full_path: 'group1' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 3, full_path: 'group1/group2', @@ -127,16 +126,10 @@ describe('gitlab:group:ensureExists', () => { }); it(`Should ${examples[2].description}`, async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'group1', - }, - { - id: 2, - full_path: 'group1/group2', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 1, full_path: 'group1' }) + .mockResolvedValueOnce({ id: 2, full_path: 'group1/group2' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 3, full_path: 'group1/group2/group3', @@ -199,23 +192,17 @@ describe('gitlab:group:ensureExists', () => { input: yaml.parse(examples[3].example).steps[0].input, }); - expect(mockGitlabClient.Groups.search).not.toHaveBeenCalled(); + expect(mockGitlabClient.Groups.show).not.toHaveBeenCalled(); expect(mockGitlabClient.Groups.create).not.toHaveBeenCalled(); expect(mockContext.output).toHaveBeenCalledWith('groupId', 42); }); it(`Should ${examples[4].description}`, async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'group1', - }, - { - id: 2, - full_path: 'group1/group2', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 1, full_path: 'group1' }) + .mockResolvedValueOnce({ id: 2, full_path: 'group1/group2' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 3, full_path: 'group1/group2/group3', @@ -255,20 +242,11 @@ describe('gitlab:group:ensureExists', () => { }); it(`Should ${examples[5].description}`, async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'group1', - }, - { - id: 2, - full_path: 'group1/group2', - }, - { - id: 3, - full_path: 'group1/group2/group3', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 1, full_path: 'group1' }) + .mockResolvedValueOnce({ id: 2, full_path: 'group1/group2' }) + .mockResolvedValueOnce({ id: 3, full_path: 'group1/group2/group3' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 4, full_path: 'group1/group2/group3/group4', diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts index f379bd38ec..967c0c55e7 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.examples.ts @@ -18,7 +18,7 @@ import yaml from 'yaml'; export const examples: TemplateExample[] = [ { - description: 'Creating a group at the top level', + description: 'Creating a group at the top level, with a description', example: yaml.stringify({ steps: [ { @@ -28,6 +28,7 @@ export const examples: TemplateExample[] = [ input: { repoUrl: 'gitlab.com', path: ['group1'], + description: 'This is a top-level group', }, }, ], diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts index 8e52ee999e..f74c80742c 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.test.ts @@ -22,7 +22,7 @@ import { mockServices } from '@backstage/backend-test-utils'; const mockGitlabClient = { Groups: { - search: jest.fn(), + show: jest.fn(), create: jest.fn(), }, }; @@ -65,16 +65,9 @@ describe('gitlab:group:ensureExists', () => { const mockContext = createMockActionContext(); it('should create a new group from string if it does not exists', async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'bar', - }, - { - id: 2, - full_path: 'foo', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 2, full_path: 'foo' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 3, @@ -86,33 +79,81 @@ describe('gitlab:group:ensureExists', () => { input: { repoUrl: 'gitlab.com?repo=repo&owner=owner', path: ['foo', 'bar'], + description: 'my cool group', }, }); expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith('bar', 'bar', { + description: 'my cool group', parentId: 2, }); expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); }); - it('should create a new group from pathstring if it does not exists', async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { + it('should only apply description to the last created group', async () => { + mockGitlabClient.Groups.create + .mockResolvedValueOnce({ id: 1, - full_path: 'bar', - }, - { - id: 2, full_path: 'foo', - }, - ]); + }) + .mockResolvedValueOnce({ + id: 2, + full_path: 'foo/bar', + }) + .mockResolvedValueOnce({ + id: 3, + full_path: 'foo/bar/baz', + }); - mockGitlabClient.Groups.create.mockResolvedValue({ - id: 3, - full_path: 'foo/bar', + await action.handler({ + ...mockContext, + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + path: ['foo', 'bar', 'baz'], + description: 'only for innermost group', + }, }); + expect(mockGitlabClient.Groups.create).toHaveBeenCalledTimes(3); + + // First group: no description + expect(mockGitlabClient.Groups.create).toHaveBeenNthCalledWith( + 1, + 'foo', + 'foo', + {}, + ); + + // Second group: no description + expect(mockGitlabClient.Groups.create).toHaveBeenNthCalledWith( + 2, + 'bar', + 'bar', + { parentId: 1 }, + ); + + // Third (last) group: has description + expect(mockGitlabClient.Groups.create).toHaveBeenNthCalledWith( + 3, + 'baz', + 'baz', + { parentId: 2, description: 'only for innermost group' }, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); + }); + + it('should create a new group from pathstring if it does not exists', async () => { + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 2, full_path: 'foo' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); + + mockGitlabClient.Groups.create + .mockResolvedValueOnce({ id: 3, full_path: 'foo/bar' }) + .mockResolvedValueOnce({ id: 4, full_path: 'foo/bar/baz' }); + await action.handler({ ...mockContext, input: { @@ -121,24 +162,31 @@ describe('gitlab:group:ensureExists', () => { }, }); - expect(mockGitlabClient.Groups.create).toHaveBeenCalledWith('bar', 'bar', { - parentId: 2, - }); + expect(mockGitlabClient.Groups.create).toHaveBeenNthCalledWith( + 1, + 'bar', + 'bar', + { + parentId: 2, + }, + ); - expect(mockContext.output).toHaveBeenCalledWith('groupId', 3); + expect(mockGitlabClient.Groups.create).toHaveBeenNthCalledWith( + 2, + 'baz', + 'baz', + { + parentId: 3, + }, + ); + + expect(mockContext.output).toHaveBeenCalledWith('groupId', 4); }); it('should create a new group from object if it does not exists', async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'bar', - }, - { - id: 2, - full_path: 'foo', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 2, full_path: 'foo' }) + .mockRejectedValueOnce({ cause: { response: { status: 404 } } }); mockGitlabClient.Groups.create.mockResolvedValue({ id: 3, @@ -168,20 +216,9 @@ describe('gitlab:group:ensureExists', () => { }); it('should return existing group if it does exists', async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'bar', - }, - { - id: 2, - full_path: 'foo', - }, - { - id: 42, - full_path: 'foo/bar', - }, - ]); + mockGitlabClient.Groups.show + .mockResolvedValueOnce({ id: 2, full_path: 'foo' }) + .mockResolvedValueOnce({ id: 42, full_path: 'foo/bar' }); await action.handler({ ...mockContext, @@ -206,19 +243,17 @@ describe('gitlab:group:ensureExists', () => { }, }); - expect(mockGitlabClient.Groups.search).not.toHaveBeenCalled(); + expect(mockGitlabClient.Groups.show).not.toHaveBeenCalled(); expect(mockGitlabClient.Groups.create).not.toHaveBeenCalled(); expect(mockContext.output).toHaveBeenCalledWith('groupId', 42); }); it('should use the token from the integration config when none is provided', async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'foobar', - }, - ]); + mockGitlabClient.Groups.show.mockResolvedValue({ + id: 1, + full_path: 'foobar', + }); await action.handler({ ...mockContext, @@ -236,12 +271,10 @@ describe('gitlab:group:ensureExists', () => { }); it('should use a provided token as bearer authentication', async () => { - mockGitlabClient.Groups.search.mockResolvedValue([ - { - id: 1, - full_path: 'foobar', - }, - ]); + mockGitlabClient.Groups.show.mockResolvedValue({ + id: 1, + full_path: 'foobar', + }); await action.handler({ ...mockContext, diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts index c3d4fd74bf..b9b5a295b3 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabGroupEnsureExists.ts @@ -16,7 +16,7 @@ import { ScmIntegrationRegistry } from '@backstage/integration'; import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; -import { GroupSchema } from '@gitbeaker/rest'; +import { GroupSchema, CreateGroupOptions } from '@gitbeaker/rest'; import { getClient, parseRepoUrl } from '../util'; import { examples } from './gitlabGroupEnsureExists.examples'; @@ -47,6 +47,12 @@ export const createGitlabGroupEnsureExistsAction = (options: { description: 'The token to use for authorization to GitLab', }) .optional(), + description: z => + z + .string({ + description: 'The group’s description', + }) + .optional(), path: z => z .array( @@ -77,8 +83,7 @@ export const createGitlabGroupEnsureExistsAction = (options: { ctx.output('groupId', 42); return; } - - const { token, repoUrl, path } = ctx.input; + const { token, repoUrl, path, description } = ctx.input; const { host } = parseRepoUrl(repoUrl, integrations); @@ -86,14 +91,23 @@ export const createGitlabGroupEnsureExistsAction = (options: { let currentPath: string | null = null; let parentId: number | null = null; - for (const { name, slug } of pathIterator(path)) { + const pathParts = [...pathIterator(path)]; + const lastIndex = pathParts.length - 1; + for (let i = 0; i < pathParts.length; i++) { + const { name, slug } = pathParts[i]; const fullPath: string = currentPath ? `${currentPath}/${slug}` : slug; - const result = (await api.Groups.search( - fullPath, - )) as unknown as Array; // recast since the return type for search is wrong in the gitbeaker typings - const subGroup = result.find( - searchPathElem => searchPathElem.full_path === fullPath, - ); + let subGroup: GroupSchema | undefined; + try { + subGroup = (await api.Groups.show( + fullPath, + )) as unknown as GroupSchema; + } catch (error: any) { + // 404 means the group doesn't exist - we'll create it below + // Any other error should be thrown + if (error.cause?.response?.status !== 404) { + throw error; + } + } if (!subGroup) { ctx.logger.info(`creating missing group ${fullPath}`); @@ -101,17 +115,11 @@ export const createGitlabGroupEnsureExistsAction = (options: { key: `ensure.${name}.${slug}.${parentId}`, // eslint-disable-next-line no-loop-func fn: async () => { - return ( - await api.Groups.create( - name, - slug, - parentId - ? { - parentId: parentId, - } - : {}, - ) - )?.id; + const groupOptions: CreateGroupOptions = { + ...(parentId ? { parentId } : {}), + ...(description && i === lastIndex ? { description } : {}), + }; + return (await api.Groups.create(name, slug, groupOptions))?.id; }, }); } else { diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index df7a07709b..077903c2e0 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -65,7 +65,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-openapi-utils": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-model": "workspace:^", diff --git a/plugins/scaffolder-react/package.json b/plugins/scaffolder-react/package.json index 840f210c78..077824950b 100644 --- a/plugins/scaffolder-react/package.json +++ b/plugins/scaffolder-react/package.json @@ -98,6 +98,7 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", diff --git a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx index cfbe8a40b4..9489321854 100644 --- a/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx +++ b/plugins/scaffolder-react/src/next/components/TemplateCategoryPicker/TemplateCategoryPicker.test.tsx @@ -17,6 +17,7 @@ import { useEntityTypeFilter } from '@backstage/plugin-catalog-react'; import { TemplateCategoryPicker } from './TemplateCategoryPicker'; import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { alertApiRef } from '@backstage/core-plugin-api'; import { fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -26,10 +27,10 @@ jest.mock('@backstage/plugin-catalog-react', () => ({ })); describe('TemplateCategoryPicker', () => { - const mockAlertApi = { post: jest.fn() }; + const mockAlertApi = mockApis.alert(); beforeEach(() => { - mockAlertApi.post.mockClear(); + mockAlertApi.clearAlerts(); }); it('should post the error to errorApi if an errors is returned', async () => { @@ -37,13 +38,16 @@ describe('TemplateCategoryPicker', () => { error: new Error('something broked'), }); + mockAlertApi.clearAlerts(); + await renderInTestApp( , ); - expect(mockAlertApi.post).toHaveBeenCalledWith({ + expect(mockAlertApi.getAlerts().length).toBeGreaterThanOrEqual(1); + expect(mockAlertApi.getAlerts()[0]).toMatchObject({ message: expect.stringContaining('something broked'), severity: 'error', }); diff --git a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx index 78c2064c4b..293ef54bc2 100644 --- a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx +++ b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx @@ -18,11 +18,10 @@ import { renderHook } from '@testing-library/react'; import { useFilteredSchemaProperties } from './useFilteredSchemaProperties'; import { TemplateParameterSchema } from '../../types'; import { TestApiProvider } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { featureFlagsApiRef } from '@backstage/core-plugin-api'; -const mockFeatureFlagApi = { - isActive: jest.fn(), -}; +const mockFeatureFlagApi = mockApis.featureFlags.mock(); describe('useFilteredSchemaProperties', () => { it('should return the same manifest if no feature flag is set', () => { diff --git a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx index dff5500b51..b51e3ff147 100644 --- a/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx +++ b/plugins/scaffolder-react/src/next/hooks/useTemplateSchema.test.tsx @@ -15,9 +15,9 @@ */ import { useTemplateSchema } from './useTemplateSchema'; import { renderHook } from '@testing-library/react'; -import { TestApiProvider } from '@backstage/test-utils'; +import { TestApiProvider } from '@backstage/frontend-test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { PropsWithChildren } from 'react'; -import { featureFlagsApiRef } from '@backstage/core-plugin-api'; import { TemplateParameterSchema } from '../../types'; describe('useTemplateSchema', () => { @@ -49,11 +49,13 @@ describe('useTemplateSchema', () => { ], }; + const mockFeatureFlagsApi = mockApis.featureFlags.mock({ + isActive: jest.fn(() => false), + }); + const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( - false }]]} - > + {children} ), @@ -119,7 +121,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} @@ -163,7 +167,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( true }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => true) }), + ]} > {children} @@ -214,7 +220,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} @@ -252,7 +260,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} @@ -356,7 +366,9 @@ describe('useTemplateSchema', () => { const { result } = renderHook(() => useTemplateSchema(manifest), { wrapper: ({ children }: PropsWithChildren<{}>) => ( false }]]} + apis={[ + mockApis.featureFlags.mock({ isActive: jest.fn(() => false) }), + ]} > {children} diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 9b505fc2b6..c08f507f42 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -108,6 +108,7 @@ "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", + "@backstage/frontend-test-utils": "workspace:^", "@backstage/plugin-catalog": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-techdocs": "workspace:^", diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 4d83a737aa..62c027b2ad 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -10,12 +10,12 @@ import { ApiRef } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { Entity } from '@backstage/catalog-model'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { FormField } from '@backstage/plugin-scaffolder-react/alpha'; import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; @@ -137,10 +137,10 @@ const _default: OverridableFrontendPlugin< config: { label: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; label?: string | undefined; title?: string | undefined; }; @@ -167,7 +167,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { useProps: () => Omit; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; }>; 'nav-item:scaffolder': OverridableExtensionDefinition<{ diff --git a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx index 5968f1da4f..cc45547827 100644 --- a/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx +++ b/plugins/scaffolder/src/components/TemplateTypePicker/TemplateTypePicker.test.tsx @@ -23,9 +23,10 @@ import { EntityKindFilter, } from '@backstage/plugin-catalog-react'; import { MockEntityListContextProvider } from '@backstage/plugin-catalog-react/testUtils'; -import { AlertApi, alertApiRef } from '@backstage/core-plugin-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; import { ApiProvider } from '@backstage/core-app-api'; import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils'; +import { mockApis } from '@backstage/frontend-test-utils'; import { GetEntityFacetsResponse } from '@backstage/catalog-client'; const entities: Entity[] = [ @@ -75,12 +76,7 @@ const apis = TestApiRegistry.from( } as GetEntityFacetsResponse), }, ], - [ - alertApiRef, - { - post: jest.fn(), - } as unknown as AlertApi, - ], + [alertApiRef, mockApis.alert()], ); describe('', () => { diff --git a/plugins/search-backend/package.json b/plugins/search-backend/package.json index 40bccb2046..3763ae7cff 100644 --- a/plugins/search-backend/package.json +++ b/plugins/search-backend/package.json @@ -56,7 +56,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-openapi-utils": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", @@ -74,6 +73,7 @@ "zod": "^3.25.76" }, "devDependencies": { + "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/repo-tools": "workspace:^", diff --git a/plugins/techdocs-backend/package.json b/plugins/techdocs-backend/package.json index 0be03ca967..c2cb655a0b 100644 --- a/plugins/techdocs-backend/package.json +++ b/plugins/techdocs-backend/package.json @@ -61,7 +61,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", "@backstage/catalog-model": "workspace:^", diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index db712d02b5..d839a03f01 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -9,10 +9,10 @@ import { ApiFactory } from '@backstage/frontend-plugin-api'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { defaultEntityContentGroups } from '@backstage/plugin-catalog-react/alpha'; import { Entity } from '@backstage/catalog-model'; -import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionInput } from '@backstage/frontend-plugin-api'; +import { FilterPredicate } from '@backstage/filter-predicates'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; @@ -126,12 +126,12 @@ const _default: OverridableFrontendPlugin< config: { path: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; group: string | false | undefined; icon: string | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; @@ -216,7 +216,7 @@ const _default: OverridableFrontendPlugin< icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; - filter?: string | EntityPredicate | ((entity: Entity) => boolean); + filter?: string | FilterPredicate | ((entity: Entity) => boolean); }; }>; 'entity-icon-link:techdocs/read-docs': OverridableExtensionDefinition<{ @@ -225,10 +225,10 @@ const _default: OverridableFrontendPlugin< config: { label: string | undefined; title: string | undefined; - filter: EntityPredicate | undefined; + filter: FilterPredicate | undefined; }; configInput: { - filter?: EntityPredicate | undefined; + filter?: FilterPredicate | undefined; label?: string | undefined; title?: string | undefined; }; @@ -255,7 +255,7 @@ const _default: OverridableFrontendPlugin< inputs: {}; params: { useProps: () => Omit; - filter?: EntityPredicate | ((entity: Entity) => boolean); + filter?: FilterPredicate | ((entity: Entity) => boolean); }; }>; 'nav-item:techdocs': OverridableExtensionDefinition<{ diff --git a/plugins/user-settings-backend/package.json b/plugins/user-settings-backend/package.json index e1f91a0abe..912ed58577 100644 --- a/plugins/user-settings-backend/package.json +++ b/plugins/user-settings-backend/package.json @@ -48,7 +48,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@backstage/backend-defaults": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", diff --git a/yarn.lock b/yarn.lock index f428351f0f..9149a9e58d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3207,6 +3207,11 @@ __metadata: yn: "npm:^4.0.0" zod: "npm:^3.25.76" zod-to-json-schema: "npm:^3.25.1" + peerDependencies: + "@types/jest": "*" + peerDependenciesMeta: + "@types/jest": + optional: true languageName: unknown linkType: soft @@ -3218,7 +3223,9 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" + "@types/lodash": "npm:^4.14.151" cross-fetch: "npm:^4.0.0" + lodash: "npm:^4.17.21" msw: "npm:^1.0.0" uri-template: "npm:^2.0.0" languageName: unknown @@ -3388,6 +3395,7 @@ __metadata: p-queue: "npm:^6.6.2" pirates: "npm:^4.0.6" postcss: "npm:^8.1.0" + postcss-import: "npm:^16.1.0" process: "npm:^0.11.10" raw-loader: "npm:^4.0.2" react-dev-utils: "npm:^12.0.0-next.60" @@ -3409,7 +3417,7 @@ __metadata: ts-morph: "npm:^24.0.0" undici: "npm:^7.2.3" util: "npm:^0.12.3" - webpack: "npm:~5.103.0" + webpack: "npm:~5.104.0" webpack-dev-server: "npm:^5.0.0" yaml: "npm:^2.0.0" yargs: "npm:^16.2.0" @@ -3429,7 +3437,7 @@ __metadata: jsdom: ^27.1.0 mini-css-extract-plugin: ^2.4.2 terser-webpack-plugin: ^5.1.3 - webpack: ~5.103.0 + webpack: ~5.104.0 webpack-dev-server: ^5.0.0 peerDependenciesMeta: "@jest/environment-jsdom-abstract": @@ -3816,6 +3824,19 @@ __metadata: languageName: unknown linkType: soft +"@backstage/filter-predicates@workspace:^, @backstage/filter-predicates@workspace:packages/filter-predicates": + version: 0.0.0-use.local + resolution: "@backstage/filter-predicates@workspace:packages/filter-predicates" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/errors": "workspace:^" + "@backstage/types": "workspace:^" + zod: "npm:^3.25.76" + zod-validation-error: "npm:^4.0.2" + languageName: unknown + linkType: soft + "@backstage/frontend-app-api@workspace:^, @backstage/frontend-app-api@workspace:packages/frontend-app-api": version: 0.0.0-use.local resolution: "@backstage/frontend-app-api@workspace:packages/frontend-app-api" @@ -3952,26 +3973,38 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/plugin-app": "workspace:^" "@backstage/plugin-app-react": "workspace:^" + "@backstage/plugin-permission-common": "workspace:^" + "@backstage/plugin-permission-react": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" "@testing-library/jest-dom": "npm:^6.0.0" + "@types/jest": "npm:*" "@types/react": "npm:^18.0.0" + "@types/zen-observable": "npm:^0.8.0" + i18next: "npm:^22.4.15" + msw: "npm:^2.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" react-router-dom: "npm:^6.30.2" + zen-observable: "npm:^0.10.0" zod: "npm:^3.25.76" peerDependencies: "@testing-library/react": ^16.0.0 + "@types/jest": "*" "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 react-router-dom: ^6.30.2 peerDependenciesMeta: + "@types/jest": + optional: true "@types/react": optional: true languageName: unknown @@ -5394,6 +5427,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" + "@backstage/filter-predicates": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" "@backstage/frontend-test-utils": "workspace:^" "@backstage/integration-react": "workspace:^" @@ -5428,11 +5462,14 @@ __metadata: zen-observable: "npm:^0.10.0" zod: "npm:^3.25.76" peerDependencies: + "@backstage/frontend-test-utils": "workspace:^" "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 react-router-dom: ^6.30.2 peerDependenciesMeta: + "@backstage/frontend-test-utils": + optional: true "@types/react": optional: true languageName: unknown @@ -5634,7 +5671,7 @@ __metadata: "@backstage/test-utils": "workspace:^" "@material-ui/core": "npm:^4.9.13" "@testing-library/jest-dom": "npm:^6.0.0" - "@testing-library/react": "npm:^14.0.0" + "@testing-library/react": "npm:^16.0.0" "@types/react": "npm:^18.0.0" react: "npm:^18.0.2" react-dom: "npm:^18.0.2" @@ -5897,6 +5934,7 @@ __metadata: resolution: "@backstage/plugin-home-react@workspace:plugins/home-react" dependencies: "@backstage/cli": "workspace:^" + "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" @@ -5928,10 +5966,13 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/core-app-api": "workspace:^" + "@backstage/core-compat-api": "workspace:^" "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/dev-utils": "workspace:^" + "@backstage/frontend-defaults": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-home-react": "workspace:^" "@backstage/test-utils": "workspace:^" @@ -7036,6 +7077,7 @@ __metadata: "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/frontend-test-utils": "workspace:^" "@backstage/plugin-catalog": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" "@backstage/plugin-catalog-react": "workspace:^" @@ -7105,6 +7147,7 @@ __metadata: "@backstage/dev-utils": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/frontend-plugin-api": "workspace:^" + "@backstage/frontend-test-utils": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/integration-react": "workspace:^" "@backstage/plugin-catalog": "workspace:^" @@ -7961,11 +8004,14 @@ __metadata: zen-observable: "npm:^0.10.0" peerDependencies: "@testing-library/react": ^16.0.0 + "@types/jest": "*" "@types/react": ^17.0.0 || ^18.0.0 react: ^17.0.0 || ^18.0.0 react-dom: ^17.0.0 || ^18.0.0 react-router-dom: ^6.30.2 peerDependenciesMeta: + "@types/jest": + optional: true "@types/react": optional: true languageName: unknown @@ -8019,13 +8065,10 @@ __metadata: "@tanstack/react-table": "npm:^8.21.3" "@types/react": "npm:^18.0.0" "@types/react-dom": "npm:^18.0.0" - chalk: "npm:^5.4.1" clsx: "npm:^2.1.1" eslint-plugin-storybook: "npm:^10.3.0-alpha.1" glob: "npm:^11.0.1" globals: "npm:^15.11.0" - lightningcss: "npm:^1.29.1" - mini-css-extract-plugin: "npm:^2.9.2" react: "npm:^18.0.2" react-aria-components: "npm:^1.14.0" react-dom: "npm:^18.0.2" @@ -8844,184 +8887,184 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/aix-ppc64@npm:0.27.2" +"@esbuild/aix-ppc64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/aix-ppc64@npm:0.27.3" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/android-arm64@npm:0.27.2" +"@esbuild/android-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/android-arm64@npm:0.27.3" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@esbuild/android-arm@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/android-arm@npm:0.27.2" +"@esbuild/android-arm@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/android-arm@npm:0.27.3" conditions: os=android & cpu=arm languageName: node linkType: hard -"@esbuild/android-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/android-x64@npm:0.27.2" +"@esbuild/android-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/android-x64@npm:0.27.3" conditions: os=android & cpu=x64 languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/darwin-arm64@npm:0.27.2" +"@esbuild/darwin-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/darwin-arm64@npm:0.27.3" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/darwin-x64@npm:0.27.2" +"@esbuild/darwin-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/darwin-x64@npm:0.27.3" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/freebsd-arm64@npm:0.27.2" +"@esbuild/freebsd-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/freebsd-arm64@npm:0.27.3" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/freebsd-x64@npm:0.27.2" +"@esbuild/freebsd-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/freebsd-x64@npm:0.27.3" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-arm64@npm:0.27.2" +"@esbuild/linux-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-arm64@npm:0.27.3" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-arm@npm:0.27.2" +"@esbuild/linux-arm@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-arm@npm:0.27.3" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-ia32@npm:0.27.2" +"@esbuild/linux-ia32@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-ia32@npm:0.27.3" conditions: os=linux & cpu=ia32 languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-loong64@npm:0.27.2" +"@esbuild/linux-loong64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-loong64@npm:0.27.3" conditions: os=linux & cpu=loong64 languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-mips64el@npm:0.27.2" +"@esbuild/linux-mips64el@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-mips64el@npm:0.27.3" conditions: os=linux & cpu=mips64el languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-ppc64@npm:0.27.2" +"@esbuild/linux-ppc64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-ppc64@npm:0.27.3" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-riscv64@npm:0.27.2" +"@esbuild/linux-riscv64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-riscv64@npm:0.27.3" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-s390x@npm:0.27.2" +"@esbuild/linux-s390x@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-s390x@npm:0.27.3" conditions: os=linux & cpu=s390x languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/linux-x64@npm:0.27.2" +"@esbuild/linux-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/linux-x64@npm:0.27.3" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/netbsd-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/netbsd-arm64@npm:0.27.2" +"@esbuild/netbsd-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/netbsd-arm64@npm:0.27.3" conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/netbsd-x64@npm:0.27.2" +"@esbuild/netbsd-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/netbsd-x64@npm:0.27.3" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/openbsd-arm64@npm:0.27.2" +"@esbuild/openbsd-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/openbsd-arm64@npm:0.27.3" conditions: os=openbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/openbsd-x64@npm:0.27.2" +"@esbuild/openbsd-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/openbsd-x64@npm:0.27.3" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openharmony-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/openharmony-arm64@npm:0.27.2" +"@esbuild/openharmony-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/openharmony-arm64@npm:0.27.3" conditions: os=openharmony & cpu=arm64 languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/sunos-x64@npm:0.27.2" +"@esbuild/sunos-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/sunos-x64@npm:0.27.3" conditions: os=sunos & cpu=x64 languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/win32-arm64@npm:0.27.2" +"@esbuild/win32-arm64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/win32-arm64@npm:0.27.3" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/win32-ia32@npm:0.27.2" +"@esbuild/win32-ia32@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/win32-ia32@npm:0.27.3" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.27.2": - version: 0.27.2 - resolution: "@esbuild/win32-x64@npm:0.27.2" +"@esbuild/win32-x64@npm:0.27.3": + version: 0.27.3 + resolution: "@esbuild/win32-x64@npm:0.27.3" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -9068,7 +9111,7 @@ __metadata: languageName: node linkType: hard -"@exodus/bytes@npm:^1.11.0, @exodus/bytes@npm:^1.6.0": +"@exodus/bytes@npm:^1.6.0": version: 1.11.0 resolution: "@exodus/bytes@npm:1.11.0" peerDependencies: @@ -17444,92 +17487,92 @@ __metadata: languageName: node linkType: hard -"@rspack/binding-darwin-arm64@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-darwin-arm64@npm:1.7.4" +"@rspack/binding-darwin-arm64@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-darwin-arm64@npm:1.7.5" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@rspack/binding-darwin-x64@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-darwin-x64@npm:1.7.4" +"@rspack/binding-darwin-x64@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-darwin-x64@npm:1.7.5" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@rspack/binding-linux-arm64-gnu@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-linux-arm64-gnu@npm:1.7.4" +"@rspack/binding-linux-arm64-gnu@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-linux-arm64-gnu@npm:1.7.5" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@rspack/binding-linux-arm64-musl@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-linux-arm64-musl@npm:1.7.4" +"@rspack/binding-linux-arm64-musl@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-linux-arm64-musl@npm:1.7.5" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@rspack/binding-linux-x64-gnu@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-linux-x64-gnu@npm:1.7.4" +"@rspack/binding-linux-x64-gnu@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-linux-x64-gnu@npm:1.7.5" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@rspack/binding-linux-x64-musl@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-linux-x64-musl@npm:1.7.4" +"@rspack/binding-linux-x64-musl@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-linux-x64-musl@npm:1.7.5" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@rspack/binding-wasm32-wasi@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-wasm32-wasi@npm:1.7.4" +"@rspack/binding-wasm32-wasi@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-wasm32-wasi@npm:1.7.5" dependencies: "@napi-rs/wasm-runtime": "npm:1.0.7" conditions: cpu=wasm32 languageName: node linkType: hard -"@rspack/binding-win32-arm64-msvc@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-win32-arm64-msvc@npm:1.7.4" +"@rspack/binding-win32-arm64-msvc@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-win32-arm64-msvc@npm:1.7.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@rspack/binding-win32-ia32-msvc@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-win32-ia32-msvc@npm:1.7.4" +"@rspack/binding-win32-ia32-msvc@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-win32-ia32-msvc@npm:1.7.5" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@rspack/binding-win32-x64-msvc@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding-win32-x64-msvc@npm:1.7.4" +"@rspack/binding-win32-x64-msvc@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding-win32-x64-msvc@npm:1.7.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@rspack/binding@npm:1.7.4": - version: 1.7.4 - resolution: "@rspack/binding@npm:1.7.4" +"@rspack/binding@npm:1.7.5": + version: 1.7.5 + resolution: "@rspack/binding@npm:1.7.5" dependencies: - "@rspack/binding-darwin-arm64": "npm:1.7.4" - "@rspack/binding-darwin-x64": "npm:1.7.4" - "@rspack/binding-linux-arm64-gnu": "npm:1.7.4" - "@rspack/binding-linux-arm64-musl": "npm:1.7.4" - "@rspack/binding-linux-x64-gnu": "npm:1.7.4" - "@rspack/binding-linux-x64-musl": "npm:1.7.4" - "@rspack/binding-wasm32-wasi": "npm:1.7.4" - "@rspack/binding-win32-arm64-msvc": "npm:1.7.4" - "@rspack/binding-win32-ia32-msvc": "npm:1.7.4" - "@rspack/binding-win32-x64-msvc": "npm:1.7.4" + "@rspack/binding-darwin-arm64": "npm:1.7.5" + "@rspack/binding-darwin-x64": "npm:1.7.5" + "@rspack/binding-linux-arm64-gnu": "npm:1.7.5" + "@rspack/binding-linux-arm64-musl": "npm:1.7.5" + "@rspack/binding-linux-x64-gnu": "npm:1.7.5" + "@rspack/binding-linux-x64-musl": "npm:1.7.5" + "@rspack/binding-wasm32-wasi": "npm:1.7.5" + "@rspack/binding-win32-arm64-msvc": "npm:1.7.5" + "@rspack/binding-win32-ia32-msvc": "npm:1.7.5" + "@rspack/binding-win32-x64-msvc": "npm:1.7.5" dependenciesMeta: "@rspack/binding-darwin-arm64": optional: true @@ -17551,23 +17594,23 @@ __metadata: optional: true "@rspack/binding-win32-x64-msvc": optional: true - checksum: 10/0d082e962980ace986c546ac359e7d4b5fada21ce38980db792c70ef97257841257b64a0b65c5d3c2e2660d58ab05b1d7302defc752f325c616507f5837fb98d + checksum: 10/3e66805d4dae5f2051f10c5a9126e5bf25926d2a6ccb1c794af2aa49c15e4fdcb9e362bd015a9afef1e788a3272dfe7a28a3c866713badda34579896d736ed4f languageName: node linkType: hard "@rspack/core@npm:^1.4.11": - version: 1.7.4 - resolution: "@rspack/core@npm:1.7.4" + version: 1.7.5 + resolution: "@rspack/core@npm:1.7.5" dependencies: "@module-federation/runtime-tools": "npm:0.22.0" - "@rspack/binding": "npm:1.7.4" + "@rspack/binding": "npm:1.7.5" "@rspack/lite-tapable": "npm:1.1.0" peerDependencies: "@swc/helpers": ">=0.5.1" peerDependenciesMeta: "@swc/helpers": optional: true - checksum: 10/ecce39e64993ba9f5792c737b6d61960946c59399254f5903651a53b6e61761f8bc2a20dfdf81b1b819e1b0f1e26257456d902244485fd035e5a585b5a28a623 + checksum: 10/c17d93ef1e7e0728b74bf527150642d9bf072cabb63964ebd32c82da94d6d2f9eac7ff2cc13031bbd376c0c03710899f549e61d2bcfc0a6638a9f3bc8620b7ce languageName: node linkType: hard @@ -20584,7 +20627,7 @@ __metadata: "@types/serve-handler": "npm:^6.1.0" "@types/webpack-env": "npm:^1.15.3" commander: "npm:^12.0.0" - find-process: "npm:^1.4.5" + find-process: "npm:^2.0.0" fs-extra: "npm:^11.0.0" global-agent: "npm:^3.0.0" http-proxy: "npm:^1.18.1" @@ -20614,22 +20657,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/dom@npm:^9.0.0": - version: 9.3.4 - resolution: "@testing-library/dom@npm:9.3.4" - dependencies: - "@babel/code-frame": "npm:^7.10.4" - "@babel/runtime": "npm:^7.12.5" - "@types/aria-query": "npm:^5.0.1" - aria-query: "npm:5.1.3" - chalk: "npm:^4.1.0" - dom-accessibility-api: "npm:^0.5.9" - lz-string: "npm:^1.5.0" - pretty-format: "npm:^27.0.2" - checksum: 10/510da752ea76f4a10a0a4e3a77917b0302cf03effe576cd3534cab7e796533ee2b0e9fb6fb11b911a1ebd7c70a0bb6f235bf4f816c9b82b95b8fe0cddfd10975 - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:^6.0.0, @testing-library/jest-dom@npm:^6.6.3": version: 6.9.1 resolution: "@testing-library/jest-dom@npm:6.9.1" @@ -20666,20 +20693,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^14.0.0": - version: 14.3.1 - resolution: "@testing-library/react@npm:14.3.1" - dependencies: - "@babel/runtime": "npm:^7.12.5" - "@testing-library/dom": "npm:^9.0.0" - "@types/react-dom": "npm:^18.0.0" - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - checksum: 10/83359dcdf9eaf067839f34604e1a181cbc14fc09f3a07672403700fcc6a900c4b8054ad1114fc24b4b9f89d84e2a09e1b7c9afce2306b1d4b4c9e30eb1cb12de - languageName: node - linkType: hard - "@testing-library/react@npm:^16.0.0": version: 16.3.2 resolution: "@testing-library/react@npm:16.3.2" @@ -25077,15 +25090,6 @@ __metadata: languageName: node linkType: hard -"aria-query@npm:5.1.3": - version: 5.1.3 - resolution: "aria-query@npm:5.1.3" - dependencies: - deep-equal: "npm:^2.0.5" - checksum: 10/e5da608a7c4954bfece2d879342b6c218b6b207e2d9e5af270b5e38ef8418f02d122afdc948b68e32649b849a38377785252059090d66fa8081da95d1609c0d2 - languageName: node - linkType: hard - "aria-query@npm:5.3.0": version: 5.3.0 resolution: "aria-query@npm:5.3.0" @@ -25102,7 +25106,7 @@ __metadata: languageName: node linkType: hard -"array-buffer-byte-length@npm:^1.0.0, array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": +"array-buffer-byte-length@npm:^1.0.1, array-buffer-byte-length@npm:^1.0.2": version: 1.0.2 resolution: "array-buffer-byte-length@npm:1.0.2" dependencies: @@ -25604,13 +25608,13 @@ __metadata: linkType: hard "axios@npm:^1.0.0, axios@npm:^1.11.0, axios@npm:^1.12.2, axios@npm:^1.13.0, axios@npm:^1.7.4": - version: 1.13.4 - resolution: "axios@npm:1.13.4" + version: 1.13.5 + resolution: "axios@npm:1.13.5" dependencies: - follow-redirects: "npm:^1.15.6" - form-data: "npm:^4.0.4" + follow-redirects: "npm:^1.15.11" + form-data: "npm:^4.0.5" proxy-from-env: "npm:^1.1.0" - checksum: 10/54b7ef71c64837f9d52475832337f520cf6fa85c94612e03a3a2aad7082804a2544741267122696662147e90e6d2746601346984cf531ae715ecdb56d586a04c + checksum: 10/db726d09902565ef9a0632893530028310e2ec2b95b727114eca1b101450b00014133dfc3871cffc87983fb922bca7e4874d7e2826d1550a377a157cdf3f05b6 languageName: node linkType: hard @@ -25896,12 +25900,12 @@ __metadata: languageName: node linkType: hard -"baseline-browser-mapping@npm:^2.8.25": - version: 2.9.8 - resolution: "baseline-browser-mapping@npm:2.9.8" +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.19 + resolution: "baseline-browser-mapping@npm:2.9.19" bin: baseline-browser-mapping: dist/cli.js - checksum: 10/1c9cf2fb0cc008ba86d5f1bd1f1a771d6f508dbd6124d173549c650734d6d305eba49622e8bc0160beea9ecafcd2f5f104ffdc92f4a815a4926b74a2d7a6d986 + checksum: 10/8d7bbb7fe3d1ad50e04b127c819ba6d059c01ed0d2a7a5fc3327e23a8c42855fa3a8b510550c1fe1e37916147e6a390243566d3ef85bf6130c8ddfe5cc3db530 languageName: node linkType: hard @@ -26324,18 +26328,18 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.0.0, browserslist@npm:^4.16.0, browserslist@npm:^4.16.6, browserslist@npm:^4.18.1, browserslist@npm:^4.24.0, browserslist@npm:^4.26.3": - version: 4.28.0 - resolution: "browserslist@npm:4.28.0" +"browserslist@npm:^4.0.0, browserslist@npm:^4.16.0, browserslist@npm:^4.16.6, browserslist@npm:^4.18.1, browserslist@npm:^4.24.0, browserslist@npm:^4.28.1": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" dependencies: - baseline-browser-mapping: "npm:^2.8.25" - caniuse-lite: "npm:^1.0.30001754" - electron-to-chromium: "npm:^1.5.249" + baseline-browser-mapping: "npm:^2.9.0" + caniuse-lite: "npm:^1.0.30001759" + electron-to-chromium: "npm:^1.5.263" node-releases: "npm:^2.0.27" - update-browserslist-db: "npm:^1.1.4" + update-browserslist-db: "npm:^1.2.0" bin: browserslist: cli.js - checksum: 10/59dc88f8d950e44a064361cb874f486e532a8ba932e0cf549aee8b36dd2b791da2bc11f36c1cf820ebb9c1f3250b100f8c56364dd6e86dbc90495af424100e19 + checksum: 10/64f2a97de4bce8473c0e5ae0af8d76d1ead07a5b05fc6bc87b848678bb9c3a91ae787b27aa98cdd33fc00779607e6c156000bed58fefb9cf8e4c5a183b994cdb languageName: node linkType: hard @@ -26762,10 +26766,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001754": - version: 1.0.30001760 - resolution: "caniuse-lite@npm:1.0.30001760" - checksum: 10/ac5c13d00b946c3ace331d25379ed9d85743b1028aba79ae80402cb128b4b491122585144898fefb836d4d9879c13c717a081ae241a00420cbbc7e1b934ec685 +"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001768 + resolution: "caniuse-lite@npm:1.0.30001768" + checksum: 10/13565325a9a1a9d172759eb4bef651ed213c9d359c654d56abe70255c665bc1fc01a3ca72950ab56a4bcc685785c358759c473fef3f40c8d5a3c08b0464316a1 languageName: node linkType: hard @@ -28907,32 +28911,6 @@ __metadata: languageName: node linkType: hard -"deep-equal@npm:^2.0.5": - version: 2.2.3 - resolution: "deep-equal@npm:2.2.3" - dependencies: - array-buffer-byte-length: "npm:^1.0.0" - call-bind: "npm:^1.0.5" - es-get-iterator: "npm:^1.1.3" - get-intrinsic: "npm:^1.2.2" - is-arguments: "npm:^1.1.1" - is-array-buffer: "npm:^3.0.2" - is-date-object: "npm:^1.0.5" - is-regex: "npm:^1.1.4" - is-shared-array-buffer: "npm:^1.0.2" - isarray: "npm:^2.0.5" - object-is: "npm:^1.1.5" - object-keys: "npm:^1.1.1" - object.assign: "npm:^4.1.4" - regexp.prototype.flags: "npm:^1.5.1" - side-channel: "npm:^1.0.4" - which-boxed-primitive: "npm:^1.0.2" - which-collection: "npm:^1.0.1" - which-typed-array: "npm:^1.1.13" - checksum: 10/1ce49d0b71d0f14d8ef991a742665eccd488dfc9b3cada069d4d7a86291e591c92d2589c832811dea182b4015736b210acaaebce6184be356c1060d176f5a05f - languageName: node - linkType: hard - "deep-equal@npm:~1.0.1": version: 1.0.1 resolution: "deep-equal@npm:1.0.1" @@ -28947,7 +28925,7 @@ __metadata: languageName: node linkType: hard -"deep-is@npm:^0.1.3, deep-is@npm:~0.1.3": +"deep-is@npm:^0.1.3": version: 0.1.3 resolution: "deep-is@npm:0.1.3" checksum: 10/dee1094e987a784a9a9c8549fc65eeca3422aef3bf2f9579f76c126085f280311d09273826c2f430d84fd09d64f6a578e5e7a4ac6ba1d50ea6cff0ddf605c025 @@ -29194,7 +29172,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.3": +"detect-libc@npm:^2.0.0": version: 2.0.3 resolution: "detect-libc@npm:2.0.3" checksum: 10/b4ea018d623e077bd395f168a9e81db77370dde36a5b01d067f2ad7989924a81d31cb547ff764acb2aa25d50bb7fdde0b0a93bec02212b0cb430621623246d39 @@ -29815,10 +29793,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.249": - version: 1.5.263 - resolution: "electron-to-chromium@npm:1.5.263" - checksum: 10/c74c6fedc38a0a5d823f4ed168efb6961972a30df443caa44265c85231087936673cdfbccfa0c14ba6f1a0fd6c634ef43b046fbdd3cdfce6c73523408d86caee +"electron-to-chromium@npm:^1.5.263": + version: 1.5.286 + resolution: "electron-to-chromium@npm:1.5.286" + checksum: 10/530ae36571f3f737431dc1f97ab176d9ec38d78e7a14a78fff78540769ef139e9011200a886864111ee26d64e647136531ff004f368f5df8cdd755c45ad97649 languageName: node linkType: hard @@ -29936,13 +29914,13 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:^5.17.3, enhanced-resolve@npm:^5.18.0": - version: 5.18.3 - resolution: "enhanced-resolve@npm:5.18.3" +"enhanced-resolve@npm:^5.17.4, enhanced-resolve@npm:^5.18.0": + version: 5.19.0 + resolution: "enhanced-resolve@npm:5.19.0" dependencies: graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.2.0" - checksum: 10/a4d0a1eacba3079f617b68c8f7e17583c3cbc572055c2edca41c0fa0230a49f6e9b2c6ffd4128cc5f84e15ea6cc313ae2b01e1057fcd252fabef70220a5d9f6a + tapable: "npm:^2.3.0" + checksum: 10/b537d52173bf1ba903c623f96a43ea3b51466ee2b606b2fcca30d73d453fd79c8683dccbb83523de27cd02763c906f11486e2591a4335e6afe49fa5ad6e67b83 languageName: node linkType: hard @@ -30128,7 +30106,7 @@ __metadata: languageName: node linkType: hard -"es-get-iterator@npm:^1.0.2, es-get-iterator@npm:^1.1.3": +"es-get-iterator@npm:^1.0.2": version: 1.1.3 resolution: "es-get-iterator@npm:1.1.3" dependencies: @@ -30169,13 +30147,20 @@ __metadata: languageName: node linkType: hard -"es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.6.0": +"es-module-lexer@npm:^1.6.0": version: 1.7.0 resolution: "es-module-lexer@npm:1.7.0" checksum: 10/b6f3e576a3fed4d82b0d0ad4bbf6b3a5ad694d2e7ce8c4a069560da3db6399381eaba703616a182b16dde50ce998af64e07dcf49f2ae48153b9e07be3f107087 languageName: node linkType: hard +"es-module-lexer@npm:^2.0.0": + version: 2.0.0 + resolution: "es-module-lexer@npm:2.0.0" + checksum: 10/b075855289b5f40ee496f3d7525c5c501d029c3da15c22298a0030d625bf36d1da0768b26278f7f4bada2a602459b505888e20b77c414fba5da5619b0e84dbd1 + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -30284,35 +30269,35 @@ __metadata: linkType: hard "esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0, esbuild@npm:^0.27.0, esbuild@npm:^0.27.1": - version: 0.27.2 - resolution: "esbuild@npm:0.27.2" + version: 0.27.3 + resolution: "esbuild@npm:0.27.3" dependencies: - "@esbuild/aix-ppc64": "npm:0.27.2" - "@esbuild/android-arm": "npm:0.27.2" - "@esbuild/android-arm64": "npm:0.27.2" - "@esbuild/android-x64": "npm:0.27.2" - "@esbuild/darwin-arm64": "npm:0.27.2" - "@esbuild/darwin-x64": "npm:0.27.2" - "@esbuild/freebsd-arm64": "npm:0.27.2" - "@esbuild/freebsd-x64": "npm:0.27.2" - "@esbuild/linux-arm": "npm:0.27.2" - "@esbuild/linux-arm64": "npm:0.27.2" - "@esbuild/linux-ia32": "npm:0.27.2" - "@esbuild/linux-loong64": "npm:0.27.2" - "@esbuild/linux-mips64el": "npm:0.27.2" - "@esbuild/linux-ppc64": "npm:0.27.2" - "@esbuild/linux-riscv64": "npm:0.27.2" - "@esbuild/linux-s390x": "npm:0.27.2" - "@esbuild/linux-x64": "npm:0.27.2" - "@esbuild/netbsd-arm64": "npm:0.27.2" - "@esbuild/netbsd-x64": "npm:0.27.2" - "@esbuild/openbsd-arm64": "npm:0.27.2" - "@esbuild/openbsd-x64": "npm:0.27.2" - "@esbuild/openharmony-arm64": "npm:0.27.2" - "@esbuild/sunos-x64": "npm:0.27.2" - "@esbuild/win32-arm64": "npm:0.27.2" - "@esbuild/win32-ia32": "npm:0.27.2" - "@esbuild/win32-x64": "npm:0.27.2" + "@esbuild/aix-ppc64": "npm:0.27.3" + "@esbuild/android-arm": "npm:0.27.3" + "@esbuild/android-arm64": "npm:0.27.3" + "@esbuild/android-x64": "npm:0.27.3" + "@esbuild/darwin-arm64": "npm:0.27.3" + "@esbuild/darwin-x64": "npm:0.27.3" + "@esbuild/freebsd-arm64": "npm:0.27.3" + "@esbuild/freebsd-x64": "npm:0.27.3" + "@esbuild/linux-arm": "npm:0.27.3" + "@esbuild/linux-arm64": "npm:0.27.3" + "@esbuild/linux-ia32": "npm:0.27.3" + "@esbuild/linux-loong64": "npm:0.27.3" + "@esbuild/linux-mips64el": "npm:0.27.3" + "@esbuild/linux-ppc64": "npm:0.27.3" + "@esbuild/linux-riscv64": "npm:0.27.3" + "@esbuild/linux-s390x": "npm:0.27.3" + "@esbuild/linux-x64": "npm:0.27.3" + "@esbuild/netbsd-arm64": "npm:0.27.3" + "@esbuild/netbsd-x64": "npm:0.27.3" + "@esbuild/openbsd-arm64": "npm:0.27.3" + "@esbuild/openbsd-x64": "npm:0.27.3" + "@esbuild/openharmony-arm64": "npm:0.27.3" + "@esbuild/sunos-x64": "npm:0.27.3" + "@esbuild/win32-arm64": "npm:0.27.3" + "@esbuild/win32-ia32": "npm:0.27.3" + "@esbuild/win32-x64": "npm:0.27.3" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -30368,7 +30353,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10/7f1229328b0efc63c4184a61a7eb303df1e99818cc1d9e309fb92600703008e69821e8e984e9e9f54a627da14e0960d561db3a93029482ef96dc82dd267a60c2 + checksum: 10/aa74b8d8a3ed8e2eea4d8421737b322f4d21215244e8fa2156c6402d49b5bda01343c220196f1e3f830a7ce92b54ef653c6c723a8cc2e912bb4d17b7398b51ae languageName: node linkType: hard @@ -30430,25 +30415,6 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^1.8.1": - version: 1.14.3 - resolution: "escodegen@npm:1.14.3" - dependencies: - esprima: "npm:^4.0.1" - estraverse: "npm:^4.2.0" - esutils: "npm:^2.0.2" - optionator: "npm:^0.8.1" - source-map: "npm:~0.6.1" - dependenciesMeta: - source-map: - optional: true - bin: - escodegen: bin/escodegen.js - esgenerate: bin/esgenerate.js - checksum: 10/70f095ca9393535f9f1c145ef99dc0b3ff14cca6bc4a79d90ff3352f90c3f2e07f75af6d6c05174ea67c45271f75e80dd440dd7d04ed2cf44c9452c3042fa84a - languageName: node - linkType: hard - "escodegen@npm:^2.1.0": version: 2.1.0 resolution: "escodegen@npm:2.1.0" @@ -30847,13 +30813,13 @@ __metadata: languageName: node linkType: hard -"esprima@npm:1.2.2": - version: 1.2.2 - resolution: "esprima@npm:1.2.2" +"esprima@npm:1.2.5": + version: 1.2.5 + resolution: "esprima@npm:1.2.5" bin: esparse: ./bin/esparse.js esvalidate: ./bin/esvalidate.js - checksum: 10/7ed9274abb1fed861354239f0da8fa3ec6592617ad0fd2aab16b0beb0425137c46f05c82faa0de89b3bb3d704054815c2657658c7e221b1fb550e88c237eefd2 + checksum: 10/839aad5916d05d3a82ccf3adaf67c2b5df69278fd7168347346e7af298dc7fbfbfd7bc5e27e38031a584d50d28e37da35d711b2f5d5376794f84b1bd8e559665 languageName: node linkType: hard @@ -30885,7 +30851,7 @@ __metadata: languageName: node linkType: hard -"estraverse@npm:^4.1.1, estraverse@npm:^4.2.0": +"estraverse@npm:^4.1.1": version: 4.3.0 resolution: "estraverse@npm:4.3.0" checksum: 10/3f67ad02b6dbfaddd9ea459cf2b6ef4ecff9a6082a7af9d22e445b9abc082ad9ca47e1825557b293fcdae477f4714e561123e30bb6a5b2f184fb2bad4a9497eb @@ -31056,6 +31022,7 @@ __metadata: "@backstage/plugin-catalog-unprocessed-entities": "workspace:^" "@backstage/plugin-devtools": "workspace:^" "@backstage/plugin-home": "workspace:^" + "@backstage/plugin-home-react": "workspace:^" "@backstage/plugin-kubernetes": "workspace:^" "@backstage/plugin-kubernetes-cluster": "workspace:^" "@backstage/plugin-notifications": "workspace:^" @@ -31570,7 +31537,7 @@ __metadata: languageName: node linkType: hard -"fast-levenshtein@npm:^2.0.6, fast-levenshtein@npm:~2.0.6": +"fast-levenshtein@npm:^2.0.6": version: 2.0.6 resolution: "fast-levenshtein@npm:2.0.6" checksum: 10/eb7e220ecf2bab5159d157350b81d01f75726a4382f5a9266f42b9150c4523b9795f7f5d9fbbbeaeac09a441b2369f05ee02db48ea938584205530fe5693cfe1 @@ -31968,16 +31935,16 @@ __metadata: languageName: node linkType: hard -"find-process@npm:^1.4.5": - version: 1.4.11 - resolution: "find-process@npm:1.4.11" +"find-process@npm:^2.0.0": + version: 2.0.0 + resolution: "find-process@npm:2.0.0" dependencies: chalk: "npm:~4.1.2" commander: "npm:^12.1.0" loglevel: "npm:^1.9.2" bin: - find-process: bin/find-process.js - checksum: 10/95f7106877c86fc09804b29339cfac518a43228c1b5f4a4e9442e57ac5538369fbeafb746fa96390ecd7be6f5a9119b58c952b5c96a0cb9f2490ef19298dec1e + find-process: dist/bin/find-process.js + checksum: 10/a8f23b72e7b67c8f039c2234116f68439c067e8d0f90484b53814aac04f1891cf322c7cf4b4193a14b91a19ba9c68bd8bcfb8a556626e6667dcaaefbca3294f9 languageName: node linkType: hard @@ -32093,13 +32060,13 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.6": - version: 1.15.6 - resolution: "follow-redirects@npm:1.15.6" +"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.11, follow-redirects@npm:^1.15.6": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" peerDependenciesMeta: debug: optional: true - checksum: 10/70c7612c4cab18e546e36b991bbf8009a1a41cf85354afe04b113d1117569abf760269409cb3eb842d9f7b03d62826687086b081c566ea7b1e6613cf29030bf7 + checksum: 10/07372fd74b98c78cf4d417d68d41fdaa0be4dcacafffb9e67b1e3cf090bc4771515e65020651528faab238f10f9b9c0d9707d6c1574a6c0387c5de1042cde9ba languageName: node linkType: hard @@ -32670,7 +32637,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.2, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.1, get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -34791,7 +34758,7 @@ __metadata: languageName: node linkType: hard -"is-array-buffer@npm:^3.0.2, is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": +"is-array-buffer@npm:^3.0.4, is-array-buffer@npm:^3.0.5": version: 3.0.5 resolution: "is-array-buffer@npm:3.0.5" dependencies: @@ -34880,7 +34847,7 @@ __metadata: languageName: node linkType: hard -"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.0, is-core-module@npm:^2.16.1": +"is-core-module@npm:^2.13.0, is-core-module@npm:^2.16.1": version: 2.16.1 resolution: "is-core-module@npm:2.16.1" dependencies: @@ -35264,7 +35231,7 @@ __metadata: languageName: node linkType: hard -"is-regex@npm:^1.1.4, is-regex@npm:^1.2.1": +"is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" dependencies: @@ -35313,7 +35280,7 @@ __metadata: languageName: node linkType: hard -"is-shared-array-buffer@npm:^1.0.2, is-shared-array-buffer@npm:^1.0.4": +"is-shared-array-buffer@npm:^1.0.4": version: 1.0.4 resolution: "is-shared-array-buffer@npm:1.0.4" dependencies: @@ -36808,13 +36775,13 @@ __metadata: linkType: hard "jsonpath@npm:^1.1.1": - version: 1.1.1 - resolution: "jsonpath@npm:1.1.1" + version: 1.2.0 + resolution: "jsonpath@npm:1.2.0" dependencies: - esprima: "npm:1.2.2" - static-eval: "npm:2.0.2" - underscore: "npm:1.12.1" - checksum: 10/aa6c2fea9c05eeba4a37870cbbcf30c20de5211d0fd967786b6c59b8546c9f80182328ee2428daf989c8d5c6e6bf97fed28eefc790144258b1238707c30706eb + esprima: "npm:1.2.5" + static-eval: "npm:2.1.1" + underscore: "npm:1.13.6" + checksum: 10/ea784a40f0f33c1562be4c3c87e9159e34af63790e4b4f8f5637712c77d9b944f77bca7f5309bd4a0a4076bb98c15224b5d90edbea8357f347553087336f3d1b languageName: node linkType: hard @@ -37145,8 +37112,8 @@ __metadata: linkType: hard "knip@npm:^5.42.0": - version: 5.83.0 - resolution: "knip@npm:5.83.0" + version: 5.83.1 + resolution: "knip@npm:5.83.1" dependencies: "@nodelib/fs.walk": "npm:^1.2.3" fast-glob: "npm:^3.3.3" @@ -37166,7 +37133,7 @@ __metadata: bin: knip: bin/knip.js knip-bun: bin/knip-bun.js - checksum: 10/07ed1308ac2dc9b2c4325430d54c19681fb4e609049db251b848d32ce5ea316c6001622e5711cf252d0c8a27ab3224af1e3b0cb2406b356d06cfdcd2630a5009 + checksum: 10/d4783b22eacbcb726790211cb6e7cfb966cbb42c4737ae71fbe766523add2e9b8c7eb389a14f9418742d7722f26b107b036c6f0e1af4b56d86a7097f62172a54 languageName: node linkType: hard @@ -37282,12 +37249,11 @@ __metadata: linkType: hard "ldapts@npm:^8.0.6": - version: 8.1.5 - resolution: "ldapts@npm:8.1.5" + version: 8.1.6 + resolution: "ldapts@npm:8.1.6" dependencies: strict-event-emitter-types: "npm:2.0.0" - whatwg-url: "npm:16.0.0" - checksum: 10/82805610977948f59f5def1daf7953a69aa0c776b443fbb7bab97dab3d801276f4f859594263a903db064b309a1811c6adfdefdb843e455cdce76da5c66260aa + checksum: 10/3ebcdf335e1477d920cdea562fdf1f91218cb4bed6de7077a837a33ec93f3a8e1659e346c28335bd0be0144505315f9445c87acae857b8af385939f0ac78a60e languageName: node linkType: hard @@ -37315,16 +37281,6 @@ __metadata: languageName: node linkType: hard -"levn@npm:~0.3.0": - version: 0.3.0 - resolution: "levn@npm:0.3.0" - dependencies: - prelude-ls: "npm:~1.1.2" - type-check: "npm:~0.3.2" - checksum: 10/e1c3e75b5c430d9aa4c32c83c8a611e4ca53608ca78e3ea3bf6bbd9d017e4776d05d86e27df7901baebd3afa732abede9f26f715b8c1be19e95505c7a3a7b589 - languageName: node - linkType: hard - "libsodium-wrappers@npm:^0.7.11": version: 0.7.15 resolution: "libsodium-wrappers@npm:0.7.15" @@ -37359,126 +37315,6 @@ __metadata: languageName: node linkType: hard -"lightningcss-android-arm64@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-android-arm64@npm:1.30.2" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-arm64@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-darwin-arm64@npm:1.30.2" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-darwin-x64@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-darwin-x64@npm:1.30.2" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-freebsd-x64@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-freebsd-x64@npm:1.30.2" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"lightningcss-linux-arm-gnueabihf@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.30.2" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"lightningcss-linux-arm64-gnu@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-linux-arm64-gnu@npm:1.30.2" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-arm64-musl@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-linux-arm64-musl@npm:1.30.2" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-linux-x64-gnu@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-linux-x64-gnu@npm:1.30.2" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"lightningcss-linux-x64-musl@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-linux-x64-musl@npm:1.30.2" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"lightningcss-win32-arm64-msvc@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-win32-arm64-msvc@npm:1.30.2" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"lightningcss-win32-x64-msvc@npm:1.30.2": - version: 1.30.2 - resolution: "lightningcss-win32-x64-msvc@npm:1.30.2" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"lightningcss@npm:^1.29.1": - version: 1.30.2 - resolution: "lightningcss@npm:1.30.2" - dependencies: - detect-libc: "npm:^2.0.3" - lightningcss-android-arm64: "npm:1.30.2" - lightningcss-darwin-arm64: "npm:1.30.2" - lightningcss-darwin-x64: "npm:1.30.2" - lightningcss-freebsd-x64: "npm:1.30.2" - lightningcss-linux-arm-gnueabihf: "npm:1.30.2" - lightningcss-linux-arm64-gnu: "npm:1.30.2" - lightningcss-linux-arm64-musl: "npm:1.30.2" - lightningcss-linux-x64-gnu: "npm:1.30.2" - lightningcss-linux-x64-musl: "npm:1.30.2" - lightningcss-win32-arm64-msvc: "npm:1.30.2" - lightningcss-win32-x64-msvc: "npm:1.30.2" - dependenciesMeta: - lightningcss-android-arm64: - optional: true - lightningcss-darwin-arm64: - optional: true - lightningcss-darwin-x64: - optional: true - lightningcss-freebsd-x64: - optional: true - lightningcss-linux-arm-gnueabihf: - optional: true - lightningcss-linux-arm64-gnu: - optional: true - lightningcss-linux-arm64-musl: - optional: true - lightningcss-linux-x64-gnu: - optional: true - lightningcss-linux-x64-musl: - optional: true - lightningcss-win32-arm64-msvc: - optional: true - lightningcss-win32-x64-msvc: - optional: true - checksum: 10/d6cc06d9bac295589a49446e9c45a241dfa16f4f81a7318c26cbc0be3e189003ec0da5d9a0fd9bdffc63a3ce05878cc7329277eaac77a826e8b68c73dc96cfda - languageName: node - linkType: hard - "lilconfig@npm:^2.0.3": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -39374,7 +39210,7 @@ __metadata: languageName: node linkType: hard -"mini-css-extract-plugin@npm:^2.4.2, mini-css-extract-plugin@npm:^2.9.2": +"mini-css-extract-plugin@npm:^2.4.2": version: 2.10.0 resolution: "mini-css-extract-plugin@npm:2.10.0" dependencies: @@ -39996,8 +39832,8 @@ __metadata: linkType: hard "mysql2@npm:^3.0.0": - version: 3.16.2 - resolution: "mysql2@npm:3.16.2" + version: 3.16.3 + resolution: "mysql2@npm:3.16.3" dependencies: aws-ssl-profiles: "npm:^1.1.2" denque: "npm:^2.1.0" @@ -40008,7 +39844,7 @@ __metadata: named-placeholders: "npm:^1.1.6" seq-queue: "npm:^0.0.5" sqlstring: "npm:^2.3.3" - checksum: 10/b44cff80bb804b56eec422cce65555ea3879d8bb1ccc0e1e26df09c8300167a123396e8591da09c1f8769d16d498f9aa454d0b0a11e53235cfd403f0f6597ba2 + checksum: 10/cc903e1534730439948976c8494a04aeebd01a494b003852a339e506cdaf36ae646a44d362019c1c4f4f1627b7ededaa9827291bc711e443d749b70a7d7748c4 languageName: node linkType: hard @@ -41275,20 +41111,6 @@ __metadata: languageName: node linkType: hard -"optionator@npm:^0.8.1": - version: 0.8.3 - resolution: "optionator@npm:0.8.3" - dependencies: - deep-is: "npm:~0.1.3" - fast-levenshtein: "npm:~2.0.6" - levn: "npm:~0.3.0" - prelude-ls: "npm:~1.1.2" - type-check: "npm:~0.3.2" - word-wrap: "npm:~1.2.3" - checksum: 10/6fa3c841b520f10aec45563962922215180e8cfbc59fde3ecd4ba2644ad66ca96bd19ad0e853f22fefcb7fc10e7612a5215b412cc66c5588f9a3138b38f6b5ff - languageName: node - linkType: hard - "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -42722,6 +42544,19 @@ __metadata: languageName: node linkType: hard +"postcss-import@npm:^16.1.0": + version: 16.1.1 + resolution: "postcss-import@npm:16.1.1" + dependencies: + postcss-value-parser: "npm:^4.0.0" + read-cache: "npm:^1.0.0" + resolve: "npm:^1.1.7" + peerDependencies: + postcss: ^8.0.0 + checksum: 10/2afac2b6e25f263f45ac2168c5f5e4b2e49e3d44c620338138fe89cf81bd83e6056784a26d54c58611d05dda18bc56069212484ec750bbf6d2e29b623460a7f9 + languageName: node + linkType: hard + "postcss-load-config@npm:^3.0.0": version: 3.0.1 resolution: "postcss-load-config@npm:3.0.1" @@ -43043,7 +42878,7 @@ __metadata: languageName: node linkType: hard -"postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": +"postcss-value-parser@npm:^4.0.0, postcss-value-parser@npm:^4.0.2, postcss-value-parser@npm:^4.1.0, postcss-value-parser@npm:^4.2.0": version: 4.2.0 resolution: "postcss-value-parser@npm:4.2.0" checksum: 10/e4e4486f33b3163a606a6ed94f9c196ab49a37a7a7163abfcd469e5f113210120d70b8dd5e33d64636f41ad52316a3725655421eb9a1094f1bcab1db2f555c62 @@ -43198,13 +43033,6 @@ __metadata: languageName: node linkType: hard -"prelude-ls@npm:~1.1.2": - version: 1.1.2 - resolution: "prelude-ls@npm:1.1.2" - checksum: 10/946a9f60d3477ca6b7d4c5e8e452ad1b98dc8aaa992cea939a6b926ac16cc4129d7217c79271dc808b5814b1537ad0af37f29a942e2eafbb92cfc5a1c87c38cb - languageName: node - linkType: hard - "prepend-http@npm:^2.0.0": version: 2.0.0 resolution: "prepend-http@npm:2.0.0" @@ -44861,6 +44689,15 @@ __metadata: languageName: node linkType: hard +"read-cache@npm:^1.0.0": + version: 1.0.0 + resolution: "read-cache@npm:1.0.0" + dependencies: + pify: "npm:^2.3.0" + checksum: 10/83a39149d9dfa38f0c482ea0d77b34773c92fef07fe7599cdd914d255b14d0453e0229ef6379d8d27d6947f42d7581635296d0cfa7708f05a9bd8e789d398b31 + languageName: node + linkType: hard + "read-cmd-shim@npm:^2.0.0": version: 2.0.0 resolution: "read-cmd-shim@npm:2.0.0" @@ -45154,7 +44991,7 @@ __metadata: languageName: node linkType: hard -"regexp.prototype.flags@npm:^1.5.1, regexp.prototype.flags@npm:^1.5.3, regexp.prototype.flags@npm:^1.5.4": +"regexp.prototype.flags@npm:^1.5.3, regexp.prototype.flags@npm:^1.5.4": version: 1.5.4 resolution: "regexp.prototype.flags@npm:1.5.4" dependencies: @@ -45441,16 +45278,16 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.1.6, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.10, resolve@npm:^1.22.4, resolve@npm:^1.22.8, resolve@npm:~1.22.1, resolve@npm:~1.22.2": - version: 1.22.10 - resolution: "resolve@npm:1.22.10" +"resolve@npm:^1.1.6, resolve@npm:^1.1.7, resolve@npm:^1.17.0, resolve@npm:^1.19.0, resolve@npm:^1.20.0, resolve@npm:^1.22.1, resolve@npm:^1.22.10, resolve@npm:^1.22.4, resolve@npm:^1.22.8, resolve@npm:~1.22.1, resolve@npm:~1.22.2": + version: 1.22.11 + resolution: "resolve@npm:1.22.11" dependencies: - is-core-module: "npm:^2.16.0" + is-core-module: "npm:^2.16.1" path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 10/0a398b44da5c05e6e421d70108822c327675febb880eebe905587628de401854c61d5df02866ff34fc4cb1173a51c9f0e84a94702738df3611a62e2acdc68181 + checksum: 10/e1b2e738884a08de03f97ee71494335eba8c2b0feb1de9ae065e82c48997f349f77a2b10e8817e147cf610bfabc4b1cb7891ee8eaf5bf80d4ad514a34c4fab0a languageName: node linkType: hard @@ -45480,16 +45317,16 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.1.6#optional!builtin, resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.10#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin, resolve@patch:resolve@npm%3A~1.22.2#optional!builtin": - version: 1.22.10 - resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" +"resolve@patch:resolve@npm%3A^1.1.6#optional!builtin, resolve@patch:resolve@npm%3A^1.1.7#optional!builtin, resolve@patch:resolve@npm%3A^1.17.0#optional!builtin, resolve@patch:resolve@npm%3A^1.19.0#optional!builtin, resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.1#optional!builtin, resolve@patch:resolve@npm%3A^1.22.10#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin, resolve@patch:resolve@npm%3A^1.22.8#optional!builtin, resolve@patch:resolve@npm%3A~1.22.1#optional!builtin, resolve@patch:resolve@npm%3A~1.22.2#optional!builtin": + version: 1.22.11 + resolution: "resolve@patch:resolve@npm%3A1.22.11#optional!builtin::version=1.22.11&hash=c3c19d" dependencies: - is-core-module: "npm:^2.16.0" + is-core-module: "npm:^2.16.1" path-parse: "npm:^1.0.7" supports-preserve-symlinks-flag: "npm:^1.0.0" bin: resolve: bin/resolve - checksum: 10/d4d878bfe3702d215ea23e75e0e9caf99468e3db76f5ca100d27ebdc527366fee3877e54bce7d47cc72ca8952fc2782a070d238bfa79a550eeb0082384c3b81a + checksum: 10/fd342cad25e52cd6f4f3d1716e189717f2522bfd6641109fe7aa372f32b5714a296ed7c238ddbe7ebb0c1ddfe0b7f71c9984171024c97cf1b2073e3e40ff71a8 languageName: node linkType: hard @@ -46340,11 +46177,11 @@ __metadata: linkType: hard "semver@npm:^7.0.0, semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.1.3, semver@npm:^7.3.2, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.8, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.7.2, semver@npm:^7.7.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" + version: 7.7.4 + resolution: "semver@npm:7.7.4" bin: semver: bin/semver.js - checksum: 10/8dbc3168e057a38fc322af909c7f5617483c50caddba135439ff09a754b20bdd6482a5123ff543dad4affa488ecf46ec5fb56d61312ad20bb140199b88dfaea9 + checksum: 10/26bdc6d58b29528f4142d29afb8526bc335f4fc04c4a10f2b98b217f277a031c66736bf82d3d3bb354a2f6a3ae50f18fd62b053c4ac3f294a3d10a61f5075b75 languageName: node linkType: hard @@ -46734,7 +46571,7 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.0.4, side-channel@npm:^1.1.0": +"side-channel@npm:^1.1.0": version: 1.1.0 resolution: "side-channel@npm:1.1.0" dependencies: @@ -47371,12 +47208,12 @@ __metadata: languageName: node linkType: hard -"static-eval@npm:2.0.2": - version: 2.0.2 - resolution: "static-eval@npm:2.0.2" +"static-eval@npm:2.1.1": + version: 2.1.1 + resolution: "static-eval@npm:2.1.1" dependencies: - escodegen: "npm:^1.8.1" - checksum: 10/2e2faf1b23bad5d9d5b2407b18945c7b97f8706b6d65f06bb3583a2d4fd1994cf5890c5779a1bfa2a02905dc860e077e4f045d7413d289d8993f605758f8992f + escodegen: "npm:^2.1.0" + checksum: 10/b47e8238014745ea457f2dc9e14130298ce5dbec142a5c93a943f338fe1b47d03086d1897114e15d8603a9a7a39fed95bb49ccd3fa95dfc1550952eca0688417 languageName: node linkType: hard @@ -48443,7 +48280,7 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:*, terser-webpack-plugin@npm:^5.1.3, terser-webpack-plugin@npm:^5.3.11": +"terser-webpack-plugin@npm:*, terser-webpack-plugin@npm:^5.1.3, terser-webpack-plugin@npm:^5.3.16": version: 5.3.16 resolution: "terser-webpack-plugin@npm:5.3.16" dependencies: @@ -49246,15 +49083,6 @@ __metadata: languageName: node linkType: hard -"type-check@npm:~0.3.2": - version: 0.3.2 - resolution: "type-check@npm:0.3.2" - dependencies: - prelude-ls: "npm:~1.1.2" - checksum: 10/11dec0b50d7c3fd2e630b4b074ba36918ed2b1efbc87dfbd40ba9429d49c58d12dad5c415ece69fcf358fa083f33466fc370f23ab91aa63295c45d38b3a60dda - languageName: node - linkType: hard - "type-detect@npm:4.0.8, type-detect@npm:^4.0.8": version: 4.0.8 resolution: "type-detect@npm:4.0.8" @@ -49648,10 +49476,10 @@ __metadata: languageName: node linkType: hard -"underscore@npm:1.12.1": - version: 1.12.1 - resolution: "underscore@npm:1.12.1" - checksum: 10/c3bb50c61ef7dea4b9add6d48f98316f65de1075801dc8cd01ecd82122b99eb17ce0f068f00f6cc10245d5cf75c443ce96f8efc5458d7773b44af9bd7c2ccc56 +"underscore@npm:1.13.6": + version: 1.13.6 + resolution: "underscore@npm:1.13.6" + checksum: 10/58cf5dc42cb0ac99c146ae4064792c0a2cc84f3a3c4ad88f5082e79057dfdff3371d896d1ec20379e9ece2450d94fa78f2ef5bfefc199ba320653e32c009bd66 languageName: node linkType: hard @@ -50031,9 +49859,9 @@ __metadata: languageName: node linkType: hard -"update-browserslist-db@npm:^1.1.4": - version: 1.1.4 - resolution: "update-browserslist-db@npm:1.1.4" +"update-browserslist-db@npm:^1.2.0": + version: 1.2.3 + resolution: "update-browserslist-db@npm:1.2.3" dependencies: escalade: "npm:^3.2.0" picocolors: "npm:^1.1.1" @@ -50041,7 +49869,7 @@ __metadata: browserslist: ">= 4.21.0" bin: update-browserslist-db: cli.js - checksum: 10/79b2c0a31e9b837b49dc55d5cb7b77f44a69502847c7be352a44b1d35ac2032bf0e1bb7543f992809ed427bf9d32aa3f7ad41cef96198fa959c1666870174c06 + checksum: 10/059f774300efb4b084a49293143c511f3ae946d40397b5c30914e900cd5691a12b8e61b41dd54ed73d3b56c8204165a0333107dd784ccf8f8c81790bcc423175 languageName: node linkType: hard @@ -50764,7 +50592,7 @@ __metadata: languageName: node linkType: hard -"webidl-conversions@npm:^8.0.0, webidl-conversions@npm:^8.0.1": +"webidl-conversions@npm:^8.0.0": version: 8.0.1 resolution: "webidl-conversions@npm:8.0.1" checksum: 10/0f7007311f1fc257a8e406dd236f13b61fb57cf0fddb476aec33457d2d0add2d012d6df0eeb00934399238e3f3b9dad30f59dc6ac83024ae0ebd5a518bf365e8 @@ -50859,9 +50687,9 @@ __metadata: languageName: node linkType: hard -"webpack@npm:^5, webpack@npm:~5.103.0": - version: 5.103.0 - resolution: "webpack@npm:5.103.0" +"webpack@npm:^5, webpack@npm:~5.104.0": + version: 5.104.1 + resolution: "webpack@npm:5.104.1" dependencies: "@types/eslint-scope": "npm:^3.7.7" "@types/estree": "npm:^1.0.8" @@ -50871,10 +50699,10 @@ __metadata: "@webassemblyjs/wasm-parser": "npm:^1.14.1" acorn: "npm:^8.15.0" acorn-import-phases: "npm:^1.0.3" - browserslist: "npm:^4.26.3" + browserslist: "npm:^4.28.1" chrome-trace-event: "npm:^1.0.2" - enhanced-resolve: "npm:^5.17.3" - es-module-lexer: "npm:^1.2.1" + enhanced-resolve: "npm:^5.17.4" + es-module-lexer: "npm:^2.0.0" eslint-scope: "npm:5.1.1" events: "npm:^3.2.0" glob-to-regexp: "npm:^0.4.1" @@ -50885,7 +50713,7 @@ __metadata: neo-async: "npm:^2.6.2" schema-utils: "npm:^4.3.3" tapable: "npm:^2.3.0" - terser-webpack-plugin: "npm:^5.3.11" + terser-webpack-plugin: "npm:^5.3.16" watchpack: "npm:^2.4.4" webpack-sources: "npm:^3.3.3" peerDependenciesMeta: @@ -50893,7 +50721,7 @@ __metadata: optional: true bin: webpack: bin/webpack.js - checksum: 10/0018e77d159da412aa8cc1c3ac1d7c0b44228d0f5ce3939b4f424c04feba69747d8490541bcf8143b358a64afbbd69daad95e573ec9c4a90a99bef55d51dd43e + checksum: 10/c22b58fdf41d993971516154cb621d13b7b61dd744b7e2c6be972e9afcc9942694a8aaa322c07fba46448c69d663023ff53d0aeeb00eb0b125d83ace5ca8fa3c languageName: node linkType: hard @@ -50938,17 +50766,6 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:16.0.0": - version: 16.0.0 - resolution: "whatwg-url@npm:16.0.0" - dependencies: - "@exodus/bytes": "npm:^1.11.0" - tr46: "npm:^6.0.0" - webidl-conversions: "npm:^8.0.1" - checksum: 10/f57a668adcd98fc21a8da88905ac23da3feba2aea2fb484caab20980504a32c16fd9e3c3313131eacade802505118967631cea3e48e5c3c272ba76521babe355 - languageName: node - linkType: hard - "whatwg-url@npm:^14.0.0": version: 14.2.0 resolution: "whatwg-url@npm:14.2.0" @@ -50979,7 +50796,7 @@ __metadata: languageName: node linkType: hard -"which-boxed-primitive@npm:^1.0.2, which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": +"which-boxed-primitive@npm:^1.1.0, which-boxed-primitive@npm:^1.1.1": version: 1.1.1 resolution: "which-boxed-primitive@npm:1.1.1" dependencies: @@ -51013,7 +50830,7 @@ __metadata: languageName: node linkType: hard -"which-collection@npm:^1.0.1, which-collection@npm:^1.0.2": +"which-collection@npm:^1.0.2": version: 1.0.2 resolution: "which-collection@npm:1.0.2" dependencies: @@ -51035,7 +50852,7 @@ __metadata: languageName: node linkType: hard -"which-typed-array@npm:^1.1.13, which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": +"which-typed-array@npm:^1.1.16, which-typed-array@npm:^1.1.19, which-typed-array@npm:^1.1.2": version: 1.1.19 resolution: "which-typed-array@npm:1.1.19" dependencies: @@ -51151,13 +50968,6 @@ __metadata: languageName: node linkType: hard -"word-wrap@npm:~1.2.3": - version: 1.2.4 - resolution: "word-wrap@npm:1.2.4" - checksum: 10/a749c0cf410724acde4bdb263dcb13de61489dde22889a6a408e8a57e5948477c5b7438a757e25bb92985ed02562ab271aade90d605a24f3ae78410b638fbbd8 - languageName: node - linkType: hard - "wordwrap@npm:^1.0.0": version: 1.0.0 resolution: "wordwrap@npm:1.0.0"