diff --git a/.changeset/all-parrots-change.md b/.changeset/all-parrots-change.md new file mode 100644 index 0000000000..65d5c37658 --- /dev/null +++ b/.changeset/all-parrots-change.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-org': patch +--- + +Improved responsiveness of GroupProfileCard component diff --git a/.changeset/create-app-1762897504.md b/.changeset/create-app-1762897504.md new file mode 100644 index 0000000000..b50d431d4b --- /dev/null +++ b/.changeset/create-app-1762897504.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Bumped create-app version. diff --git a/.changeset/eighty-mails-leave.md b/.changeset/eighty-mails-leave.md new file mode 100644 index 0000000000..3c6ec5ab8f --- /dev/null +++ b/.changeset/eighty-mails-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-home': patch +--- + +Allow customization of VisitList by adding optional enrichVisit, transformPathname, canSave functions to VisitsStorageApi, along with VisitDisplayProvider for colors, labels diff --git a/.changeset/eleven-lights-taste.md b/.changeset/eleven-lights-taste.md new file mode 100644 index 0000000000..e575b909e6 --- /dev/null +++ b/.changeset/eleven-lights-taste.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Added missing i18n diff --git a/.changeset/fancy-years-camp.md b/.changeset/fancy-years-camp.md new file mode 100644 index 0000000000..41782d4e0c --- /dev/null +++ b/.changeset/fancy-years-camp.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-notifications-backend': minor +'@backstage/plugin-notifications-common': minor +--- + +Adds support for default configuration for an entire notification channel. +This setting will also be inherited down to origins and topics while still respecting the users individual choices. + +This will be handy if you want to use a "opt-in" strategy. diff --git a/.changeset/fast-tables-sink.md b/.changeset/fast-tables-sink.md new file mode 100644 index 0000000000..a7c240fc5d --- /dev/null +++ b/.changeset/fast-tables-sink.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Improved visual consistency of PasswordField, SearchField, and MenuAutocomplete components. diff --git a/.changeset/long-impalas-burn.md b/.changeset/long-impalas-burn.md new file mode 100644 index 0000000000..ef70f65f78 --- /dev/null +++ b/.changeset/long-impalas-burn.md @@ -0,0 +1,5 @@ +--- +'@backstage/repo-tools': minor +--- + +The `package-docs` command will now automatically use a `typedoc.json` file if one exists at the root of your project. diff --git a/.changeset/odd-states-melt.md b/.changeset/odd-states-melt.md new file mode 100644 index 0000000000..514e890bab --- /dev/null +++ b/.changeset/odd-states-melt.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-plugin-api': minor +--- + +Explicitly mark `coreServices.rootInstanceMetadata` as a root service. diff --git a/.changeset/open-cloths-listen.md b/.changeset/open-cloths-listen.md new file mode 100644 index 0000000000..3af368c7ce --- /dev/null +++ b/.changeset/open-cloths-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-test-utils': patch +--- + +Tweak some of the mock services to have more precise types diff --git a/.changeset/plugin-relative-extensions.md b/.changeset/plugin-relative-extensions.md new file mode 100644 index 0000000000..dc27a2283f --- /dev/null +++ b/.changeset/plugin-relative-extensions.md @@ -0,0 +1,17 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added support for plugin-relative `attachTo` declarations for extension definitions. This allows for the creation of extension and extension blueprints that attach to other extensions of a particular `kind` in the same plugin, rather than needing to provide the exact extension ID. This is particularly useful when wanting to provide extension blueprints with a built-in hierarchy where the extensions created from one blueprint attach to extensions created from the other blueprint, for example: + +```ts +// kind: 'tabbed-page' +const parentPage = TabbedPageBlueprint.make({ + params: {....} +}) +// attachTo: { kind: 'tabbed-page', input: 'tabs' } +const child1 = TabContentBlueprint.make({ + name: 'tab1', + params: {....} +}) +``` diff --git a/.changeset/polite-eggs-dance.md b/.changeset/polite-eggs-dance.md new file mode 100644 index 0000000000..e9725be567 --- /dev/null +++ b/.changeset/polite-eggs-dance.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Removed `@base-ui-components/react` dependency as all components now use React Aria Components. diff --git a/.changeset/pre.json b/.changeset/pre.json index a8ef8fe974..f068415e9f 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -208,6 +208,7 @@ }, "changesets": [ "all-camels-agree", + "all-parrots-change", "better-hats-cross", "better-steaks-act", "breezy-times-ring", @@ -215,13 +216,19 @@ "busy-goats-create", "clever-boats-clap", "create-app-1761312116", + "create-app-1762897504", "cruel-items-dig", "cruel-plums-talk", + "deep-gifts-slide", "easy-hands-grow", + "eighty-mails-leave", "eighty-results-prove", "eleven-carpets-win", + "eleven-lights-taste", "every-ants-count", "every-clocks-arrive", + "fancy-years-camp", + "fast-tables-sink", "fast-tools-mate", "fine-hands-return", "five-seas-jam", @@ -233,24 +240,34 @@ "giant-lamps-happen", "grumpy-planes-bet", "heavy-cars-wash", + "honest-lamps-occur", "honest-pandas-win", "huge-taxis-grab", "itchy-bars-smell", "legal-weeks-walk", "lemon-spies-sleep", "long-humans-sink", + "long-impalas-burn", "loud-carpets-throw", "moody-plums-add", "ninety-cobras-feel", + "odd-states-melt", + "open-cloths-listen", "open-items-open", + "plugin-relative-extensions", + "polite-eggs-dance", "polite-seas-divide", "pretty-kids-allow", + "purple-deer-bet", "quiet-singers-pick", + "renovate-cd75086", "rich-streets-rule", "ripe-crabs-care", "seven-cycles-pick", + "sharp-dragons-relate", "short-sides-feel", "silver-garlics-thank", + "sixty-clowns-run", "smart-donuts-teach", "solid-bees-agree", "solid-dancers-march", @@ -259,11 +276,15 @@ "tender-regions-know", "thirty-hoops-own", "tough-sloths-spend", + "true-taxes-cover", "twelve-spoons-feel", "typescript-constructor-refactor", "upset-teeth-add", + "warm-carrots-lead", + "warm-ducks-notice", "warm-moments-repeat", "warm-shrimps-clap", + "wicked-cycles-enter", "wide-papers-run", "wild-donkeys-sneeze", "wild-owls-divide", diff --git a/.changeset/purple-deer-bet.md b/.changeset/purple-deer-bet.md new file mode 100644 index 0000000000..c60f2ada7a --- /dev/null +++ b/.changeset/purple-deer-bet.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-graph': patch +--- + +Ensure the catalog graph entity card respects the height prop so the visualization scales down properly on wide screens. diff --git a/.changeset/renovate-cd75086.md b/.changeset/renovate-cd75086.md new file mode 100644 index 0000000000..5bc38af431 --- /dev/null +++ b/.changeset/renovate-cd75086.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-kubernetes-backend': patch +'@backstage/plugin-kubernetes-common': patch +'@backstage/plugin-kubernetes-node': patch +'@backstage/plugin-kubernetes-react': patch +--- + +Updated dependency `@kubernetes/client-node` to `1.4.0`. diff --git a/.changeset/sharp-dragons-relate.md b/.changeset/sharp-dragons-relate.md new file mode 100644 index 0000000000..1f7e5e0d4a --- /dev/null +++ b/.changeset/sharp-dragons-relate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app-visualizer': patch +--- + +Migrated to use `@backstage/ui`. diff --git a/.changeset/true-taxes-cover.md b/.changeset/true-taxes-cover.md new file mode 100644 index 0000000000..ec204084d0 --- /dev/null +++ b/.changeset/true-taxes-cover.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added `loading` prop to Button and ButtonIcon components for displaying spinner during async operations. diff --git a/.changeset/wicked-cycles-enter.md b/.changeset/wicked-cycles-enter.md new file mode 100644 index 0000000000..cea5cff371 --- /dev/null +++ b/.changeset/wicked-cycles-enter.md @@ -0,0 +1,9 @@ +--- +'@backstage/ui': minor +--- + +**BREAKING**: The `SelectProps` interface now accepts a generic type parameter for selection mode. + +Added searchable and multiple selection support to Select component. The component now accepts `searchable`, `selectionMode`, and `searchPlaceholder` props to enable filtering and multi-selection modes. + +Migration: If you're using `SelectProps` type directly, update from `SelectProps` to `SelectProps<'single' | 'multiple'>`. Component usage remains backward compatible. diff --git a/.github/workflows/api-breaking-changes-comment.yml b/.github/workflows/api-breaking-changes-comment.yml index 29b8545766..981d5cb4d6 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: disable-sudo: true egress-policy: block diff --git a/.github/workflows/api-breaking-changes.yml b/.github/workflows/api-breaking-changes.yml index 5ee0e35af9..f72d9c29bf 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/automate_area-labels.yml b/.github/workflows/automate_area-labels.yml index 0620c877eb..f21ef62d8d 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/automate_changeset_feedback.yml b/.github/workflows/automate_changeset_feedback.yml index 5c91ff0038..c41f4dd8f3 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/automate_merge_message.yml b/.github/workflows/automate_merge_message.yml index 2476ab71f4..59da292109 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/automate_stale.yml b/.github/workflows/automate_stale.yml index 56bf1e26ec..5b92c6211b 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/ci-noop.yml b/.github/workflows/ci-noop.yml index b1415652e5..0fce9ad6b9 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9eafd2c2a8..8aabe7de58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: name: Install ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -64,7 +64,7 @@ jobs: name: Verify ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index 156d676cd2..285c2e6c41 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -10,7 +10,7 @@ jobs: timeout-minutes: 10 steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/deploy_docker-image.yml b/.github/workflows/deploy_docker-image.yml index 18987aa3e0..e0f4ec29ed 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/deploy_microsite.yml b/.github/workflows/deploy_microsite.yml index a07ac06d99..2d61bcc19b 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -135,7 +135,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -240,7 +240,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -329,7 +329,7 @@ jobs: run: ls microsite/build && ls microsite/build/storybook && ls microsite/build/api/stable && ls microsite/build/api/next - name: Deploy both microsite and storybook to gh-pages - uses: JamesIves/github-pages-deploy-action@6c2d9db40f9296374acc17b90404b6e8864128c8 # v4.7.3 + uses: JamesIves/github-pages-deploy-action@4a3abc783e1a24aeb44c16e869ad83caf6b4cc23 # v4.7.4 with: branch: gh-pages folder: microsite/build diff --git a/.github/workflows/deploy_packages.yml b/.github/workflows/deploy_packages.yml index b968c51782..9e4e504e60 100644 --- a/.github/workflows/deploy_packages.yml +++ b/.github/workflows/deploy_packages.yml @@ -151,7 +151,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/issue.yaml b/.github/workflows/issue.yaml index 8c1b0d028b..a759adcb46 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/pr-review-comment-trigger.yaml b/.github/workflows/pr-review-comment-trigger.yaml index dc90da1384..d5970deae8 100644 --- a/.github/workflows/pr-review-comment-trigger.yaml +++ b/.github/workflows/pr-review-comment-trigger.yaml @@ -20,7 +20,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/pr-review-comment.yaml b/.github/workflows/pr-review-comment.yaml index 30e7f5bad4..9db0792eaa 100644 --- a/.github/workflows/pr-review-comment.yaml +++ b/.github/workflows/pr-review-comment.yaml @@ -17,7 +17,7 @@ jobs: 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index b1a67bec15..33dd3d00ce 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -18,7 +18,7 @@ jobs: if: github.repository == 'backstage/backstage' && ( github.event.pull_request || github.event.issue.pull_request ) steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 5bb4c13b25..0766d1c8a5 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -39,7 +39,7 @@ jobs: persist-credentials: false - name: 'Run analysis' - uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 with: results_file: results.sarif results_format: sarif diff --git a/.github/workflows/sync_canon.yml b/.github/workflows/sync_canon.yml index 4868fdaca9..3b85c1c49d 100644 --- a/.github/workflows/sync_canon.yml +++ b/.github/workflows/sync_canon.yml @@ -8,7 +8,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_code-formatting.yml b/.github/workflows/sync_code-formatting.yml index 458fa9ed99..33cab94280 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_dependabot-changesets.yml b/.github/workflows/sync_dependabot-changesets.yml index 26c0387cce..181d48eb38 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_release-manifest.yml b/.github/workflows/sync_release-manifest.yml index 7cf7e018bd..a9c4b3722b 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_renovate-changesets.yml b/.github/workflows/sync_renovate-changesets.yml index caf697714a..2331e2c30f 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_snyk-github-issues.yml b/.github/workflows/sync_snyk-github-issues.yml index 697f63c991..f18455f5fc 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_snyk-monitor.yml b/.github/workflows/sync_snyk-monitor.yml index 3f12815379..b928586aa8 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_version-packages.yml b/.github/workflows/sync_version-packages.yml index 3071990fbd..66a798bd58 100644 --- a/.github/workflows/sync_version-packages.yml +++ b/.github/workflows/sync_version-packages.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_accessibility-noop.yml b/.github/workflows/verify_accessibility-noop.yml index 837e8153be..df6f004171 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_accessibility.yml b/.github/workflows/verify_accessibility.yml index c64a4d2ed1..48e243b002 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_chromatic-noop.yml b/.github/workflows/verify_chromatic-noop.yml index cd6248e004..4de52a3258 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_chromatic.yml b/.github/workflows/verify_chromatic.yml index 705d769c83..e6649c3e55 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_codeql.yml b/.github/workflows/verify_codeql.yml index 57da6f7f21..254938ecc8 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_docs-quality.yml b/.github/workflows/verify_docs-quality.yml index a0ec5a2eee..6c855eeea4 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-linux-noop.yml b/.github/workflows/verify_e2e-linux-noop.yml index f9e3d26e89..aa3358f2f7 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml index 898de3d57c..dc81d42e8a 100644 --- a/.github/workflows/verify_e2e-linux.yml +++ b/.github/workflows/verify_e2e-linux.yml @@ -43,7 +43,7 @@ jobs: name: E2E Linux ${{ matrix.node-version }} steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-techdocs.yml b/.github/workflows/verify_e2e-techdocs.yml index abfdf17ee6..e933efe3f8 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-windows-noop.yml b/.github/workflows/verify_e2e-windows-noop.yml index fc4af922a5..b1a4344bfc 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml index e859468462..eefcf22b35 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_fossa.yml b/.github/workflows/verify_fossa.yml index 8f09531a94..9c811fbe9f 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite-noop.yml b/.github/workflows/verify_microsite-noop.yml index 169e447bbb..3466826cae 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite.yml b/.github/workflows/verify_microsite.yml index 60fa039ec4..67ac397d43 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -137,7 +137,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -234,7 +234,7 @@ jobs: name: Microsite steps: - name: Harden Runner - uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite_accessibility-noop.yml b/.github/workflows/verify_microsite_accessibility-noop.yml index 26d4748772..5e0bc35cc0 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_microsite_accessibility.yml b/.github/workflows/verify_microsite_accessibility.yml index 01cf592a73..40e1866b5f 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/verify_windows.yml b/.github/workflows/verify_windows.yml index 187c3647d8..363bc075b8 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@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.0 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/README.md b/README.md index ba44ca951d..545874adf9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ > [!NOTE] -> 🏖 From Monday October 27th through November 3rd, maintainers and Spotify employees will be on vacation due to Wellness Week. Expect the project to move a little slower than normal, and support to be limited. Normal service will resume after that! 🏝 +> ✈️ From Monday November 10th through November 14rd, several maintainers and Spotify employees will be at KubeCon / BackstageCon! Expect the project to move a little slower than normal, and support to be limited. Normal service will resume after that! And do come visit our booth if you are there. ✈️ [![headline](docs/assets/headline.png)](https://backstage.io/) diff --git a/docker-compose.deps.yml b/docker-compose.deps.yml index c7515eca04..2ad65d6085 100644 --- a/docker-compose.deps.yml +++ b/docker-compose.deps.yml @@ -24,7 +24,7 @@ services: timeout: 3s retries: 5 opensearch: - image: opensearchproject/opensearch:2.19.3 + image: opensearchproject/opensearch:2.19.4 environment: plugins.security.disabled: true discovery.type: single-node diff --git a/docs-ui/package.json b/docs-ui/package.json index 75b79d1000..d05fdf81bb 100644 --- a/docs-ui/package.json +++ b/docs-ui/package.json @@ -16,6 +16,7 @@ "@types/react-dom": "19.1.7" }, "dependencies": { + "@base-ui-components/react": "^1.0.0-beta.4", "@codemirror/lang-sass": "^6.0.2", "@codemirror/view": "^6.34.4", "@lezer/highlight": "^1.2.1", diff --git a/docs-ui/src/content/button-icon.mdx b/docs-ui/src/content/button-icon.mdx index e1f27e0601..fbc0c8c54c 100644 --- a/docs-ui/src/content/button-icon.mdx +++ b/docs-ui/src/content/button-icon.mdx @@ -9,6 +9,7 @@ import { buttonIconVariantsSnippet, buttonIconSizesSnippet, buttonIconDisabledSnippet, + buttonIconLoadingSnippet, buttonIconResponsiveSnippet, buttonIconAsLinkSnippet, } from './button-icon.props'; @@ -74,6 +75,18 @@ Here's a view when buttons are disabled. code={buttonIconDisabledSnippet} /> +### Loading + +Here's a view when buttons are in a loading state. + +} + code={buttonIconLoadingSnippet} +/> + ### Responsive Here's a view when buttons are responsive. diff --git a/docs-ui/src/content/button-icon.props.ts b/docs-ui/src/content/button-icon.props.ts index 2ba7372a9c..b063484d27 100644 --- a/docs-ui/src/content/button-icon.props.ts +++ b/docs-ui/src/content/button-icon.props.ts @@ -19,6 +19,7 @@ export const buttonIconPropDefs: Record = { }, icon: { type: 'enum', values: ['ReactNode'], responsive: false }, isDisabled: { type: 'boolean', default: 'false', responsive: false }, + loading: { type: 'boolean', default: 'false', responsive: false }, type: { type: 'enum', values: ['button', 'submit', 'reset'], @@ -50,6 +51,8 @@ export const buttonIconSizesSnippet = ` export const buttonIconDisabledSnippet = `} isDisabled />`; +export const buttonIconLoadingSnippet = `} variant="primary" loading={isLoading} onPress={handleClick} />`; + export const buttonIconResponsiveSnippet = `} variant={{ initial: 'primary', lg: 'secondary' }} />`; export const buttonIconAsLinkSnippet = `import { ButtonLink } from '@backstage/ui'; diff --git a/docs-ui/src/content/button.mdx b/docs-ui/src/content/button.mdx index a25115012b..b60f32ff07 100644 --- a/docs-ui/src/content/button.mdx +++ b/docs-ui/src/content/button.mdx @@ -9,6 +9,7 @@ import { buttonSizesSnippet, buttonIconsSnippet, buttonDisabledSnippet, + buttonLoadingSnippet, buttonResponsiveSnippet, buttonAsLinkSnippet, } from './button.props'; @@ -86,6 +87,18 @@ Here's a view when buttons are disabled. code={buttonDisabledSnippet} /> +### Loading + +Here's a view when buttons are in a loading state. + +} + code={buttonLoadingSnippet} +/> + ### Responsive Here's a view when buttons are responsive. diff --git a/docs-ui/src/content/button.props.ts b/docs-ui/src/content/button.props.ts index a3bb2659bf..e650a9f2e9 100644 --- a/docs-ui/src/content/button.props.ts +++ b/docs-ui/src/content/button.props.ts @@ -17,6 +17,7 @@ export const buttonPropDefs: Record = { iconStart: { type: 'enum', values: ['ReactNode'], responsive: false }, iconEnd: { type: 'enum', values: ['ReactNode'], responsive: false }, isDisabled: { type: 'boolean', default: 'false', responsive: false }, + loading: { type: 'boolean', default: 'false', responsive: false }, children: { type: 'enum', values: ['ReactNode'], responsive: false }, type: { type: 'enum', @@ -65,6 +66,10 @@ export const buttonResponsiveSnippet = ``; + export const buttonAsLinkSnippet = `import { ButtonLink } from '@backstage/ui'; diff --git a/docs-ui/src/content/select.mdx b/docs-ui/src/content/select.mdx index 09a852e871..eb53e9ea35 100644 --- a/docs-ui/src/content/select.mdx +++ b/docs-ui/src/content/select.mdx @@ -11,6 +11,9 @@ import { selectDisabledSnippet, selectResponsiveSnippet, selectIconSnippet, + selectSearchableSnippet, + selectMultipleSnippet, + selectSearchableMultipleSnippet, } from './select.props'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; @@ -87,6 +90,42 @@ Here's a view when the select is disabled. code={selectDisabledSnippet} /> +### Searchable + +Here's a view when the select has searchable filtering. + +} + code={selectSearchableSnippet} +/> + +### Multiple Selection + +Here's a view when the select allows multiple selections. + +} + code={selectMultipleSnippet} +/> + +### Searchable with Multiple Selection + +Here's a view when the select combines search and multiple selection. + +} + code={selectSearchableMultipleSnippet} +/> + ### Responsive Here's a view when the select is responsive. diff --git a/docs-ui/src/content/select.props.ts b/docs-ui/src/content/select.props.ts index a93f448bd8..05f0d895c9 100644 --- a/docs-ui/src/content/select.props.ts +++ b/docs-ui/src/content/select.props.ts @@ -23,6 +23,12 @@ export const selectPropDefs: Record = { values: ['Array<{ value: string, label: string }>'], required: true, }, + selectionMode: { + type: 'enum', + values: ['single', 'multiple'], + default: 'single', + responsive: false, + }, placeholder: { type: 'string', default: 'Select an item', @@ -34,17 +40,23 @@ export const selectPropDefs: Record = { responsive: false, }, value: { - type: 'string', + type: 'enum', + values: ['string', 'string[]'], responsive: false, + description: + 'Selected value (controlled). String for single selection, array for multiple.', }, defaultValue: { - type: 'string', + type: 'enum', + values: ['string', 'string[]'], responsive: false, + description: + 'Initial value (uncontrolled). String for single selection, array for multiple.', }, size: { type: 'enum', values: ['small', 'medium'], - default: 'medium', + default: 'small', responsive: true, }, isOpen: { @@ -57,7 +69,7 @@ export const selectPropDefs: Record = { }, disabledKeys: { type: 'enum', - values: ['string[]'], + values: ['Iterable'], responsive: false, }, isDisabled: { @@ -72,14 +84,6 @@ export const selectPropDefs: Record = { type: 'boolean', responsive: false, }, - selectedKey: { - type: 'string', - responsive: false, - }, - defaultSelectedKey: { - type: 'string', - responsive: false, - }, onOpenChange: { type: 'enum', values: ['(isOpen: boolean) => void'], @@ -87,7 +91,19 @@ export const selectPropDefs: Record = { }, onSelectionChange: { type: 'enum', - values: ['(key: Key | null) => void'], + values: ['(key: Key | null) => void', '(keys: Selection) => void'], + responsive: false, + description: + 'Handler called when selection changes. Single mode: receives Key | null. Multiple mode: receives Selection.', + }, + searchable: { + type: 'boolean', + default: 'false', + responsive: false, + }, + searchPlaceholder: { + type: 'string', + default: 'Search...', responsive: false, }, ...classNamePropDefs, @@ -151,3 +167,45 @@ export const selectResponsiveSnippet = ``; + +export const selectMultipleSnippet = ``; diff --git a/docs-ui/yarn.lock b/docs-ui/yarn.lock index faba322cfc..480f7592f8 100644 --- a/docs-ui/yarn.lock +++ b/docs-ui/yarn.lock @@ -23,10 +23,51 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.18.6": - version: 7.28.3 - resolution: "@babel/runtime@npm:7.28.3" - checksum: 10/f2415e4dbface7496f6fc561d640b44be203071fb0dfb63fbe338c7d2d2047419cb054ef13d1ebb8fc11e35d2b55aa3045def4b985e8b82aea5d7e58e1133e52 +"@babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/runtime@npm:7.28.4" + checksum: 10/6c9a70452322ea80b3c9b2a412bcf60771819213a67576c8cec41e88a95bb7bf01fc983754cda35dc19603eef52df22203ccbf7777b9d6316932f9fb77c25163 + languageName: node + linkType: hard + +"@base-ui-components/react@npm:^1.0.0-beta.4": + version: 1.0.0-beta.4 + resolution: "@base-ui-components/react@npm:1.0.0-beta.4" + dependencies: + "@babel/runtime": "npm:^7.28.4" + "@base-ui-components/utils": "npm:0.1.2" + "@floating-ui/react-dom": "npm:^2.1.6" + "@floating-ui/utils": "npm:^0.2.10" + reselect: "npm:^5.1.1" + tabbable: "npm:^6.2.0" + use-sync-external-store: "npm:^1.5.0" + peerDependencies: + "@types/react": ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/d2c6a779aaefa3454cc130244e839f0428460997f3ca8bbcd9636056bc5a4c77436cc0ceac064f34bdc7cb01489a4cbc4d8b0bf9668a023fa754e5a24bd3af4e + languageName: node + linkType: hard + +"@base-ui-components/utils@npm:0.1.2": + version: 0.1.2 + resolution: "@base-ui-components/utils@npm:0.1.2" + dependencies: + "@babel/runtime": "npm:^7.28.4" + "@floating-ui/utils": "npm:^0.2.10" + reselect: "npm:^5.1.1" + use-sync-external-store: "npm:^1.5.0" + peerDependencies: + "@types/react": ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/b8408f5ad55886f793fb7580ffe2bb9565ff0713bec324f726b88ee1d135d75b26a1312338b19af743adf52e5b9d46892a47fb225e6abfe1077e75d32e88fb6e languageName: node linkType: hard @@ -401,6 +442,44 @@ __metadata: languageName: node linkType: hard +"@floating-ui/core@npm:^1.7.3": + version: 1.7.3 + resolution: "@floating-ui/core@npm:1.7.3" + dependencies: + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10/a8952ff2673ddf28f12feeb86d90c54949e45bcb1af5758b7672850ac0dadb36d4bd61aa45dad1b6a35ba40d4756d3573afac6610b90502639d7266b91e0864e + languageName: node + linkType: hard + +"@floating-ui/dom@npm:^1.7.4": + version: 1.7.4 + resolution: "@floating-ui/dom@npm:1.7.4" + dependencies: + "@floating-ui/core": "npm:^1.7.3" + "@floating-ui/utils": "npm:^0.2.10" + checksum: 10/d3d6a23e7b9804ba56338c7c666590258683af14b6026270d32afc1202f72b5b82cca359004bdc7830bf2463a045da6c7bd4e7d5351218cf270ff94206197971 + languageName: node + linkType: hard + +"@floating-ui/react-dom@npm:^2.1.6": + version: 2.1.6 + resolution: "@floating-ui/react-dom@npm:2.1.6" + dependencies: + "@floating-ui/dom": "npm:^1.7.4" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10/fbfd3319b42edb9c156e4e872f500d2edb112bc9cfd1b45892bff16ccf21c2484ddc9c416f7631c2aaaadec1b2f98b205db8a3f89eb78ca870905fcfe3917c35 + languageName: node + linkType: hard + +"@floating-ui/utils@npm:^0.2.10": + version: 0.2.10 + resolution: "@floating-ui/utils@npm:0.2.10" + checksum: 10/b635ea865a8be2484b608b7157f5abf9ed439f351011a74b7e988439e2898199a9a8b790f52291e05bdcf119088160dc782d98cff45cc98c5a271bc6f51327ae + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.13.0": version: 0.13.0 resolution: "@humanwhocodes/config-array@npm:0.13.0" @@ -705,8 +784,8 @@ __metadata: linkType: hard "@mdx-js/loader@npm:^3.1.0": - version: 3.1.0 - resolution: "@mdx-js/loader@npm:3.1.0" + version: 3.1.1 + resolution: "@mdx-js/loader@npm:3.1.1" dependencies: "@mdx-js/mdx": "npm:^3.0.0" source-map: "npm:^0.7.0" @@ -715,7 +794,7 @@ __metadata: peerDependenciesMeta: webpack: optional: true - checksum: 10/983b8d4949f4d214a17f4ad8597a50de36ace75e93292d2493b5fb6077d69d6abc8230b62f88b818c04f12a01d281d63edb6a0738937f7e259406a73ce998134 + checksum: 10/b78afe06e49b5c0bec9e5b232fbc874ffa721ff6b022bdaa7a29e2b5a44fa9f8282aa2b34f74701ca0b8750b33a775cd3182aaba5202e209f3d66961600c930f languageName: node linkType: hard @@ -2299,6 +2378,7 @@ __metadata: version: 0.0.0-use.local resolution: "docs-ui@workspace:." dependencies: + "@base-ui-components/react": "npm:^1.0.0-beta.4" "@codemirror/lang-sass": "npm:^6.0.2" "@codemirror/view": "npm:^6.34.4" "@lezer/highlight": "npm:^1.2.1" @@ -4229,92 +4309,102 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-arm64@npm:1.30.1": - version: 1.30.1 - resolution: "lightningcss-darwin-arm64@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-darwin-x64@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-freebsd-x64@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-linux-arm64-gnu@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-linux-arm64-musl@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-linux-x64-gnu@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-linux-x64-musl@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-win32-arm64-msvc@npm:1.30.1" +"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.1": - version: 1.30.1 - resolution: "lightningcss-win32-x64-msvc@npm:1.30.1" +"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.28.2": - version: 1.30.1 - resolution: "lightningcss@npm:1.30.1" + version: 1.30.2 + resolution: "lightningcss@npm:1.30.2" dependencies: detect-libc: "npm:^2.0.3" - lightningcss-darwin-arm64: "npm:1.30.1" - lightningcss-darwin-x64: "npm:1.30.1" - lightningcss-freebsd-x64: "npm:1.30.1" - lightningcss-linux-arm-gnueabihf: "npm:1.30.1" - lightningcss-linux-arm64-gnu: "npm:1.30.1" - lightningcss-linux-arm64-musl: "npm:1.30.1" - lightningcss-linux-x64-gnu: "npm:1.30.1" - lightningcss-linux-x64-musl: "npm:1.30.1" - lightningcss-win32-arm64-msvc: "npm:1.30.1" - lightningcss-win32-x64-msvc: "npm:1.30.1" + 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: @@ -4335,7 +4425,7 @@ __metadata: optional: true lightningcss-win32-x64-msvc: optional: true - checksum: 10/6c921135216cc498dfcb87e35dffe8432e99306cbd58009c598b1daf20c81cc14535abbd4c1066e5d1faf4080ed44a2995e8ecc343633db4897a2d041b76fb05 + checksum: 10/d6cc06d9bac295589a49446e9c45a241dfa16f4f81a7318c26cbc0be3e189003ec0da5d9a0fd9bdffc63a3ce05878cc7329277eaac77a826e8b68c73dc96cfda languageName: node linkType: hard @@ -5768,6 +5858,13 @@ __metadata: languageName: node linkType: hard +"reselect@npm:^5.1.1": + version: 5.1.1 + resolution: "reselect@npm:5.1.1" + checksum: 10/1fdae11a39ed9c8d85a24df19517c8372ee24fefea9cce3fae9eaad8e9cefbba5a3d4940c6fe31296b6addf76e035588c55798f7e6e147e1b7c0855f119e7fa5 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -6455,6 +6552,13 @@ __metadata: languageName: node linkType: hard +"tabbable@npm:^6.2.0": + version: 6.3.0 + resolution: "tabbable@npm:6.3.0" + checksum: 10/3e54a0b770d26bc20c3de5837652be19f5efa8bfa869f580af24bcf60de934506e9401a577213186b5e86ebcf6b5290a5429d354cc3041471815f5095e44e51a + languageName: node + linkType: hard + "tar@npm:^7.4.3": version: 7.4.3 resolution: "tar@npm:7.4.3" @@ -6836,6 +6940,15 @@ __metadata: languageName: node linkType: hard +"use-sync-external-store@npm:^1.5.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "util@npm:^0.12.5": version: 0.12.5 resolution: "util@npm:0.12.5" diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index 24a5f24e12..a6d00a3131 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -352,3 +352,69 @@ const extension = createExtension({ }, }); ``` + +## Relative attachment points + +When creating an extension or an [extension blueprint](./23-extension-blueprints.md) you can specify an attachment point that is relative to the current plugin. This is particularly useful for groups of blueprints that are part of a common hierarchy, with extensions from one blueprint attaching to extensions from the other blueprint. For example, the following pair of extension definitions could be installed multiple times in different plugins, each creating their own hierarchy: + +```tsx +// Parent extension with a fixed attachment point +const parentExtension = createExtension({ + kind: 'section', + attachTo: [{ id: 'app/some-fixed-extension', input: 'children' }], + inputs: { + content: createExtensionInput([coreExtensionData.reactElement], { + singleton: true, + }), + }, + output: [coreExtensionData.reactElement], + factory({ inputs }) { + return [ + coreExtensionData.reactElement( +
+

Section Title

+ {inputs.content.get(coreExtensionData.reactElement)} +
, + ), + ]; + }, +}); + +// Child extension with a relative attachment point +const childExtension = createExtension({ + kind: 'section-content', + attachTo: [{ relative: { kind: 'section' }, input: 'content' }], + output: [coreExtensionData.reactElement], + factory() { + return [coreExtensionData.reactElement(

Section Content

)]; + }, +}); +``` + +## Extension input references + +Building on the relative attachment point concept, you can also reference extension inputs directly via the `inputs` property of an extension definition. This provides a more convenient and type-safe way to attach child extensions, especially when using blueprints that provide a nested hierarchy of extensions. + +Extension inputs references are always relative, this means that they can only be used for referencing extensions within the same plugin. + +Each extension definition exposes an `inputs` property that contains references to all of its defined inputs. These references can be passed directly to the `attachTo` option when creating child extensions: + +```tsx +const parent = createExtension({ + inputs: { + children: createExtensionInput([coreExtensionData.reactElement]), + }, + // other options... +}); + +// Create a child extension that attaches to the parent's input +const child = createExtension({ + attachTo: page.inputs.children, // Direct reference to the input + output: [coreExtensionData.reactElement], // Outputs are verified against the parent input + // other options... +}); +``` + +These references are a type-safe way to attach child extensions, it both ensures that the parent input is present, as well as the child providing the required data for the parent. + +Under the hood, input references are resolved in the same way as relative attachment points, using the extension's kind, namespace, and name to construct the final attachment target. diff --git a/docs/notifications/index.md b/docs/notifications/index.md index 59ff2962ed..dce58d3214 100644 --- a/docs/notifications/index.md +++ b/docs/notifications/index.md @@ -164,6 +164,61 @@ You can customize the origin names shown in the UI by passing an object where th Each notification processor will receive its own row in the settings page, where the user can enable or disable notifications from that processor. +### Default notification settings + +You can configure default notification settings for all users in your `app-config.yaml` file. This allows you to set up notification preferences globally, such as disabling specific channels or origins by default, implementing an opt-in strategy instead of opt-out. + +#### Channel-level defaults + +You can set a default enabled state for an entire channel. When set to `false`, the channel uses an opt-in strategy where notifications are disabled by default unless explicitly enabled by the user or for specific origins. + +```yaml +notifications: + defaultSettings: + channels: + - id: 'Web' + enabled: false # Opt-in strategy: channel disabled by default + - id: 'Email' + enabled: true # Opt-out strategy: channel enabled by default (default behavior) +``` + +#### Origin-level defaults + +You can also configure defaults for specific origins within a channel: + +```yaml +notifications: + defaultSettings: + channels: + - id: 'Web' + enabled: true # Channel is enabled by default + origins: + - id: 'plugin:scaffolder' + enabled: false # Disable scaffolder notifications by default + - id: 'plugin:catalog' + enabled: true # Enable catalog notifications by default +``` + +#### Topic-level defaults + +For even more granular control, you can set defaults for specific topics within origins: + +```yaml +notifications: + defaultSettings: + channels: + - id: 'Email' + enabled: false # Email is opt-in by default + origins: + - id: 'plugin:catalog' + enabled: true # But catalog notifications are enabled + topics: + - id: 'entity:validation:error' + enabled: false # Except validation errors +``` + +**Note:** If a channel's `enabled` flag is not set, it defaults to `true` for backwards compatibility. When a channel is set to `enabled: false`, all origins within that channel default to disabled unless explicitly enabled. + ### Automatic notification cleanup Notifications are deleted automatically after a certain period of time to prevent the database from growing indefinitely diff --git a/docs/plugins/backend-plugin.md b/docs/plugins/backend-plugin.md index bfd210aeb9..9b8b63fc89 100644 --- a/docs/plugins/backend-plugin.md +++ b/docs/plugins/backend-plugin.md @@ -48,11 +48,20 @@ This will think for a bit, and then say `Listening on :7007`. In a different terminal window, now run ```sh -curl localhost:7007/api/carmen/health +curl localhost:7007/api/carmen/todos ``` -This should return `{"status":"ok"}`. Success! Press `Ctrl + c` to stop it -again. +You should see the following response: + +```json +{ + "items": [] +} +``` + +:::note Note: The route shown here matches the default in the current backend plugin template. If you want a `/health` endpoint for health checks, you can add it to your router yourself. + +::: ## Developing your Backend Plugin diff --git a/docs/releases/v1.45.0-next.3-changelog.md b/docs/releases/v1.45.0-next.3-changelog.md new file mode 100644 index 0000000000..0f66b0ca65 --- /dev/null +++ b/docs/releases/v1.45.0-next.3-changelog.md @@ -0,0 +1,282 @@ +# Release v1.45.0-next.3 + +Upgrade Helper: [https://backstage.github.io/upgrade-helper/?to=1.45.0-next.3](https://backstage.github.io/upgrade-helper/?to=1.45.0-next.3) + +## @backstage/backend-plugin-api@1.5.0-next.2 + +### Minor Changes + +- 62fc2de: Explicitly mark `coreServices.rootInstanceMetadata` as a root service. + +## @backstage/repo-tools@0.16.0-next.2 + +### Minor Changes + +- 11c61f2: The `package-docs` command will now automatically use a `typedoc.json` file if one exists at the root of your project. + +### Patch Changes + +- Updated dependencies + - @backstage/backend-plugin-api@1.5.0-next.2 + +## @backstage/ui@0.9.0-next.3 + +### Minor Changes + +- 83c100e: **BREAKING**: Removed `Collapsible` component. Migrate to `Accordion` or use React Aria `Disclosure`. + + ## Migration Path 1: Accordion (Opinionated Styled Component) + + Accordion provides preset styling with a similar component structure. + + ```diff + - import { Collapsible } from '@backstage/ui'; + + import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui'; + + - + - } /> + - Content + - + + + + + + + Content + + + ``` + + CSS classes: `.bui-CollapsibleRoot` → `.bui-Accordion`, `.bui-CollapsibleTrigger` → `.bui-AccordionTrigger` (now on heading element), `.bui-CollapsiblePanel` → `.bui-AccordionPanel` + + ## Migration Path 2: React Aria Disclosure (Full Customization) + + For custom styling without preset styles: + + ```tsx + import { Disclosure, Button, DisclosurePanel } from 'react-aria-components'; + + + + Content + ; + ``` + +- 816af0f: **BREAKING**: The `SelectProps` interface now accepts a generic type parameter for selection mode. + + Added searchable and multiple selection support to Select component. The component now accepts `searchable`, `selectionMode`, and `searchPlaceholder` props to enable filtering and multi-selection modes. + + Migration: If you're using `SelectProps` type directly, update from `SelectProps` to `SelectProps<'single' | 'multiple'>`. Component usage remains backward compatible. + +### Patch Changes + +- 35a3614: Fixed CSS issues in Select component including popover width constraints, focus outline behavior, and overflow handling. +- 01476f0: Improved visual consistency of PasswordField, SearchField, and MenuAutocomplete components. +- 836b0c7: Fixed dialog backdrop appearance in dark mode. +- 6d35a6b: Removed `@base-ui-components/react` dependency as all components now use React Aria Components. +- 7839e7b: Added `loading` prop to Button and ButtonIcon components for displaying spinner during async operations. +- a00fb88: Fixed Table Row component to properly support opening links in new tabs via right-click or Cmd+Click when using the href prop. + +## @backstage/plugin-notifications-backend@0.6.0-next.2 + +### Minor Changes + +- 87e597c: Adds support for default configuration for an entire notification channel. + This setting will also be inherited down to origins and topics while still respecting the users individual choices. + + This will be handy if you want to use a "opt-in" strategy. + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-common@0.2.0-next.1 + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-notifications-node@0.2.21-next.2 + +## @backstage/plugin-notifications-common@0.2.0-next.1 + +### Minor Changes + +- 87e597c: Adds support for default configuration for an entire notification channel. + This setting will also be inherited down to origins and topics while still respecting the users individual choices. + + This will be handy if you want to use a "opt-in" strategy. + +## @backstage/backend-test-utils@1.10.0-next.2 + +### Patch Changes + +- f3001fd: Tweak some of the mock services to have more precise types +- Updated dependencies + - @backstage/backend-plugin-api@1.5.0-next.2 + +## @backstage/core-components@0.18.3-next.2 + +### Patch Changes + +- 96ad674: Line numbers in LogViewer will not be selectable in UI anymore + +## @backstage/create-app@0.7.6-next.3 + +### Patch Changes + +- Bumped create-app version. + +## @backstage/frontend-plugin-api@0.12.2-next.2 + +### Patch Changes + +- 7c6a66d: Added support for plugin-relative `attachTo` declarations for extension definitions. This allows for the creation of extension and extension blueprints that attach to other extensions of a particular `kind` in the same plugin, rather than needing to provide the exact extension ID. This is particularly useful when wanting to provide extension blueprints with a built-in hierarchy where the extensions created from one blueprint attach to extensions created from the other blueprint, for example: + + ```ts + // kind: 'tabbed-page' + const parentPage = TabbedPageBlueprint.make({ + params: {....} + }) + // attachTo: { kind: 'tabbed-page', input: 'tabs' } + const child1 = TabContentBlueprint.make({ + name: 'tab1', + params: {....} + }) + ``` + +- Updated dependencies + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-app-visualizer@0.1.25-next.2 + +### Patch Changes + +- 722e2df: Migrated to use `@backstage/ui`. +- Updated dependencies + - @backstage/ui@0.9.0-next.3 + - @backstage/frontend-plugin-api@0.12.2-next.2 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-catalog-graph@0.5.3-next.2 + +### Patch Changes + +- a2d7ae7: Ensure the catalog graph entity card respects the height prop so the visualization scales down properly on wide screens. +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.3-next.2 + - @backstage/frontend-plugin-api@0.12.2-next.2 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-catalog-react@1.21.3-next.2 + +### Patch Changes + +- 36d7582: Added missing i18n +- Updated dependencies + - @backstage/frontend-plugin-api@0.12.2-next.2 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-home@0.8.14-next.1 + +### Patch Changes + +- 2ac5d29: Allow customization of VisitList by adding optional enrichVisit, transformPathname, canSave functions to VisitsStorageApi, along with VisitDisplayProvider for colors, labels +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.3-next.2 + - @backstage/frontend-plugin-api@0.12.2-next.2 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-kubernetes-backend@0.20.4-next.2 + +### Patch Changes + +- 1906d37: Updated dependency `@kubernetes/client-node` to `1.4.0`. +- Updated dependencies + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-kubernetes-common@0.9.8-next.1 + - @backstage/plugin-kubernetes-node@0.3.6-next.2 + +## @backstage/plugin-kubernetes-common@0.9.8-next.1 + +### Patch Changes + +- 1906d37: Updated dependency `@kubernetes/client-node` to `1.4.0`. + +## @backstage/plugin-kubernetes-node@0.3.6-next.2 + +### Patch Changes + +- 1906d37: Updated dependency `@kubernetes/client-node` to `1.4.0`. +- Updated dependencies + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-kubernetes-common@0.9.8-next.1 + +## @backstage/plugin-kubernetes-react@0.5.13-next.1 + +### Patch Changes + +- 1906d37: Updated dependency `@kubernetes/client-node` to `1.4.0`. +- Updated dependencies + - @backstage/plugin-kubernetes-common@0.9.8-next.1 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-notifications@0.5.11-next.1 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-common@0.2.0-next.1 + - @backstage/frontend-plugin-api@0.12.2-next.2 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-notifications-backend-module-email@0.3.16-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-common@0.2.0-next.1 + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-notifications-node@0.2.21-next.2 + +## @backstage/plugin-notifications-backend-module-slack@0.2.1-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-common@0.2.0-next.1 + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-notifications-node@0.2.21-next.2 + +## @backstage/plugin-notifications-node@0.2.21-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-common@0.2.0-next.1 + - @backstage/backend-plugin-api@1.5.0-next.2 + +## @backstage/plugin-org@0.6.46-next.1 + +### Patch Changes + +- 6db9e7e: Improved responsiveness of GroupProfileCard component +- Updated dependencies + - @backstage/plugin-catalog-react@1.21.3-next.2 + - @backstage/frontend-plugin-api@0.12.2-next.2 + - @backstage/core-components@0.18.3-next.2 + +## @backstage/plugin-scaffolder-backend-module-notifications@0.1.16-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-common@0.2.0-next.1 + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-notifications-node@0.2.21-next.2 + +## example-backend@0.0.44-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-backend@0.6.0-next.2 + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-kubernetes-backend@0.20.4-next.2 + - @backstage/plugin-scaffolder-backend-module-notifications@0.1.16-next.2 + - @backstage/plugin-catalog-backend@3.2.0-next.1 + - @backstage/plugin-events-backend@0.5.8-next.1 + - @backstage/plugin-scaffolder-backend@3.0.1-next.1 + - @backstage/plugin-search-backend@2.0.8-next.1 diff --git a/microsite/data/plugins/usage-statistics.yaml b/microsite/data/plugins/usage-statistics.yaml new file mode 100644 index 0000000000..d32fb54ac4 --- /dev/null +++ b/microsite/data/plugins/usage-statistics.yaml @@ -0,0 +1,9 @@ +--- +title: Usage Statistics +author: CodeVerse-GP +authorUrl: https://github.com/CodeVerse-GP +category: Monitoring +description: Shows usage statistics for scaffolder templates +documentation: https://github.com/CodeVerse-GP/usage-statistics/blob/main/README.md +npmPackageName: '@codeverse-gp/plugin-usage-statistics' +addedDate: '2025-11-05' diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 9040e75d86..20c3f39fbd 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -2460,14 +2460,14 @@ __metadata: linkType: hard "@mdx-js/react@npm:^3.0.0": - version: 3.1.0 - resolution: "@mdx-js/react@npm:3.1.0" + version: 3.1.1 + resolution: "@mdx-js/react@npm:3.1.1" dependencies: "@types/mdx": "npm:^2.0.0" peerDependencies: "@types/react": ">=16" react: ">=16" - checksum: 10/cf89d6392c76091622fb647f205e1ab5cbdf5edd4401dde7092138cefc9fbb6d61428aa63557de0bccca3695d5a8854dd4a93b34a27cb8e27369da7eaeaa3e73 + checksum: 10/52a740e2f37761694fa94d4704b7825084b4055616a95c8b8f4c1676190d399ddc5cdbb399ffc45b550beecd30497a7224c2e5b05bf43ecb668c7473641037d1 languageName: node linkType: hard diff --git a/package.json b/package.json index cbefee8708..5039c29e2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "root", - "version": "1.45.0-next.2", + "version": "1.45.0-next.3", "backstage": { "cli": { "new": { diff --git a/packages/backend-defaults/src/alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory.test.ts b/packages/backend-defaults/src/alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory.test.ts new file mode 100644 index 0000000000..2368ea74e8 --- /dev/null +++ b/packages/backend-defaults/src/alpha/entrypoints/instanceMetadata/instanceMetadataServiceFactory.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { instanceMetadataServiceFactory } from './instanceMetadataServiceFactory'; +import { startTestBackend } from '@backstage/backend-test-utils'; +import { instanceMetadataServiceRef } from '@backstage/backend-plugin-api/alpha'; +import Router from 'express-promise-router'; +import request from 'supertest'; + +describe('instanceMetadataServiceFactory', () => { + it('should create an instance metadata service', async () => { + const pluginSubject = createBackendPlugin({ + pluginId: 'my-plugin', + register(reg) { + reg.registerInit({ + deps: { + instanceMetadata: instanceMetadataServiceRef, + httpRouter: coreServices.httpRouter, + }, + async init({ instanceMetadata, httpRouter }) { + const router = Router(); + router.get('/instance-metadata', async (_, res) => { + const metadata = await instanceMetadata.getInstalledFeatures(); + res.json(metadata); + }); + httpRouter.use(router); + }, + }); + }, + }); + + const { server } = await startTestBackend({ + features: [pluginSubject, instanceMetadataServiceFactory], + }); + + const response = await request(server) + .get('/api/my-plugin/instance-metadata') + .expect(200); + expect(response.body).toEqual([{ pluginId: 'my-plugin', type: 'plugin' }]); + }); +}); diff --git a/packages/backend-plugin-api/CHANGELOG.md b/packages/backend-plugin-api/CHANGELOG.md index 0b820e221c..a12734cc98 100644 --- a/packages/backend-plugin-api/CHANGELOG.md +++ b/packages/backend-plugin-api/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/backend-plugin-api +## 1.5.0-next.2 + +### Minor Changes + +- 62fc2de: Explicitly mark `coreServices.rootInstanceMetadata` as a root service. + ## 1.5.0-next.1 ### Minor Changes diff --git a/packages/backend-plugin-api/package.json b/packages/backend-plugin-api/package.json index 2c47843444..90d3959658 100644 --- a/packages/backend-plugin-api/package.json +++ b/packages/backend-plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/backend-plugin-api", - "version": "1.5.0-next.1", + "version": "1.5.0-next.2", "description": "Core API used by Backstage backend plugins", "backstage": { "role": "node-library" diff --git a/packages/backend-plugin-api/report.api.md b/packages/backend-plugin-api/report.api.md index cce3221c80..c46ed254c8 100644 --- a/packages/backend-plugin-api/report.api.md +++ b/packages/backend-plugin-api/report.api.md @@ -234,7 +234,7 @@ export namespace coreServices { const urlReader: ServiceRef; const rootInstanceMetadata: ServiceRef< RootInstanceMetadataService, - 'plugin', + 'root', 'singleton' >; } diff --git a/packages/backend-plugin-api/src/services/definitions/coreServices.ts b/packages/backend-plugin-api/src/services/definitions/coreServices.ts index 62f2f848fc..887b05ee4e 100644 --- a/packages/backend-plugin-api/src/services/definitions/coreServices.ts +++ b/packages/backend-plugin-api/src/services/definitions/coreServices.ts @@ -287,5 +287,6 @@ export namespace coreServices { import('./RootInstanceMetadataService').RootInstanceMetadataService >({ id: 'core.rootInstanceMetadata', + scope: 'root', }); } diff --git a/packages/backend-test-utils/CHANGELOG.md b/packages/backend-test-utils/CHANGELOG.md index 8ce8e92fda..a30fc1fa68 100644 --- a/packages/backend-test-utils/CHANGELOG.md +++ b/packages/backend-test-utils/CHANGELOG.md @@ -1,5 +1,13 @@ # @backstage/backend-test-utils +## 1.10.0-next.2 + +### Patch Changes + +- f3001fd: Tweak some of the mock services to have more precise types +- Updated dependencies + - @backstage/backend-plugin-api@1.5.0-next.2 + ## 1.10.0-next.1 ### Patch Changes diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 8da6bb6640..ea0c2d0c85 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/backend-test-utils", - "version": "1.10.0-next.1", + "version": "1.10.0-next.2", "description": "Test helpers library for Backstage backends", "backstage": { "role": "node-library" diff --git a/packages/backend-test-utils/report.api.md b/packages/backend-test-utils/report.api.md index 915162e9ab..da5002275b 100644 --- a/packages/backend-test-utils/report.api.md +++ b/packages/backend-test-utils/report.api.md @@ -230,11 +230,7 @@ export namespace mockServices { export function events(): EventsService; // (undocumented) export namespace events { - const factory: () => ServiceFactory< - EventsService, - 'plugin', - 'singleton' | 'multiton' - >; + const factory: () => ServiceFactory; const mock: ( partialImpl?: Partial | undefined, ) => ServiceMock; @@ -320,7 +316,7 @@ export namespace mockServices { const // (undocumented) factory: ( options?: Options | undefined, - ) => ServiceFactory; + ) => ServiceFactory; const // (undocumented) mock: ( partialImpl?: Partial | undefined, @@ -355,8 +351,8 @@ export namespace mockServices { const // (undocumented) factory: () => ServiceFactory< RootInstanceMetadataService, - 'plugin', - 'singleton' | 'multiton' + 'root', + 'singleton' >; } // (undocumented) @@ -369,7 +365,7 @@ export namespace mockServices { ) => ServiceMock; } // (undocumented) - export function rootLogger(options?: rootLogger.Options): LoggerService; + export function rootLogger(options?: rootLogger.Options): RootLoggerService; // (undocumented) export namespace rootLogger { // (undocumented) @@ -379,7 +375,7 @@ export namespace mockServices { const // (undocumented) factory: ( options?: Options | undefined, - ) => ServiceFactory; + ) => ServiceFactory; const // (undocumented) mock: ( partialImpl?: Partial | undefined, diff --git a/packages/backend-test-utils/src/services/mockServices.ts b/packages/backend-test-utils/src/services/mockServices.ts index 9ecb59a35b..9e941db3bb 100644 --- a/packages/backend-test-utils/src/services/mockServices.ts +++ b/packages/backend-test-utils/src/services/mockServices.ts @@ -35,7 +35,6 @@ import { DiscoveryService, HttpAuthService, RootInstanceMetadataService, - LoggerService, PermissionsService, RootConfigService, SchedulerService, @@ -44,6 +43,7 @@ import { UserInfoService, coreServices, createServiceFactory, + RootLoggerService, } from '@backstage/backend-plugin-api'; import { ConfigReader } from '@backstage/config'; import { EventsService, eventsServiceRef } from '@backstage/plugin-events-node'; @@ -77,14 +77,15 @@ function createLoggerMock() { function simpleFactoryWithOptions< TService, TScope extends 'root' | 'plugin', + TInstances extends 'singleton' | 'multiton', TOptions extends [options?: object] = [], >( - ref: ServiceRef, + ref: ServiceRef, factory: (...options: TOptions) => TService, -): (...options: TOptions) => ServiceFactory { +): (...options: TOptions) => ServiceFactory { const factoryWithOptions = (...options: TOptions) => createServiceFactory({ - service: ref as ServiceRef, + service: ref as ServiceRef, deps: {}, async factory() { return factory(...options); @@ -93,8 +94,8 @@ function simpleFactoryWithOptions< return Object.assign( factoryWithOptions, factoryWithOptions(...([undefined] as unknown as TOptions)), - ) as ServiceFactory & - ((...options: TOptions) => ServiceFactory); + ) as ServiceFactory & + ((...options: TOptions) => ServiceFactory); } /** @@ -178,7 +179,7 @@ export namespace mockServices { })); } - export function rootLogger(options?: rootLogger.Options): LoggerService { + export function rootLogger(options?: rootLogger.Options): RootLoggerService { return MockRootLoggerService.create(options); } export namespace rootLogger { diff --git a/packages/backend/CHANGELOG.md b/packages/backend/CHANGELOG.md index f59395af02..cb84bcbc4c 100644 --- a/packages/backend/CHANGELOG.md +++ b/packages/backend/CHANGELOG.md @@ -1,5 +1,19 @@ # example-backend +## 0.0.44-next.2 + +### Patch Changes + +- Updated dependencies + - @backstage/plugin-notifications-backend@0.6.0-next.2 + - @backstage/backend-plugin-api@1.5.0-next.2 + - @backstage/plugin-kubernetes-backend@0.20.4-next.2 + - @backstage/plugin-scaffolder-backend-module-notifications@0.1.16-next.2 + - @backstage/plugin-catalog-backend@3.2.0-next.1 + - @backstage/plugin-events-backend@0.5.8-next.1 + - @backstage/plugin-scaffolder-backend@3.0.1-next.1 + - @backstage/plugin-search-backend@2.0.8-next.1 + ## 0.0.44-next.1 ### Patch Changes diff --git a/packages/backend/package.json b/packages/backend/package.json index 3f2d388318..199304ae5a 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "example-backend", - "version": "0.0.44-next.1", + "version": "0.0.44-next.2", "backstage": { "role": "backend" }, diff --git a/packages/core-components/CHANGELOG.md b/packages/core-components/CHANGELOG.md index e7a6b37f53..b4d7f1c7b0 100644 --- a/packages/core-components/CHANGELOG.md +++ b/packages/core-components/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/core-components +## 0.18.3-next.2 + +### Patch Changes + +- 96ad674: Line numbers in LogViewer will not be selectable in UI anymore + ## 0.18.3-next.1 ### Patch Changes diff --git a/packages/core-components/package.json b/packages/core-components/package.json index 32548a13cc..958f566c88 100644 --- a/packages/core-components/package.json +++ b/packages/core-components/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/core-components", - "version": "0.18.3-next.1", + "version": "0.18.3-next.2", "description": "Core components used by Backstage plugins and apps", "backstage": { "role": "web-library" diff --git a/packages/create-app/CHANGELOG.md b/packages/create-app/CHANGELOG.md index 5e91ba3fa8..f86586b990 100644 --- a/packages/create-app/CHANGELOG.md +++ b/packages/create-app/CHANGELOG.md @@ -1,5 +1,11 @@ # @backstage/create-app +## 0.7.6-next.3 + +### Patch Changes + +- Bumped create-app version. + ## 0.7.6-next.2 ### Patch Changes diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 810d5598c4..d0b5850d58 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/create-app", - "version": "0.7.6-next.2", + "version": "0.7.6-next.3", "description": "A CLI that helps you create your own Backstage app", "backstage": { "role": "cli" diff --git a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts index 2eacd0ae67..3ccdf5258f 100644 --- a/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts +++ b/packages/frontend-app-api/src/tree/resolveAppNodeSpecs.test.ts @@ -44,6 +44,7 @@ function makeExt( version: 'v1', id, attachTo: { id: attachId, input: 'default' }, + inputs: {}, disabled: status === 'disabled', toString: expect.any(Function), } as Extension; @@ -61,6 +62,7 @@ function makeExtDef( name, attachTo: { id: attachId, input: 'default' }, disabled: status === 'disabled', + inputs: {}, override: () => ({} as ExtensionDefinition), } as ExtensionDefinition; } diff --git a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts index 08f787a4ae..54953c169a 100644 --- a/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts +++ b/packages/frontend-internal/src/wiring/InternalExtensionDefinition.ts @@ -17,7 +17,7 @@ import { ApiHolder, AppNode, - ExtensionAttachToSpec, + ExtensionDefinitionAttachTo, ExtensionDataValue, ExtensionDataRef, ExtensionDefinition, @@ -36,7 +36,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly kind?: string; readonly namespace?: string; readonly name?: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; readonly configSchema?: PortableSchema; readonly inputs: { @@ -67,7 +67,7 @@ export const OpaqueExtensionDefinition = OpaqueType.create<{ readonly kind?: string; readonly namespace?: string; readonly name?: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionDefinitionAttachTo; readonly disabled: boolean; readonly configSchema?: PortableSchema; readonly inputs: { [inputName in string]: ExtensionInput }; diff --git a/packages/frontend-internal/src/wiring/InternalExtensionInput.ts b/packages/frontend-internal/src/wiring/InternalExtensionInput.ts new file mode 100644 index 0000000000..b816890f1a --- /dev/null +++ b/packages/frontend-internal/src/wiring/InternalExtensionInput.ts @@ -0,0 +1,36 @@ +/* + * 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. + */ + +import { ExtensionInput } from '@backstage/frontend-plugin-api'; +import { OpaqueType } from '@internal/opaque'; + +export type ExtensionInputContext = { + input: string; + kind?: string; + name?: string; +}; + +export const OpaqueExtensionInput = OpaqueType.create<{ + public: ExtensionInput; + versions: { + readonly version: undefined; + readonly context?: ExtensionInputContext; + withContext(context: ExtensionInputContext): ExtensionInput; + }; +}>({ + type: '@backstage/ExtensionInput', + versions: [undefined], +}); diff --git a/packages/frontend-internal/src/wiring/index.ts b/packages/frontend-internal/src/wiring/index.ts index b61294cb1f..1efcc7d16d 100644 --- a/packages/frontend-internal/src/wiring/index.ts +++ b/packages/frontend-internal/src/wiring/index.ts @@ -15,6 +15,10 @@ */ export { createExtensionDataContainer } from './createExtensionDataContainer'; -export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef'; export { OpaqueExtensionDefinition } from './InternalExtensionDefinition'; +export { + OpaqueExtensionInput, + type ExtensionInputContext, +} from './InternalExtensionInput'; export { OpaqueFrontendPlugin } from './InternalFrontendPlugin'; +export { OpaqueSwappableComponentRef } from './InternalSwappableComponentRef'; diff --git a/packages/frontend-plugin-api/CHANGELOG.md b/packages/frontend-plugin-api/CHANGELOG.md index 1bf8f64a43..9c97055ceb 100644 --- a/packages/frontend-plugin-api/CHANGELOG.md +++ b/packages/frontend-plugin-api/CHANGELOG.md @@ -1,5 +1,26 @@ # @backstage/frontend-plugin-api +## 0.12.2-next.2 + +### Patch Changes + +- 7c6a66d: Added support for plugin-relative `attachTo` declarations for extension definitions. This allows for the creation of extension and extension blueprints that attach to other extensions of a particular `kind` in the same plugin, rather than needing to provide the exact extension ID. This is particularly useful when wanting to provide extension blueprints with a built-in hierarchy where the extensions created from one blueprint attach to extensions created from the other blueprint, for example: + + ```ts + // kind: 'tabbed-page' + const parentPage = TabbedPageBlueprint.make({ + params: {....} + }) + // attachTo: { kind: 'tabbed-page', input: 'tabs' } + const child1 = TabContentBlueprint.make({ + name: 'tab1', + params: {....} + }) + ``` + +- Updated dependencies + - @backstage/core-components@0.18.3-next.2 + ## 0.12.2-next.1 ### Patch Changes diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index c00227563d..fec762ff63 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/frontend-plugin-api", - "version": "0.12.2-next.1", + "version": "0.12.2-next.2", "backstage": { "role": "web-library" }, diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index b7c5b08528..c3dd12119f 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -259,7 +259,7 @@ export interface AppNodeInstance { // @public export interface AppNodeSpec { // (undocumented) - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionAttachTo; // (undocumented) readonly config?: unknown; // (undocumented) @@ -407,6 +407,7 @@ export function createExtension< UFactoryOutput extends ExtensionDataValue, const TKind extends string | undefined = undefined, const TName extends string | undefined = undefined, + UParentInputs extends ExtensionDataRef = ExtensionDataRef, >( options: CreateExtensionOptions< TKind, @@ -414,7 +415,8 @@ export function createExtension< UOutput, TInputs, TConfigSchema, - UFactoryOutput + UFactoryOutput, + UParentInputs >, ): ExtensionDefinition<{ config: string extends keyof TConfigSchema @@ -454,6 +456,7 @@ export function createExtensionBlueprint< }, UFactoryOutput extends ExtensionDataValue, TKind extends string, + UParentInputs extends ExtensionDataRef, TDataRefs extends { [name in string]: ExtensionDataRef; } = never, @@ -465,7 +468,8 @@ export function createExtensionBlueprint< TInputs, TConfigSchema, UFactoryOutput, - TDataRefs + TDataRefs, + UParentInputs >, ): ExtensionBlueprint<{ kind: TKind; @@ -508,9 +512,11 @@ export type CreateExtensionBlueprintOptions< TDataRefs extends { [name in string]: ExtensionDataRef; }, + UParentInputs extends ExtensionDataRef, > = { kind: TKind; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -589,10 +595,12 @@ export type CreateExtensionOptions< [key: string]: (zImpl: typeof z) => z.ZodType; }, UFactoryOutput extends ExtensionDataValue, + UParentInputs extends ExtensionDataRef, > = { kind?: TKind; name?: TName; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -839,7 +847,7 @@ export interface Extension { } // @public (undocumented) -export type ExtensionAttachToSpec = +export type ExtensionAttachTo = | { id: string; input: string; @@ -849,6 +857,9 @@ export type ExtensionAttachToSpec = input: string; }>; +// @public @deprecated (undocumented) +export type ExtensionAttachToSpec = ExtensionAttachTo; + // @public (undocumented) export interface ExtensionBlueprint< T extends ExtensionBlueprintParameters = ExtensionBlueprintParameters, @@ -859,9 +870,11 @@ export interface ExtensionBlueprint< make< TName extends string | undefined, TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -887,9 +900,16 @@ export interface ExtensionBlueprint< TExtraInputs extends { [inputName in string]: ExtensionInput; }, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -1073,6 +1093,11 @@ export type ExtensionDefinition< > = { $$type: '@backstage/ExtensionDefinition'; readonly T: T; + readonly inputs: { + [K in keyof T['inputs']]: ExtensionInput< + T['inputs'][K] extends ExtensionInput ? IData : never + >; + }; override< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; @@ -1083,10 +1108,17 @@ export type ExtensionDefinition< [inputName in string]: ExtensionInput; }, TParamsInput extends AnyParamsInput_2>, + UParentInputs extends ExtensionDataRef, >( args: Expand< { - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -1168,6 +1200,41 @@ export type ExtensionDefinition< }>; }; +// @public +export type ExtensionDefinitionAttachTo< + UParentInputs extends ExtensionDataRef = ExtensionDataRef, +> = + | { + id: string; + input: string; + relative?: never; + } + | { + relative: { + kind?: string; + name?: string; + }; + input: string; + id?: never; + } + | ExtensionInput + | Array< + | { + id: string; + input: string; + relative?: never; + } + | { + relative: { + kind?: string; + name?: string; + }; + input: string; + id?: never; + } + | ExtensionInput + >; + // @public (undocumented) export type ExtensionDefinitionParameters = { kind?: string; @@ -1215,13 +1282,13 @@ export interface ExtensionInput< }, > { // (undocumented) - $$type: '@backstage/ExtensionInput'; + readonly $$type: '@backstage/ExtensionInput'; // (undocumented) - config: TConfig; + readonly config: TConfig; // (undocumented) - extensionData: Array; + readonly extensionData: Array; // (undocumented) - replaces?: Array<{ + readonly replaces?: Array<{ id: string; input: string; }>; diff --git a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts index f4fd2f3a6f..6c318d782a 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AppTreeApi.ts @@ -15,12 +15,8 @@ */ import { createApiRef } from '@backstage/core-plugin-api'; -import { - FrontendPlugin, - Extension, - ExtensionDataRef, - ExtensionAttachToSpec, -} from '../../wiring'; +import { FrontendPlugin, Extension, ExtensionDataRef } from '../../wiring'; +import { ExtensionAttachTo } from '../../wiring/resolveExtensionDefinition'; /** * The specification for this {@link AppNode} in the {@link AppTree}. @@ -33,7 +29,7 @@ import { */ export interface AppNodeSpec { readonly id: string; - readonly attachTo: ExtensionAttachToSpec; + readonly attachTo: ExtensionAttachTo; readonly extension: Extension; readonly disabled: boolean; readonly config?: unknown; diff --git a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts index 471e933b12..988c5a15c0 100644 --- a/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts +++ b/packages/frontend-plugin-api/src/blueprints/ApiBlueprint.test.ts @@ -203,10 +203,16 @@ describe('ApiBlueprint', () => { "optional": false, "singleton": false, }, + "context": { + "input": "test", + "kind": "api", + "name": "test", + }, "extensionData": [ [Function], ], "replaces": undefined, + "withContext": [Function], }, }, "kind": "api", diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts index 59a81f1b59..4996fbc535 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.test.ts @@ -216,6 +216,167 @@ describe('createExtension', () => { ); }); + it('should create an extension with relative attachment points', () => { + const extension = createExtension({ + attachTo: [ + { relative: {}, input: 'tabs' }, + { relative: { kind: 'page' }, input: 'tabs' }, + { relative: { name: 'index' }, input: 'tabs' }, + { relative: { kind: 'page', name: 'index' }, input: 'tabs' }, + ], + output: [stringDataRef], + factory: () => [stringDataRef('bar')], + }); + expect(String(extension)).toBe( + 'ExtensionDefinition{attachTo=@tabs+page:@tabs+/index@tabs+page:/index@tabs}', + ); + }); + + it('should create an extension with relative attachment points by reference', () => { + const baseOpts = { + attachTo: { id: 'root', input: 'children' }, + inputs: { + tabs: createExtensionInput([stringDataRef]), + }, + output: [], + factory: () => [], + }; + const parent1 = createExtension({ + ...baseOpts, + }); + const parent2 = createExtension({ + ...baseOpts, + kind: 'page', + }); + const parent3 = createExtension({ + ...baseOpts, + name: 'index', + }); + const parent4 = createExtension({ + ...baseOpts, + inputs: {}, + kind: 'page', + name: 'index', + }).override({ + inputs: { + otherTabs: createExtensionInput([stringDataRef]), + }, + factory: () => [], + }); + const extension = createExtension({ + attachTo: [ + parent1.inputs.tabs, + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + output: [stringDataRef], + factory: () => [stringDataRef('bar')], + }); + expect(String(extension)).toBe( + 'ExtensionDefinition{attachTo=@tabs+page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + const overrdeExtension = extension.override({ + attachTo: [ + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + }); + expect(String(overrdeExtension)).toBe( + 'ExtensionDefinition{attachTo=page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + }); + + it('should provide type safe attachments by reference', () => { + const parent = createExtension({ + attachTo: { id: 'root', input: 'children' }, + inputs: { + string: createExtensionInput([stringDataRef]), + stringOpt: createExtensionInput([stringDataRef.optional()]), + number: createExtensionInput([numberDataRef]), + numberOpt: createExtensionInput([numberDataRef.optional()]), + both: createExtensionInput([stringDataRef, numberDataRef]), + bothOptString: createExtensionInput([ + stringDataRef.optional(), + numberDataRef, + ]), + bothOptNumber: createExtensionInput([ + stringDataRef, + numberDataRef.optional(), + ]), + bothOpt: createExtensionInput([ + stringDataRef.optional(), + numberDataRef.optional(), + ]), + }, + output: [], + factory: () => [], + }); + const strOutExt = createExtension({ + attachTo: parent.inputs.string, + output: [stringDataRef], + factory: () => [stringDataRef('str')], + }); + strOutExt.override({ + attachTo: parent.inputs.string, + }); + strOutExt.override({ + attachTo: parent.inputs.stringOpt, + }); + strOutExt.override({ + // @ts-expect-error + attachTo: parent.inputs.number, + }); + strOutExt.override({ + attachTo: parent.inputs.numberOpt, + }); + strOutExt.override({ + // @ts-expect-error + attachTo: parent.inputs.both, + }); + strOutExt.override({ + attachTo: parent.inputs.bothOptNumber, + }); + strOutExt.override({ + // @ts-expect-error + attachTo: parent.inputs.bothOptString, + }); + strOutExt.override({ + attachTo: parent.inputs.bothOpt, + }); + const numberOutExt = createExtension({ + // @ts-expect-error + attachTo: parent.inputs.string, + output: [numberDataRef], + factory: () => [numberDataRef(1)], + }); + numberOutExt.override({ + // @ts-expect-error + attachTo: parent.inputs.string, + }); + numberOutExt.override({ + attachTo: parent.inputs.number, + }); + const bothOutExt = createExtension({ + attachTo: parent.inputs.both, + output: [numberDataRef, stringDataRef], + factory: () => [numberDataRef(1), stringDataRef('str')], + }); + // TODO(Rugvip): Potentially encapsulate the parent input type in the extension, until then we can't verify this + bothOutExt.override({ + output: [numberDataRef.optional(), stringDataRef], + factory: () => [stringDataRef('str')], + }); + bothOutExt.override({ + // @ts-expect-error + attachTo: parent.inputs.both, + output: [numberDataRef.optional(), stringDataRef], + factory: () => [stringDataRef('str')], + }); + expect('types').not.toBe('broken'); + }); + it('should create an extension with input', () => { const extension = createExtension({ attachTo: { id: 'root', input: 'default' }, diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 8ed4b8b259..0fec40f4c9 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -20,7 +20,10 @@ import { ResolvedInputValueOverrides, resolveInputOverrides, } from './resolveInputOverrides'; -import { createExtensionDataContainer } from '@internal/frontend'; +import { + createExtensionDataContainer, + OpaqueExtensionInput, +} from '@internal/frontend'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; import { ExtensionInput } from './createExtensionInput'; import { z } from 'zod'; @@ -90,32 +93,89 @@ type JoinStringUnion< : JoinStringUnion : TResult; +/** @ignore */ +export type RequiredExtensionIds = + UExtensionData extends any + ? UExtensionData['config']['optional'] extends true + ? never + : UExtensionData['id'] + : never; + /** @ignore */ export type VerifyExtensionFactoryOutput< UDeclaredOutput extends ExtensionDataRef, UFactoryOutput extends ExtensionDataValue, -> = ( - UDeclaredOutput extends any - ? UDeclaredOutput['config']['optional'] extends true - ? never - : UDeclaredOutput['id'] - : never -) extends infer IRequiredOutputIds - ? [IRequiredOutputIds] extends [UFactoryOutput['id']] - ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] - ? {} - : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< - Exclude - >}` - : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< - Exclude +> = [RequiredExtensionIds] extends [UFactoryOutput['id']] + ? [UFactoryOutput['id']] extends [UDeclaredOutput['id']] + ? {} + : `Error: The extension factory has undeclared output(s): ${JoinStringUnion< + Exclude >}` - : never; + : `Error: The extension factory is missing the following output(s): ${JoinStringUnion< + Exclude, UFactoryOutput['id']> + >}`; -/** @public */ -export type ExtensionAttachToSpec = - | { id: string; input: string } - | Array<{ id: string; input: string }>; +/** @ignore */ +export type VerifyExtensionAttachTo< + UOutput extends ExtensionDataRef, + UParentInput extends ExtensionDataRef, +> = ExtensionDataRef extends UParentInput + ? {} + : [RequiredExtensionIds] extends [RequiredExtensionIds] + ? {} + : `Error: This parent extension input requires the following extension data, but it is not declared as guaranteed output of this extension: ${JoinStringUnion< + Exclude, RequiredExtensionIds> + >}`; + +/** + * Specifies where an extension should attach in the extension tree. + * + * @remarks + * + * A standard attachment point declaration will specify the ID of the parent extension, as well as the name of the input to attach to. + * + * There are three more advanced forms that are available for more complex use-cases: + * + * 1. Relative attachment points: using the `relative` property instead of `id`, the attachment point is resolved relative to the current plugin. + * 2. Extension input references: using a reference in code to another extension's input in the same plugin. These references are always relative. + * 3. Array of attachment points: an array of attachment points can be used to clone and attach to multiple extensions at once. + * + * @example + * ```ts + * // Attach to a specific extension by full ID + * { id: 'app/routes', input: 'routes' } + * + * // Attach to an extension in the same plugin by kind + * { relative: { kind: 'page' }, input: 'actions' } + * + * // Attach to a specific input of another extension + * const page = ParentBlueprint.make({ ... }); + * const child = ChildBlueprint.make({ attachTo: page.inputs.children }); + * + * // Attach to multiple parents at once + * [ + * { id: 'page/home', input: 'widgets' }, + * { relative: { kind: 'page' }, input: 'widgets' }, + * ] + * ``` + * + * @public + */ +export type ExtensionDefinitionAttachTo< + UParentInputs extends ExtensionDataRef = ExtensionDataRef, +> = + | { id: string; input: string; relative?: never } + | { relative: { kind?: string; name?: string }; input: string; id?: never } + | ExtensionInput + | Array< + | { id: string; input: string; relative?: never } + | { + relative: { kind?: string; name?: string }; + input: string; + id?: never; + } + | ExtensionInput + >; /** @public */ export type CreateExtensionOptions< @@ -125,10 +185,12 @@ export type CreateExtensionOptions< TInputs extends { [inputName in string]: ExtensionInput }, TConfigSchema extends { [key: string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, + UParentInputs extends ExtensionDataRef, > = { kind?: TKind; name?: TName; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -177,6 +239,15 @@ export type ExtensionDefinition< $$type: '@backstage/ExtensionDefinition'; readonly T: T; + /** + * References to the inputs of this extension, which can be used to attach child extensions. + */ + readonly inputs: { + [K in keyof T['inputs']]: ExtensionInput< + T['inputs'][K] extends ExtensionInput ? IData : never + >; + }; + override< TExtensionConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType; @@ -185,10 +256,17 @@ export type ExtensionDefinition< UNewOutput extends ExtensionDataRef, TExtraInputs extends { [inputName in string]: ExtensionInput }, TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, >( args: Expand< { - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -270,6 +348,30 @@ export type ExtensionDefinition< }>; }; +/** + * @internal + */ +function bindInputs( + inputs: { [inputName in string]: ExtensionInput } | undefined, + kind?: string, + name?: string, +) { + if (!inputs) { + return {}; + } + + return Object.fromEntries( + Object.entries(inputs).map(([inputName, input]) => [ + inputName, + OpaqueExtensionInput.toInternal(input).withContext({ + kind, + name, + input: inputName, + }), + ]), + ); +} + /** * Creates a new extension definition for installation in a Backstage app. * @@ -312,6 +414,7 @@ export function createExtension< UFactoryOutput extends ExtensionDataValue, const TKind extends string | undefined = undefined, const TName extends string | undefined = undefined, + UParentInputs extends ExtensionDataRef = ExtensionDataRef, >( options: CreateExtensionOptions< TKind, @@ -319,7 +422,8 @@ export function createExtension< UOutput, TInputs, TConfigSchema, - UFactoryOutput + UFactoryOutput, + UParentInputs >, ): ExtensionDefinition<{ config: string extends keyof TConfigSchema @@ -383,7 +487,7 @@ export function createExtension< name: options.name, attachTo: options.attachTo, disabled: options.disabled ?? false, - inputs: options.inputs ?? {}, + inputs: bindInputs(options.inputs, options.kind, options.name), output: options.output, configSchema, factory: options.factory, @@ -395,12 +499,41 @@ export function createExtension< if (options.name) { parts.push(`name=${options.name}`); } - parts.push( - `attachTo=${[options.attachTo] - .flat() - .map(a => `${a.id}@${a.input}`) - .join('+')}`, - ); + const attachTo = [options.attachTo] + .flat() + .map(aAny => { + const a = aAny as ExtensionDefinitionAttachTo; + if (OpaqueExtensionInput.isType(a)) { + const { context } = OpaqueExtensionInput.toInternal(a); + if (!context) { + return ''; + } + let id = ''; + if (context?.kind) { + id = `${context?.kind}:${id}`; + } + if (context?.name) { + id = `${id}/${context?.name}`; + } + return `${id}@${context.input}`; + } + if ('relative' in a && a.relative) { + let id = ''; + if (a.relative.kind) { + id = `${a.relative.kind}:${id}`; + } + if (a.relative.name) { + id = `${id}/${a.relative.name}`; + } + return `${id}@${a.input}`; + } + if ('id' in a) { + return `${a.id}@${a.input}`; + } + throw new Error('Invalid attachment point specification'); + }) + .join('+'); + parts.push(`attachTo=${attachTo}`); return `ExtensionDefinition{${parts.join(',')}}`; }, override(overrideOptions) { @@ -428,9 +561,17 @@ export function createExtension< return createExtension({ kind: options.kind, name: options.name, - attachTo: overrideOptions.attachTo ?? options.attachTo, + attachTo: (overrideOptions.attachTo ?? + options.attachTo) as ExtensionDefinitionAttachTo, disabled: overrideOptions.disabled ?? options.disabled, - inputs: { ...overrideOptions.inputs, ...options.inputs }, + inputs: bindInputs( + { + ...(options.inputs ?? {}), + ...(overrideOptions.inputs ?? {}), + }, + options.kind, + options.name, + ), output: (overrideOptions.output ?? options.output) as ExtensionDataRef[], config: diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index e4cc876ff0..4759441187 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -30,7 +30,7 @@ import { } from './createExtensionDataRef'; import { createExtensionInput } from './createExtensionInput'; import { RouteRef } from '../routing'; -import { ExtensionDefinition } from './createExtension'; +import { createExtension, ExtensionDefinition } from './createExtension'; import { createExtensionDataContainer, OpaqueExtensionDefinition, @@ -1345,6 +1345,235 @@ describe('createExtensionBlueprint', () => { ).toThrow('Refused to override params and factory at the same time'); }); + describe('with relative attachment points', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + + it('should create an extension with relative attachment points', () => { + const blueprint = createExtensionBlueprint({ + kind: 'test', + attachTo: [ + { relative: {}, input: 'tabs' }, + { relative: { kind: 'page' }, input: 'tabs' }, + { relative: { name: 'index' }, input: 'tabs' }, + { relative: { kind: 'page', name: 'index' }, input: 'tabs' }, + ], + output: [dataRef], + factory: () => [dataRef('bar')], + }); + + expect(String(blueprint.make({ params: {} }))).toBe( + 'ExtensionDefinition{kind=test,attachTo=@tabs+page:@tabs+/index@tabs+page:/index@tabs}', + ); + expect( + String( + blueprint.make({ + attachTo: [ + { relative: { kind: 'page' }, input: 'tabs' }, + { relative: { name: 'index' }, input: 'tabs' }, + { relative: { kind: 'page', name: 'index' }, input: 'tabs' }, + ], + params: {}, + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:@tabs+/index@tabs+page:/index@tabs}', + ); + expect( + String( + blueprint.makeWithOverrides({ + attachTo: { + relative: { kind: 'page', name: 'index' }, + input: 'tabs', + }, + factory: orig => orig({}), + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:/index@tabs}', + ); + }); + + it('should create an extension with relative attachment points by reference', () => { + const baseOpts = { + attachTo: { id: 'root', input: 'children' }, + inputs: { + tabs: createExtensionInput([dataRef]), + }, + output: [], + factory: () => [], + }; + const parent1 = createExtension({ + ...baseOpts, + }); + const parent2 = createExtension({ + ...baseOpts, + kind: 'page', + }); + const parent3 = createExtension({ + ...baseOpts, + name: 'index', + }); + const parent4 = createExtension({ + ...baseOpts, + inputs: {}, + kind: 'page', + name: 'index', + }).override({ + inputs: { + otherTabs: createExtensionInput([dataRef]), + }, + factory: () => [], + }); + const blueprint = createExtensionBlueprint({ + kind: 'test', + attachTo: [ + parent1.inputs.tabs, + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + output: [dataRef], + factory: () => [dataRef('bar')], + }); + expect(String(blueprint.make({ params: {} }))).toBe( + 'ExtensionDefinition{kind=test,attachTo=@tabs+page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + expect( + String( + blueprint.make({ + attachTo: [ + parent2.inputs.tabs, + parent3.inputs.tabs, + parent4.inputs.otherTabs, + ], + params: {}, + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:@tabs+/index@tabs+page:/index@otherTabs}', + ); + expect( + String( + blueprint.makeWithOverrides({ + attachTo: parent4.inputs.otherTabs, + factory: orig => orig({}), + }), + ), + ).toBe( + 'ExtensionDefinition{kind=test,attachTo=page:/index@otherTabs}', + ); + }); + + it('should provide type safe attachments by reference', () => { + const stringDataRef = createExtensionDataRef().with({ + id: 'test.string', + }); + const numberDataRef = createExtensionDataRef().with({ + id: 'test.number', + }); + + const parent = createExtensionBlueprint({ + kind: 'test-parent', + attachTo: { id: 'root', input: 'children' }, + inputs: { + string: createExtensionInput([stringDataRef]), + stringOpt: createExtensionInput([stringDataRef.optional()]), + number: createExtensionInput([numberDataRef]), + numberOpt: createExtensionInput([numberDataRef.optional()]), + both: createExtensionInput([stringDataRef, numberDataRef]), + bothOptString: createExtensionInput([ + stringDataRef.optional(), + numberDataRef, + ]), + bothOptNumber: createExtensionInput([ + stringDataRef, + numberDataRef.optional(), + ]), + bothOpt: createExtensionInput([ + stringDataRef.optional(), + numberDataRef.optional(), + ]), + }, + output: [], + factory: () => [], + }).make({ params: {} }); + const strOutExt = createExtensionBlueprint({ + kind: 'test', + attachTo: parent.inputs.string, + output: [stringDataRef], + factory: () => [stringDataRef('str')], + }); + strOutExt.make({ + attachTo: parent.inputs.string, + params: {}, + }); + strOutExt.makeWithOverrides({ + attachTo: parent.inputs.stringOpt, + factory: orig => orig({}), + }); + strOutExt.make({ + // @ts-expect-error + attachTo: parent.inputs.number, + params: {}, + }); + strOutExt.makeWithOverrides({ + attachTo: parent.inputs.numberOpt, + factory: orig => orig({}), + }); + strOutExt.make({ + // @ts-expect-error + attachTo: parent.inputs.both, + params: {}, + }); + strOutExt.make({ + attachTo: parent.inputs.bothOptNumber, + params: {}, + }); + strOutExt.make({ + // @ts-expect-error + attachTo: parent.inputs.bothOptString, + params: {}, + }); + strOutExt.make({ + attachTo: parent.inputs.bothOpt, + params: {}, + }); + const numberOutExt = createExtensionBlueprint({ + kind: 'test', + // @ts-expect-error + attachTo: parent.inputs.string, + output: [numberDataRef], + factory: () => [numberDataRef(1)], + }); + numberOutExt.make({ + // @ts-expect-error + attachTo: parent.inputs.string, + params: {}, + }); + numberOutExt.makeWithOverrides({ + attachTo: parent.inputs.number, + factory: orig => orig({}), + }); + const bothOutExt = createExtensionBlueprint({ + kind: 'test', + attachTo: parent.inputs.both, + output: [numberDataRef, stringDataRef], + factory: () => [numberDataRef(1), stringDataRef('str')], + }); + bothOutExt.makeWithOverrides({ + output: [numberDataRef.optional(), stringDataRef], + factory: orig => orig({}), + }); + bothOutExt.makeWithOverrides({ + // @ts-expect-error + attachTo: parent.inputs.both, + output: [numberDataRef.optional(), stringDataRef], + factory: orig => orig({}), + }); + expect('types').not.toBe('broken'); + }); + }); + describe('with advanced parameter types', () => { const testDataRef = createExtensionDataRef().with({ id: 'test' }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index f8dd55ca80..f9a3731743 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -18,12 +18,13 @@ import { ApiHolder, AppNode } from '../apis'; import { Expand } from '@backstage/types'; import { OpaqueType } from '@internal/opaque'; import { - ExtensionAttachToSpec, + ExtensionDefinitionAttachTo, ExtensionDefinition, ResolvedExtensionInputs, VerifyExtensionFactoryOutput, createExtension, ctxParamsSymbol, + VerifyExtensionAttachTo, } from './createExtension'; import { z } from 'zod'; import { ExtensionInput } from './createExtensionInput'; @@ -107,9 +108,11 @@ export type CreateExtensionBlueprintOptions< TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, TDataRefs extends { [name in string]: ExtensionDataRef }, + UParentInputs extends ExtensionDataRef, > = { kind: TKind; - attachTo: ExtensionAttachToSpec; + attachTo: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo; disabled?: boolean; inputs?: TInputs; output: Array; @@ -212,9 +215,11 @@ export interface ExtensionBlueprint< make< TName extends string | undefined, TParamsInput extends AnyParamsInput>, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo, UParentInputs>; disabled?: boolean; params: TParamsInput extends ExtensionBlueprintDefineParams ? TParamsInput @@ -245,9 +250,16 @@ export interface ExtensionBlueprint< UFactoryOutput extends ExtensionDataValue, UNewOutput extends ExtensionDataRef, TExtraInputs extends { [inputName in string]: ExtensionInput }, + UParentInputs extends ExtensionDataRef, >(args: { name?: TName; - attachTo?: ExtensionAttachToSpec; + attachTo?: ExtensionDefinitionAttachTo & + VerifyExtensionAttachTo< + ExtensionDataRef extends UNewOutput + ? NonNullable + : UNewOutput, + UParentInputs + >; disabled?: boolean; inputs?: TExtraInputs & { [KName in keyof T['inputs']]?: `Error: Input '${KName & @@ -444,6 +456,7 @@ export function createExtensionBlueprint< TConfigSchema extends { [key in string]: (zImpl: typeof z) => z.ZodType }, UFactoryOutput extends ExtensionDataValue, TKind extends string, + UParentInputs extends ExtensionDataRef, TDataRefs extends { [name in string]: ExtensionDataRef } = never, >( options: CreateExtensionBlueprintOptions< @@ -453,7 +466,8 @@ export function createExtensionBlueprint< TInputs, TConfigSchema, UFactoryOutput, - TDataRefs + TDataRefs, + UParentInputs >, ): ExtensionBlueprint<{ kind: TKind; @@ -489,7 +503,8 @@ export function createExtensionBlueprint< return createExtension({ kind: options.kind, name: args.name, - attachTo: args.attachTo ?? options.attachTo, + attachTo: (args.attachTo ?? + options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, inputs: options.inputs, output: options.output as ExtensionDataRef[], @@ -505,7 +520,8 @@ export function createExtensionBlueprint< return createExtension({ kind: options.kind, name: args.name, - attachTo: args.attachTo ?? options.attachTo, + attachTo: (args.attachTo ?? + options.attachTo) as ExtensionDefinitionAttachTo, disabled: args.disabled ?? options.disabled, inputs: { ...args.inputs, ...options.inputs }, output: (args.output ?? options.output) as ExtensionDataRef[], diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts index 084758b793..8511f79b4e 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { OpaqueExtensionInput } from '@internal/frontend'; import { createExtensionDataRef } from './createExtensionDataRef'; import { ExtensionInput, createExtensionInput } from './createExtensionInput'; @@ -29,6 +30,7 @@ describe('createExtensionInput', () => { $$type: '@backstage/ExtensionInput', extensionData: [stringDataRef, numberDataRef], config: { singleton: false, optional: false }, + withContext: expect.any(Function), }); const x1: ExtensionInput< @@ -54,6 +56,20 @@ describe('createExtensionInput', () => { unused(x1, x2, x3, x4); }); + it('should attach a context to the input', () => { + const input = createExtensionInput([stringDataRef, numberDataRef]); + const context = { input: 'test1', kind: 'test2', name: 'test3' }; + const inputWithContext = + OpaqueExtensionInput.toInternal(input).withContext(context); + expect(inputWithContext).toEqual({ + $$type: '@backstage/ExtensionInput', + extensionData: [stringDataRef, numberDataRef], + config: { singleton: false, optional: false }, + withContext: expect.any(Function), + context, + }); + }); + it('should create a singleton input', () => { const input = createExtensionInput([stringDataRef, numberDataRef], { singleton: true, @@ -62,6 +78,7 @@ describe('createExtensionInput', () => { $$type: '@backstage/ExtensionInput', extensionData: [stringDataRef, numberDataRef], config: { singleton: true, optional: false }, + withContext: expect.any(Function), }); const x1: ExtensionInput< @@ -96,6 +113,7 @@ describe('createExtensionInput', () => { $$type: '@backstage/ExtensionInput', extensionData: [stringDataRef, numberDataRef], config: { singleton: true, optional: true }, + withContext: expect.any(Function), }); const x1: ExtensionInput< diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts index 24e14c45b2..57040c8adf 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionInput.ts @@ -14,6 +14,10 @@ * limitations under the License. */ +import { + ExtensionInputContext, + OpaqueExtensionInput, +} from '@internal/frontend'; import { ExtensionDataRef } from './createExtensionDataRef'; /** @public */ @@ -28,10 +32,10 @@ export interface ExtensionInput< optional: boolean; }, > { - $$type: '@backstage/ExtensionInput'; - extensionData: Array; - config: TConfig; - replaces?: Array<{ id: string; input: string }>; + readonly $$type: '@backstage/ExtensionInput'; + readonly extensionData: Array; + readonly config: TConfig; + readonly replaces?: Array<{ id: string; input: string }>; } /** @public */ @@ -68,8 +72,7 @@ export function createExtensionInput< } } } - return { - $$type: '@backstage/ExtensionInput', + const baseOptions = { extensionData, config: { singleton: Boolean(config?.singleton) as TConfig['singleton'] extends true @@ -80,11 +83,21 @@ export function createExtensionInput< : false, }, replaces: config?.replaces, - } as ExtensionInput< + }; + + function createInstance(parent?: ExtensionInputContext): ExtensionInput< UExtensionData, { singleton: TConfig['singleton'] extends true ? true : false; optional: TConfig['optional'] extends true ? true : false; } - >; + > { + return OpaqueExtensionInput.createInstance(undefined, { + ...baseOptions, + context: parent, + withContext: createInstance, + }); + } + + return createInstance(); } diff --git a/packages/frontend-plugin-api/src/wiring/index.ts b/packages/frontend-plugin-api/src/wiring/index.ts index 45c80d4a90..1bbdf68b13 100644 --- a/packages/frontend-plugin-api/src/wiring/index.ts +++ b/packages/frontend-plugin-api/src/wiring/index.ts @@ -18,8 +18,8 @@ export { coreExtensionData } from './coreExtensionData'; export { createExtension, type ExtensionDefinition, + type ExtensionDefinitionAttachTo, type ExtensionDefinitionParameters, - type ExtensionAttachToSpec, type CreateExtensionOptions, type ResolvedExtensionInput, type ResolvedExtensionInputs, @@ -54,7 +54,11 @@ export { type FrontendFeatureLoader, type CreateFrontendFeatureLoaderOptions, } from './createFrontendFeatureLoader'; -export { type Extension } from './resolveExtensionDefinition'; +export { + type Extension, + type ExtensionAttachTo, + type ExtensionAttachToSpec, +} from './resolveExtensionDefinition'; export { type ExtensionDataContainer, type FeatureFlagConfig, diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts index a74d908024..d8b90d69f3 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.test.ts @@ -14,11 +14,21 @@ * limitations under the License. */ +import { + createExtensionDataRef, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; import { ExtensionDefinition } from './createExtension'; import { ResolveExtensionId, resolveExtensionDefinition, } from './resolveExtensionDefinition'; +import { + OpaqueExtensionDefinition, + OpaqueExtensionInput, +} from '@internal/frontend'; + +const testDataRef = createExtensionDataRef().with({ id: 'test' }); describe('resolveExtensionDefinition', () => { const baseDef = { @@ -26,6 +36,7 @@ describe('resolveExtensionDefinition', () => { T: undefined as any, version: 'v2', attachTo: { id: '', input: '' }, + inputs: {}, disabled: false, override: () => ({} as ExtensionDefinition), }; @@ -60,6 +71,111 @@ describe('resolveExtensionDefinition', () => { 'Extension must declare an explicit namespace or name as it could not be resolved from context, kind=undefined namespace=undefined name=undefined', ); }); + + it('should resolve extension input references', () => { + const baseInpuf = OpaqueExtensionInput.toInternal( + createExtensionInput([testDataRef]), + ); + expect( + resolveExtensionDefinition( + OpaqueExtensionDefinition.toInternal({ + ...baseDef, + attachTo: baseInpuf.withContext({ + kind: 'parent', + name: 'example', + input: 'children', + }), + }), + { namespace: 'test' }, + ).attachTo, + ).toEqual({ + id: 'parent:test/example', + input: 'children', + }); + + expect( + resolveExtensionDefinition( + OpaqueExtensionDefinition.toInternal({ + ...baseDef, + attachTo: baseInpuf.withContext({ + name: 'example', + input: 'children', + }), + }), + { namespace: 'test' }, + ).attachTo, + ).toEqual({ + id: 'test/example', + input: 'children', + }); + + expect( + resolveExtensionDefinition( + OpaqueExtensionDefinition.toInternal({ + ...baseDef, + attachTo: baseInpuf.withContext({ + kind: 'parent', + input: 'children', + }), + }), + { namespace: 'test' }, + ).attachTo, + ).toEqual({ + id: 'parent:test', + input: 'children', + }); + + expect( + resolveExtensionDefinition( + OpaqueExtensionDefinition.toInternal({ + ...baseDef, + attachTo: baseInpuf.withContext({ + input: 'children', + }), + }), + { namespace: 'test' }, + ).attachTo, + ).toEqual({ + id: 'test', + input: 'children', + }); + + expect( + resolveExtensionDefinition( + OpaqueExtensionDefinition.toInternal({ + ...baseDef, + attachTo: [ + baseInpuf.withContext({ + kind: 'k1', + input: 'children', + }), + baseInpuf.withContext({ + kind: 'k2', + input: 'children', + }), + baseInpuf.withContext({ + kind: 'k3', + input: 'children', + }), + ], + }), + { namespace: 'test' }, + ).attachTo, + ).toEqual([ + { + id: 'k1:test', + input: 'children', + }, + { + id: 'k2:test', + input: 'children', + }, + { + id: 'k3:test', + input: 'children', + }, + ]); + }); }); describe('old resolveExtensionDefinition', () => { @@ -68,6 +184,7 @@ describe('old resolveExtensionDefinition', () => { T: undefined as any, version: 'v1', attachTo: { id: '', input: '' }, + inputs: {}, disabled: false, override: () => ({} as ExtensionDefinition), }; diff --git a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts index a0a9a52dd3..f99d586e87 100644 --- a/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts +++ b/packages/frontend-plugin-api/src/wiring/resolveExtensionDefinition.ts @@ -16,7 +16,7 @@ import { ApiHolder, AppNode } from '../apis'; import { - ExtensionAttachToSpec, + ExtensionDefinitionAttachTo, ExtensionDefinition, ExtensionDefinitionParameters, ResolvedExtensionInputs, @@ -24,7 +24,21 @@ import { import { PortableSchema } from '../schema'; import { ExtensionInput } from './createExtensionInput'; import { ExtensionDataRef, ExtensionDataValue } from './createExtensionDataRef'; -import { OpaqueExtensionDefinition } from '@internal/frontend'; +import { + OpaqueExtensionDefinition, + OpaqueExtensionInput, +} from '@internal/frontend'; + +/** @public */ +export type ExtensionAttachTo = + | { id: string; input: string } + | Array<{ id: string; input: string }>; + +/** + * @deprecated Use {@link ExtensionAttachTo} instead. + * @public + */ +export type ExtensionAttachToSpec = ExtensionAttachTo; /** @public */ export interface Extension { @@ -118,24 +132,11 @@ export type ResolveExtensionId< : never : never; -/** @internal */ -export function resolveExtensionDefinition< - T extends ExtensionDefinitionParameters, ->( - definition: ExtensionDefinition, - context?: { namespace?: string }, -): Extension { - const internalDefinition = OpaqueExtensionDefinition.toInternal(definition); - const { - name, - kind, - namespace: _skip1, - override: _skip2, - ...rest - } = internalDefinition; - - const namespace = internalDefinition.namespace ?? context?.namespace; - +function resolveExtensionId( + kind?: string, + namespace?: string, + name?: string, +): string { const namePart = name && namespace ? `${namespace}/${name}` : namespace || name; if (!namePart) { @@ -144,10 +145,75 @@ export function resolveExtensionDefinition< ); } - const id = kind ? `${kind}:${namePart}` : namePart; + return kind ? `${kind}:${namePart}` : namePart; +} + +function resolveAttachTo( + attachTo: ExtensionDefinitionAttachTo, + namespace?: string, +): ExtensionAttachToSpec { + const resolveSpec = ( + spec: Exclude>, + ): { id: string; input: string } => { + if (OpaqueExtensionInput.isType(spec)) { + const { context } = OpaqueExtensionInput.toInternal(spec); + if (!context) { + throw new Error( + 'Invalid input object without a parent extension used as attachment point', + ); + } + return { + id: resolveExtensionId(context.kind, namespace, context.name), + input: context.input, + }; + } + if ('relative' in spec && spec.relative) { + return { + id: resolveExtensionId( + spec.relative.kind, + namespace, + spec.relative.name, + ), + input: spec.input, + }; + } + if ('id' in spec) { + return { id: spec.id, input: spec.input }; + } + throw new Error('Invalid attachment point specification'); + }; + + if (Array.isArray(attachTo)) { + return attachTo.map(resolveSpec); + } + + return resolveSpec(attachTo); +} + +/** @internal */ +export function resolveExtensionDefinition< + T extends ExtensionDefinitionParameters, +>( + definition: ExtensionDefinition, + context?: { namespace?: string }, +): Extension { + const internalDefinition = OpaqueExtensionDefinition.toInternal(definition); + + const { + name, + kind, + namespace: internalNamespace, + override: _skip2, + attachTo, + ...rest + } = internalDefinition; + + const namespace = internalNamespace ?? context?.namespace; + const id = resolveExtensionId(kind, namespace, name); return { ...rest, + attachTo: resolveAttachTo(attachTo, namespace), $$type: '@backstage/Extension', version: internalDefinition.version, id, diff --git a/packages/repo-tools/CHANGELOG.md b/packages/repo-tools/CHANGELOG.md index ad70afb07e..d9ef90fe21 100644 --- a/packages/repo-tools/CHANGELOG.md +++ b/packages/repo-tools/CHANGELOG.md @@ -1,5 +1,16 @@ # @backstage/repo-tools +## 0.16.0-next.2 + +### Minor Changes + +- 11c61f2: The `package-docs` command will now automatically use a `typedoc.json` file if one exists at the root of your project. + +### Patch Changes + +- Updated dependencies + - @backstage/backend-plugin-api@1.5.0-next.2 + ## 0.15.4-next.1 ### Patch Changes diff --git a/packages/repo-tools/package.json b/packages/repo-tools/package.json index f6de3a7cba..b250018017 100644 --- a/packages/repo-tools/package.json +++ b/packages/repo-tools/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/repo-tools", - "version": "0.15.4-next.1", + "version": "0.16.0-next.2", "description": "CLI for Backstage repo tooling ", "backstage": { "role": "cli" diff --git a/packages/repo-tools/src/commands/package-docs/command.ts b/packages/repo-tools/src/commands/package-docs/command.ts index 174a1d949d..28255193f0 100644 --- a/packages/repo-tools/src/commands/package-docs/command.ts +++ b/packages/repo-tools/src/commands/package-docs/command.ts @@ -23,6 +23,7 @@ import { mkdirp } from 'fs-extra'; import { PackageDocsCache } from './Cache'; import { Lockfile } from '@backstage/cli-node'; import { glob } from 'glob'; +import { existsSync } from 'fs'; const limit = pLimit(8); @@ -220,6 +221,9 @@ export default async function packageDocs(paths: string[] = [], opts: any) { ...HIGHLIGHT_LANGUAGES.flatMap(e => ['--highlightLanguages', e]), '--out', cliPaths.resolveTargetRoot('type-docs'), + ...(existsSync(cliPaths.resolveTargetRoot('typedoc.json')) + ? ['--options', cliPaths.resolveTargetRoot('typedoc.json')] + : []), ].join(' '), { cwd: cliPaths.targetRoot, diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 254ad0a7a4..7347058a0c 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,5 +1,60 @@ # @backstage/ui +## 0.9.0-next.3 + +### Minor Changes + +- 83c100e: **BREAKING**: Removed `Collapsible` component. Migrate to `Accordion` or use React Aria `Disclosure`. + + ## Migration Path 1: Accordion (Opinionated Styled Component) + + Accordion provides preset styling with a similar component structure. + + ```diff + - import { Collapsible } from '@backstage/ui'; + + import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui'; + + - + - } /> + - Content + - + + + + + + + Content + + + ``` + + CSS classes: `.bui-CollapsibleRoot` → `.bui-Accordion`, `.bui-CollapsibleTrigger` → `.bui-AccordionTrigger` (now on heading element), `.bui-CollapsiblePanel` → `.bui-AccordionPanel` + + ## Migration Path 2: React Aria Disclosure (Full Customization) + + For custom styling without preset styles: + + ```tsx + import { Disclosure, Button, DisclosurePanel } from 'react-aria-components'; + + + + Content + ; + ``` + +- 816af0f: **BREAKING**: The `SelectProps` interface now accepts a generic type parameter for selection mode. + + Added searchable and multiple selection support to Select component. The component now accepts `searchable`, `selectionMode`, and `searchPlaceholder` props to enable filtering and multi-selection modes. + + Migration: If you're using `SelectProps` type directly, update from `SelectProps` to `SelectProps<'single' | 'multiple'>`. Component usage remains backward compatible. + +### Patch Changes + +- 35a3614: Fixed CSS issues in Select component including popover width constraints, focus outline behavior, and overflow handling. +- 01476f0: Improved visual consistency of PasswordField, SearchField, and MenuAutocomplete components. +- 836b0c7: Fixed dialog backdrop appearance in dark mode. +- 6d35a6b: Removed `@base-ui-components/react` dependency as all components now use React Aria Components. +- 7839e7b: Added `loading` prop to Button and ButtonIcon components for displaying spinner during async operations. +- a00fb88: Fixed Table Row component to properly support opening links in new tabs via right-click or Cmd+Click when using the href prop. + ## 0.9.0-next.2 ### Minor Changes diff --git a/packages/ui/package.json b/packages/ui/package.json index c3703639e5..767af01601 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@backstage/ui", - "version": "0.9.0-next.2", + "version": "0.9.0-next.3", "backstage": { "role": "web-library" }, @@ -40,7 +40,6 @@ "test": "backstage-cli package test" }, "dependencies": { - "@base-ui-components/react": "1.0.0-alpha.7", "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index c1e1a309cc..4f6087b0f0 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -187,6 +187,8 @@ export interface ButtonIconProps extends ButtonProps_2 { // (undocumented) icon?: ReactElement; // (undocumented) + loading?: boolean; + // (undocumented) size?: 'small' | 'medium' | Partial>; // (undocumented) variant?: @@ -228,6 +230,8 @@ export interface ButtonProps extends ButtonProps_2 { // (undocumented) iconStart?: ReactElement; // (undocumented) + loading?: boolean; + // (undocumented) size?: 'small' | 'medium' | Partial>; // (undocumented) variant?: @@ -418,15 +422,20 @@ export const componentDefinitions: { readonly Button: { readonly classNames: { readonly root: 'bui-Button'; + readonly content: 'bui-ButtonContent'; + readonly spinner: 'bui-ButtonSpinner'; }; readonly dataAttributes: { readonly size: readonly ['small', 'medium', 'large']; readonly variant: readonly ['primary', 'secondary', 'tertiary']; + readonly loading: readonly [true, false]; }; }; readonly ButtonIcon: { readonly classNames: { readonly root: 'bui-ButtonIcon'; + readonly content: 'bui-ButtonIconContent'; + readonly spinner: 'bui-ButtonIconSpinner'; }; }; readonly ButtonLink: { @@ -628,7 +637,10 @@ export const componentDefinitions: { readonly PasswordField: { readonly classNames: { readonly root: 'bui-PasswordField'; - readonly inputVisibility: 'bui-InputVisibility'; + readonly inputWrapper: 'bui-PasswordFieldInputWrapper'; + readonly input: 'bui-PasswordFieldInput'; + readonly inputIcon: 'bui-PasswordFieldIcon'; + readonly inputVisibility: 'bui-PasswordFieldVisibility'; }; readonly dataAttributes: { readonly size: readonly ['small', 'medium']; @@ -650,7 +662,7 @@ export const componentDefinitions: { readonly classNames: { readonly root: 'bui-SearchField'; readonly clear: 'bui-SearchFieldClear'; - readonly inputWrapper: 'bui-SearchFieldWrapper'; + readonly inputWrapper: 'bui-SearchFieldInputWrapper'; readonly input: 'bui-SearchFieldInput'; readonly inputIcon: 'bui-SearchFieldInputIcon'; }; @@ -664,12 +676,16 @@ export const componentDefinitions: { readonly root: 'bui-Select'; readonly popover: 'bui-SelectPopover'; readonly trigger: 'bui-SelectTrigger'; + readonly chevron: 'bui-SelectTriggerChevron'; readonly value: 'bui-SelectValue'; - readonly icon: 'bui-SelectIcon'; readonly list: 'bui-SelectList'; readonly item: 'bui-SelectItem'; readonly itemIndicator: 'bui-SelectItemIndicator'; readonly itemLabel: 'bui-SelectItemLabel'; + readonly searchWrapper: 'bui-SelectSearchWrapper'; + readonly search: 'bui-SelectSearch'; + readonly searchClear: 'bui-SelectSearchClear'; + readonly noResults: 'bui-SelectNoResults'; }; readonly dataAttributes: { readonly size: readonly ['small', 'medium']; @@ -1170,6 +1186,14 @@ export const MenuTrigger: (props: MenuTriggerProps) => JSX_2.Element; // @public (undocumented) export interface MenuTriggerProps extends MenuTriggerProps_2 {} +// @public (undocumented) +type Option_2 = { + value: string; + label: string; + disabled?: boolean; +}; +export { Option_2 as Option }; + // @public (undocumented) export const Radio: ForwardRefExoticComponent< RadioProps & RefAttributes @@ -1214,22 +1238,18 @@ export interface SearchFieldProps // @public (undocumented) export const Select: ForwardRefExoticComponent< - SelectProps & RefAttributes + SelectProps<'multiple' | 'single'> & RefAttributes >; // @public (undocumented) -export interface SelectProps - extends SelectProps_2<{ - name: string; - value: string; - }>, +export interface SelectProps + extends SelectProps_2, Omit { icon?: ReactNode; - options?: Array<{ - value: string; - label: string; - disabled?: boolean; - }>; + options?: Array; + searchable?: boolean; + searchPlaceholder?: string; + selectionMode?: T; size?: 'small' | 'medium' | Partial>; } diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 2216d0882b..a87f5d67cf 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -18,22 +18,27 @@ @layer components { .bui-Button { - border: none; + --loading-duration: 200ms; + position: relative; display: inline-flex; - align-items: center; - justify-content: center; + border: none; user-select: none; font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-bold); padding: 0; cursor: pointer; border-radius: var(--bui-radius-2); - gap: var(--bui-space-1_5); flex-shrink: 0; + transition: background-color var(--loading-duration) ease-out, + box-shadow var(--loading-duration) ease-out; &[data-disabled='true'] { cursor: not-allowed; } + + &[data-loading='true'] { + cursor: wait; + } } .bui-Button[data-variant='primary'] { @@ -54,7 +59,8 @@ outline-offset: 2px; } - &[data-disabled='true'] { + &[data-disabled='true'], + &[data-loading='true'] { background-color: var(--bui-bg-solid-disabled); color: var(--bui-fg-solid-disabled); } @@ -80,7 +86,8 @@ box-shadow: inset 0 0 0 2px var(--bui-ring); } - &[data-disabled='true'] { + &[data-disabled='true'], + &[data-loading='true'] { box-shadow: inset 0 0 0 1px var(--bui-border-disabled); color: var(--bui-fg-disabled); } @@ -105,31 +112,92 @@ box-shadow: inset 0 0 0 2px var(--bui-ring); } - &[data-disabled='true'] { + &[data-disabled='true'], + &[data-loading='true'] { background-color: transparent; color: var(--bui-fg-disabled); } } + .bui-Button[data-size='small'] { + font-size: var(--bui-font-size-3); + padding: 0 var(--bui-space-2); + height: 2rem; + + svg { + width: 1rem; + height: 1rem; + } + } + .bui-Button[data-size='medium'] { font-size: var(--bui-font-size-4); padding: 0 var(--bui-space-3); height: 2.5rem; + + svg { + width: 1.25rem; + height: 1.25rem; + } } - .bui-Button[data-size='small'] { - font-size: var(--bui-font-size-3); - padding: 0 var(--bui-space-2); - height: 2rem; + .bui-ButtonContent { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--bui-space-1_5); + height: 100%; + width: 100%; + transition: opacity var(--loading-duration) ease-out; + + .bui-Button[data-loading='true'] & { + opacity: 0; + } } - .bui-Button[data-size='small'] svg { - width: 1rem; - height: 1rem; + .bui-ButtonSpinner { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + opacity: 0; + transition: opacity var(--loading-duration) ease-in; + + .bui-Button[data-loading='true'] & { + opacity: 1; + } + + & svg { + animation: bui-spin 1s linear infinite; + } } - .bui-Button[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; + @media (prefers-reduced-motion: reduce) { + .bui-Button { + transition-duration: 50ms; + } + + .bui-ButtonContent { + transition-duration: 50ms; + } + + .bui-ButtonSpinner { + transition-duration: 50ms; + } + + .bui-ButtonSpinner svg { + animation: none; + } + } + + @keyframes bui-spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } } diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 6b74615eeb..4734770aa4 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -19,6 +19,7 @@ import { Button } from './Button'; import { Flex } from '../Flex'; import { Text } from '../Text'; import { RiArrowRightSLine, RiCloudLine } from '@remixicon/react'; +import { useState } from 'react'; const meta = { title: 'Backstage UI/Button', @@ -212,3 +213,80 @@ export const Playground: Story = {
), }; + +export const Loading: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 3000); + }; + + return ( + + ); + }, +}; + +export const LoadingVariants: Story = { + render: () => ( + + Primary + + + + + + + Secondary + + + + + + + Tertiary + + + + + + + Loading vs Disabled + + + + + + + ), +}; diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index d22a3036e6..45228ff6ef 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -16,7 +16,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; -import { Button as RAButton } from 'react-aria-components'; +import { Button as RAButton, ProgressBar } from 'react-aria-components'; +import { RiLoader4Line } from '@remixicon/react'; import type { ButtonProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import styles from './Button.module.css'; @@ -30,18 +31,38 @@ export const Button = forwardRef( ...props, }); - const { children, className, iconStart, iconEnd, ...rest } = cleanedProps; + const { children, className, iconStart, iconEnd, loading, ...rest } = + cleanedProps; return ( - {iconStart} - {children} - {iconEnd} + {({ isPending }) => ( + <> + + {iconStart} + {children} + {iconEnd} + + + {isPending && ( + + + )} + + )} ); }, diff --git a/packages/ui/src/components/Button/types.ts b/packages/ui/src/components/Button/types.ts index cbe5bd3608..daeba7653f 100644 --- a/packages/ui/src/components/Button/types.ts +++ b/packages/ui/src/components/Button/types.ts @@ -33,4 +33,5 @@ export interface ButtonProps extends RAButtonProps { iconStart?: ReactElement; iconEnd?: ReactElement; children?: ReactNode; + loading?: boolean; } diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx index c1a908119f..97072c3664 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx @@ -17,7 +17,9 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { ButtonIcon } from './ButtonIcon'; import { Flex } from '../Flex'; +import { Text } from '../Text'; import { RiCloudLine } from '@remixicon/react'; +import { useState } from 'react'; const meta = { title: 'Backstage UI/ButtonIcon', @@ -83,3 +85,91 @@ export const Responsive: Story = { }, render: args => } />, }; + +export const Loading: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = () => { + setIsLoading(true); + setTimeout(() => { + setIsLoading(false); + }, 3000); + }; + + return ( + } + loading={isLoading} + onPress={handleClick} + /> + ); + }, +}; + +export const LoadingVariants: Story = { + render: () => ( + + Primary + + } + loading + /> + } + loading + /> + + + Secondary + + } + loading + /> + } + loading + /> + + + Tertiary + + } + loading + /> + } + loading + /> + + + Loading vs Disabled + + } loading /> + } isDisabled /> + } + loading + isDisabled + /> + + + ), +}; diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx index 2e6a96c4c1..fecccd6a37 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx @@ -16,7 +16,8 @@ import clsx from 'clsx'; import { forwardRef, Ref } from 'react'; -import { Button as RAButton } from 'react-aria-components'; +import { Button as RAButton, ProgressBar } from 'react-aria-components'; +import { RiLoader4Line } from '@remixicon/react'; import type { ButtonIconProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import stylesButtonIcon from './ButtonIcon.module.css'; @@ -33,7 +34,7 @@ export const ButtonIcon = forwardRef( const { classNames: classNamesButtonIcon } = useStyles('ButtonIcon'); - const { className, icon, ...rest } = cleanedProps; + const { className, icon, loading, ...rest } = cleanedProps; return ( - {icon} + {({ isPending }) => ( + <> + + {icon} + + + {isPending && ( + + + )} + + )} ); }, diff --git a/packages/ui/src/components/ButtonIcon/types.ts b/packages/ui/src/components/ButtonIcon/types.ts index bc358b6d7d..6af3d12361 100644 --- a/packages/ui/src/components/ButtonIcon/types.ts +++ b/packages/ui/src/components/ButtonIcon/types.ts @@ -31,4 +31,5 @@ export interface ButtonIconProps extends RAButtonProps { | 'tertiary' | Partial>; icon?: ReactElement; + loading?: boolean; } diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.tsx b/packages/ui/src/components/ButtonLink/ButtonLink.tsx index 70d216b3d8..28a67105bb 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.tsx +++ b/packages/ui/src/components/ButtonLink/ButtonLink.tsx @@ -41,47 +41,38 @@ export const ButtonLink = forwardRef( const isExternal = isExternalLink(href); - // If it's an external link, render RALink without RouterProvider - if (isExternal) { - return ( - + {iconStart} {children} {iconEnd} - - ); + + + ); + + // If it's an external link, render RALink without RouterProvider + if (isExternal) { + return linkButton; } // For internal links, use RouterProvider return ( - - {iconStart} - {children} - {iconEnd} - + {linkButton} ); }, diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css index f97f3f57ff..606714583e 100644 --- a/packages/ui/src/components/Menu/Menu.module.css +++ b/packages/ui/src/components/Menu/Menu.module.css @@ -188,48 +188,56 @@ } .bui-MenuSearchField { - position: relative; font-family: var(--bui-font-regular); width: 100%; flex-shrink: 0; + display: flex; + align-items: center; + border-bottom: 1px solid var(--bui-border); + background-color: var(--bui-bg-surface-1); + height: 2rem; &[data-empty] { .bui-MenuSearchFieldClear { - display: none; + visibility: hidden; } } } .bui-MenuSearchFieldInput { + flex: 1; display: flex; align-items: center; - padding: 0 var(--bui-space-3); + padding: 0; border: none; - border-bottom: 1px solid var(--bui-border); - background-color: var(--bui-bg-surface-1); + background-color: transparent; font-size: var(--bui-font-size-3); font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-regular); color: var(--bui-fg-primary); width: 100%; - height: 2rem; - cursor: inherit; + height: 100%; outline: none; + cursor: inherit; &::-webkit-search-cancel-button, &::-webkit-search-decoration { -webkit-appearance: none; } + + &::placeholder { + color: var(--bui-fg-secondary); + } + + &:first-child { + padding-inline: var(--bui-space-3) 0; + } } .bui-MenuSearchFieldClear { - position: absolute; - right: var(--bui-space-2); - top: 0; - bottom: 0; - display: flex; - align-items: center; - justify-content: center; + flex: 0 0 auto; + display: grid; + place-content: center; background-color: transparent; border: none; padding: 0; @@ -237,6 +245,12 @@ cursor: pointer; color: var(--bui-fg-secondary); transition: color 0.2s ease-in-out; + width: 2rem; + height: 2rem; + + &:hover { + color: var(--bui-fg-primary); + } & > svg { width: 1rem; diff --git a/packages/ui/src/components/PasswordField/PasswordField.module.css b/packages/ui/src/components/PasswordField/PasswordField.module.css index bf78ab477f..adad950816 100644 --- a/packages/ui/src/components/PasswordField/PasswordField.module.css +++ b/packages/ui/src/components/PasswordField/PasswordField.module.css @@ -23,52 +23,136 @@ font-family: var(--bui-font-regular); width: 100%; flex-shrink: 0; - /* Reserve space for browser/password manager icon (e.g. 1Password) */ - --bui-passwordmanager-icon-width: var(--bui-space-1); + + &[data-size='small'] { + --password-field-item-height: 2rem; + } + + &[data-size='medium'] { + --password-field-item-height: 2.5rem; + } } - .bui-InputVisibility { - position: absolute; - right: var(--bui-passwordmanager-icon-width); - top: 0; - bottom: 0; + .bui-PasswordFieldInputWrapper { + display: flex; + align-items: center; + border-radius: var(--bui-radius-2); + border: 1px solid var(--bui-border); + background-color: var(--bui-bg-surface-1); + transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out; + + &[data-size='small'] { + height: 2rem; + } + + &[data-size='medium'] { + height: 2.5rem; + } + + &:focus-within { + border-color: var(--bui-border-pressed); + outline-width: 0px; + } + + &:hover { + border-color: var(--bui-border-hover); + } + + &:has([data-invalid]) { + border-color: var(--bui-fg-danger); + } + + &:has([data-disabled]) { + opacity: 0.5; + cursor: not-allowed; + border: 1px solid var(--bui-border-disabled); + } + } + + .bui-PasswordFieldIcon { + flex: 0 0 auto; + display: grid; + place-content: center; + color: var(--bui-fg-primary); + pointer-events: none; + width: var(--password-field-item-height); + height: var(--password-field-item-height); + + & svg { + .bui-PasswordField[data-size='small'] & { + width: 1rem; + height: 1rem; + } + + .bui-PasswordField[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } + } + } + + .bui-PasswordFieldInput { + flex: 1; + display: flex; + align-items: center; + padding: 0; + border: none; + background-color: transparent; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-primary); + width: 100%; + height: 100%; + outline: none; + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &::placeholder { + color: var(--bui-fg-secondary); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &:first-child { + .bui-PasswordField[data-size='small'] & { + padding-inline: var(--bui-space-3) 0; + } + + .bui-PasswordField[data-size='medium'] & { + padding-inline: var(--bui-space-4) 0; + } + } + } + + .bui-PasswordFieldVisibility { + flex: 0 0 auto; + display: grid; + place-content: center; background-color: transparent; cursor: pointer; border: none; padding: 0; margin: 0; - display: flex; - align-items: center; - justify-content: center; color: var(--bui-fg-primary); + width: var(--password-field-item-height); + height: var(--password-field-item-height); - /* Size: small */ - &[data-size='small'] { - width: 2rem; - height: 2rem; - } - &[data-size='small'] svg { - width: 1rem; - height: 1rem; - } + & svg { + .bui-PasswordField[data-size='small'] & { + width: 1rem; + height: 1rem; + } - /* Size: medium */ - &[data-size='medium'] { - width: 2.5rem; - height: 2.5rem; + .bui-PasswordField[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } } - &[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; - } - } - - /* Ensure input has enough right padding for our toggle + PM icon */ - .bui-PasswordField .bui-InputWrapper[data-size='small'] .bui-Input { - padding-right: calc(2rem + var(--bui-passwordmanager-icon-width)); - } - - .bui-PasswordField .bui-InputWrapper[data-size='medium'] .bui-Input { - padding-right: calc(2.5rem + var(--bui-passwordmanager-icon-width)); } } diff --git a/packages/ui/src/components/PasswordField/PasswordField.tsx b/packages/ui/src/components/PasswordField/PasswordField.tsx index d7cea3a6bd..412128ca32 100644 --- a/packages/ui/src/components/PasswordField/PasswordField.tsx +++ b/packages/ui/src/components/PasswordField/PasswordField.tsx @@ -28,7 +28,6 @@ import type { PasswordFieldProps } from './types'; import { useStyles } from '../../hooks/useStyles'; import { RiEyeLine, RiEyeOffLine } from '@remixicon/react'; import stylesPasswordField from './PasswordField.module.css'; -import stylesTextField from '../TextField/TextField.module.css'; /** @public */ export const PasswordField = forwardRef( @@ -47,8 +46,6 @@ export const PasswordField = forwardRef( } }, [label, ariaLabel, ariaLabelledBy]); - const { classNames: textFieldClassNames } = useStyles('TextField'); - const { classNames: classNamesPasswordField, dataAttributes, @@ -96,16 +93,16 @@ export const PasswordField = forwardRef( />
{icon && ( )} -
- setIsVisible(v => !v)} - className={clsx( - classNamesPasswordField.inputVisibility, - stylesPasswordField[classNamesPasswordField.inputVisibility], - )} - > - {isVisible ? : } - -
+ setIsVisible(v => !v)} + className={clsx( + classNamesPasswordField.inputVisibility, + stylesPasswordField[classNamesPasswordField.inputVisibility], + )} + > + {isVisible ? : } +
diff --git a/packages/ui/src/components/SearchField/SearchField.module.css b/packages/ui/src/components/SearchField/SearchField.module.css index 63ebb815b8..e99c212b29 100644 --- a/packages/ui/src/components/SearchField/SearchField.module.css +++ b/packages/ui/src/components/SearchField/SearchField.module.css @@ -25,6 +25,14 @@ flex: 1; flex-shrink: 0; + &[data-size='small'] { + --search-field-item-height: 2rem; + } + + &[data-size='medium'] { + --search-field-item-height: 2.5rem; + } + &[data-empty] { .bui-SearchFieldClear { display: none; @@ -65,7 +73,7 @@ } } - .bui-SearchFieldWrapper { + .bui-SearchFieldInputWrapper { .bui-SearchFieldInput[data-icon] { padding-right: 0px; } @@ -74,102 +82,31 @@ } } - .bui-SearchFieldWrapper { - position: relative; - - .bui-SearchFieldInput[data-icon] { - padding-right: var(--bui-space-6); - } - - &[data-size='small'] .bui-SearchFieldInput { - height: 2rem; - } - - &[data-size='medium'] .bui-SearchFieldInput { - height: 2.5rem; - } - - &[data-size='small'] .bui-SearchFieldInput[data-icon] { - padding-left: var(--bui-space-8); - } - - &[data-size='medium'] .bui-SearchFieldInput[data-icon] { - padding-left: var(--bui-space-9); - } - } - - .bui-SearchFieldInputIcon { - position: absolute; - display: flex; - justify-content: center; - left: 0; - top: 50%; - transform: translateY(-50%); - margin-right: var(--bui-space-1); - color: var(--bui-fg-primary); - pointer-events: none; - /* To animate the icon when the input is collapsed */ - transition: left 0.2s ease-in-out; - - &[data-size='small'] { - width: 2rem; - } - - &[data-size='medium'] { - width: 2.5rem; - } - - &[data-size='small'] svg { - width: 1rem; - height: 1rem; - } - - &[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; - } - } - - .bui-SearchFieldInput { + .bui-SearchFieldInputWrapper { display: flex; align-items: center; - padding: 0 var(--bui-space-3); border-radius: var(--bui-radius-2); border: 1px solid var(--bui-border); background-color: var(--bui-bg-surface-1); - font-size: var(--bui-font-size-3); - font-family: var(--bui-font-regular); - font-weight: var(--bui-font-weight-regular); - color: var(--bui-fg-primary); - transition: padding 0.3s ease-in-out, border-color 0.2s ease-in-out, - outline-color 0.2s ease-in-out; - width: 100%; - height: 100%; - cursor: inherit; + transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out; - &::-webkit-search-cancel-button, - &::-webkit-search-decoration { - -webkit-appearance: none; + &[data-size='small'] { + height: 2rem; } - &::placeholder { - color: var(--bui-fg-secondary); + &[data-size='medium'] { + height: 2.5rem; } - &[data-focused] { - outline-color: var(--bui-border-pressed); - outline-width: 0px; - } - - &[data-hovered] { - border-color: var(--bui-border-hover); - } - - &[data-focused] { + &:focus-within { border-color: var(--bui-border-pressed); outline-width: 0px; } + &:hover { + border-color: var(--bui-border-hover); + } + &[data-invalid] { border-color: var(--bui-fg-danger); } @@ -181,14 +118,75 @@ } } - .bui-SearchFieldClear { - position: absolute; - right: 0; - top: 0; - bottom: 0; + .bui-SearchFieldInputIcon { + flex: 0 0 auto; + display: grid; + place-content: center; + color: var(--bui-fg-primary); + pointer-events: none; + width: var(--search-field-item-height); + height: var(--search-field-item-height); + /* To animate the icon when the input is collapsed */ + transition: opacity 0.2s ease-in-out; + + & svg { + .bui-SearchField[data-size='small'] & { + width: 1rem; + height: 1rem; + } + + .bui-SearchField[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } + } + } + + .bui-SearchFieldInput { + flex: 1; display: flex; align-items: center; - justify-content: center; + padding: 0; + border: none; + background-color: transparent; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + color: var(--bui-fg-primary); + transition: padding 0.3s ease-in-out; + width: 100%; + height: 100%; + outline: none; + cursor: inherit; + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &::placeholder { + color: var(--bui-fg-secondary); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &:first-child { + .bui-SearchField[data-size='small'] & { + padding-inline: var(--bui-space-3) 0; + } + + .bui-SearchField[data-size='medium'] & { + padding-inline: var(--bui-space-3) 0; + } + } + } + + .bui-SearchFieldClear { + flex: 0 0 auto; + display: grid; + place-content: center; background-color: transparent; border: none; padding: 0; @@ -196,21 +194,13 @@ cursor: pointer; color: var(--bui-fg-secondary); transition: color 0.2s ease-in-out; + width: var(--search-field-item-height); + height: var(--search-field-item-height); &:hover { color: var(--bui-fg-primary); } - &[data-size='small'] { - width: 2rem; - height: 2rem; - } - - &[data-size='medium'] { - width: 2.5rem; - height: 2.5rem; - } - & svg { width: 1rem; height: 1rem; diff --git a/packages/ui/src/components/Select/Select.module.css b/packages/ui/src/components/Select/Select.module.css index d4e201ef3a..a2eb41040e 100644 --- a/packages/ui/src/components/Select/Select.module.css +++ b/packages/ui/src/components/Select/Select.module.css @@ -17,6 +17,17 @@ @layer tokens, base, components, utilities; @layer components { + .bui-Select, + .bui-SelectPopover { + &[data-size='small'] { + --select-item-height: 2rem; + } + + &[data-size='medium'] { + --select-item-height: 2.5rem; + } + } + .bui-SelectPopover { min-width: var(--trigger-width); } @@ -32,30 +43,29 @@ cursor: pointer; gap: var(--bui-space-2); width: 100%; + height: var(--select-item-height); + + .bui-Select[data-size='small'] & { + padding-inline: var(--bui-space-3) 0; + } + + .bui-Select[data-size='medium'] & { + padding-inline: var(--bui-space-4) 0; + } & svg { flex-shrink: 0; color: var(--bui-fg-secondary); - } - &[data-size='small'] { - height: 2rem; - padding-inline: var(--bui-space-3); - } + .bui-Select[data-size='small'] & { + width: 1rem; + height: 1rem; + } - &[data-size='medium'] { - height: 3rem; - padding-inline: var(--bui-space-4); - } - - &[data-size='small'] svg { - width: 1rem; - height: 1rem; - } - - &[data-size='medium'] svg { - width: 1.25rem; - height: 1.25rem; + .bui-Select[data-size='medium'] & { + width: 1.25rem; + height: 1.25rem; + } } &::placeholder { @@ -63,7 +73,7 @@ } &:hover { - transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out; + transition: border-color 0.2s ease-in-out; border-color: var(--bui-border-hover); } @@ -72,16 +82,13 @@ outline: 0; } - .bui-Select[data-invalid] &, - &[data-invalid] { + .bui-Select[data-invalid] & { border-color: var(--bui-fg-danger); - } - &[data-invalid]:hover { - border-width: 2px; - } - &[data-invalid]:focus-visible { - border-width: 2px; + &:focus-visible, + &:hover { + outline: 1px solid var(--bui-fg-danger); + } } &[disabled] { @@ -89,14 +96,15 @@ border-color: var(--bui-border-disabled); color: var(--bui-fg-disabled); } + } - &[disabled] .bui-SelectValue { - color: var(--bui-fg-disabled); - } - - &[data-popup-open] .bui-SelectIcon { - transform: rotate(180deg); - } + .bui-SelectTriggerChevron { + display: grid; + place-content: center; + width: var(--select-item-height); + height: var(--select-item-height); + flex-shrink: 0; + flex-grow: 0; } .bui-SelectValue { @@ -128,37 +136,32 @@ } .bui-SelectItem { + box-sizing: border-box; position: relative; width: var(--anchor-width); display: grid; grid-template-areas: 'icon text'; grid-template-columns: 1rem 1fr; align-items: center; - padding-block: var(--bui-space-2); + min-height: var(--select-item-height); + padding-block: var(--bui-space-1); padding-left: var(--bui-space-3); padding-right: var(--bui-space-4); color: var(--bui-fg-primary); - border-radius: var(--bui-radius-3); cursor: pointer; user-select: none; font-size: var(--bui-font-size-3); - gap: var(--bui-space-1); + gap: var(--bui-space-2); outline: none; - &[data-focused] { - z-index: 0; - position: relative; - color: var(--bui-fg-primary); - } - &[data-focused]::before { content: ''; - z-index: -1; position: absolute; inset-block: 0; - inset-inline: 0.25rem; - border-radius: 0.25rem; - background-color: var(--bui-bg-tint-hover); + inset-inline: var(--bui-space-1); + border-radius: var(--bui-radius-2); + background: var(--bui-bg-surface-2); + z-index: -1; } &[data-disabled] { @@ -184,4 +187,73 @@ flex: 1; grid-area: text; } + + .bui-SelectSearchWrapper { + flex-shrink: 0; + margin-bottom: var(--bui-space-1); + display: flex; + align-items: center; + padding-inline: var(--bui-space-3) 0; + border-bottom: 1px solid var(--bui-border); + } + + .bui-SelectSearch { + border: none; + background-color: transparent; + padding: 0; + color: var(--bui-fg-primary); + flex: 1; + outline: none; + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + height: var(--select-item-height); + line-height: var(--select-item-height); + + &::placeholder { + color: var(--bui-fg-secondary); + } + + /* Hide native browser clear button */ + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + } + + .bui-SelectSearchClear { + flex: 0 0 auto; + display: grid; + place-content: center; + background-color: transparent; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + color: var(--bui-fg-secondary); + transition: color 0.2s ease-in-out; + width: var(--select-item-height); + height: var(--select-item-height); + + input:placeholder-shown + & { + display: none; + } + + &:hover { + color: var(--bui-fg-primary); + } + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-SelectNoResults { + padding-inline: var(--bui-space-3); + padding-block: var(--bui-space-2); + color: var(--bui-fg-secondary); + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-regular); + } } diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index 6e96e37f08..18263fe710 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -22,6 +22,9 @@ import { RiCloudLine } from '@remixicon/react'; const meta = { title: 'Backstage UI/Select', component: Select, + args: { + style: { width: 300 }, + }, } satisfies Meta; export default meta; @@ -34,6 +37,35 @@ const fontOptions = [ { value: 'cursive', label: 'Cursive' }, ]; +const countries = [ + { value: 'us', label: 'United States' }, + { value: 'ca', label: 'Canada' }, + { value: 'mx', label: 'Mexico' }, + { value: 'uk', label: 'United Kingdom' }, + { value: 'fr', label: 'France' }, + { value: 'de', label: 'Germany' }, + { value: 'it', label: 'Italy' }, + { value: 'es', label: 'Spain' }, + { value: 'jp', label: 'Japan' }, + { value: 'cn', label: 'China' }, + { value: 'in', label: 'India' }, + { value: 'br', label: 'Brazil' }, + { value: 'au', label: 'Australia' }, +]; + +const skills = [ + { value: 'react', label: 'React' }, + { value: 'typescript', label: 'TypeScript' }, + { value: 'javascript', label: 'JavaScript' }, + { value: 'python', label: 'Python' }, + { value: 'java', label: 'Java' }, + { value: 'csharp', label: 'C#' }, + { value: 'go', label: 'Go' }, + { value: 'rust', label: 'Rust' }, + { value: 'kotlin', label: 'Kotlin' }, + { value: 'swift', label: 'Swift' }, +]; + export const Default: Story = { args: { options: fontOptions, @@ -41,6 +73,38 @@ export const Default: Story = { }, }; +export const Searchable: Story = { + args: { + label: 'Country', + searchable: true, + searchPlaceholder: 'Search countries...', + options: countries, + }, +}; + +export const MultipleSelection: Story = { + args: { + label: 'Select multiple options', + selectionMode: 'multiple', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + { value: 'option4', label: 'Option 4' }, + ], + }, +}; + +export const SearchableMultiple: Story = { + args: { + label: 'Skills', + searchable: true, + selectionMode: 'multiple', + searchPlaceholder: 'Filter skills...', + options: skills, + }, +}; + export const Preview: Story = { args: { label: 'Font Family', diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index b1fec7dbe7..0b133d07c5 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -15,15 +15,7 @@ */ import { forwardRef, useEffect } from 'react'; -import { - Select as AriaSelect, - SelectValue, - Button, - Popover, - ListBox, - ListBoxItem, - Text, -} from 'react-aria-components'; +import { Select as AriaSelect, Popover } from 'react-aria-components'; import clsx from 'clsx'; import { SelectProps } from './types'; import { useStyles } from '../../hooks/useStyles'; @@ -31,10 +23,14 @@ import { FieldLabel } from '../FieldLabel'; import { FieldError } from '../FieldError'; import styles from './Select.module.css'; import stylesPopover from '../Popover/Popover.module.css'; -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'; +import { SelectTrigger } from './SelectTrigger'; +import { SelectContent } from './SelectContent'; /** @public */ -export const Select = forwardRef((props, ref) => { +export const Select = forwardRef< + HTMLDivElement, + SelectProps<'single' | 'multiple'> +>((props, ref) => { const { classNames: popoverClassNames } = useStyles('Popover'); const { classNames, dataAttributes, cleanedProps } = useStyles('Select', { size: 'small', @@ -47,14 +43,13 @@ export const Select = forwardRef((props, ref) => { label, description, options, - placeholder, - size, icon, + searchable, + searchPlaceholder, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, isRequired, secondaryLabel, - style, ...rest } = cleanedProps; @@ -66,7 +61,6 @@ export const Select = forwardRef((props, ref) => { } }, [label, ariaLabel, ariaLabelledBy]); - // If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required. const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null); return ( @@ -83,16 +77,7 @@ export const Select = forwardRef((props, ref) => { secondaryLabel={secondaryLabelText} description={description} /> - + ((props, ref) => { classNames.popover, styles[classNames.popover], )} + {...dataAttributes} > - - {options?.map(option => ( - -
- -
- - {option.label} - -
- ))} -
+
); diff --git a/packages/ui/src/components/Select/SelectContent.tsx b/packages/ui/src/components/Select/SelectContent.tsx new file mode 100644 index 0000000000..42070321a3 --- /dev/null +++ b/packages/ui/src/components/Select/SelectContent.tsx @@ -0,0 +1,74 @@ +/* + * 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 { + Input, + SearchField, + Autocomplete, + Button, +} from 'react-aria-components'; +import { useFilter } from 'react-aria'; +import { RiCloseCircleLine } from '@remixicon/react'; +import clsx from 'clsx'; +import { useStyles } from '../../hooks/useStyles'; +import { SelectListBox } from './SelectListBox'; +import styles from './Select.module.css'; +import type { Option } from './types'; + +interface SelectContentProps { + searchable?: boolean; + searchPlaceholder?: string; + options?: Array