diff --git a/.changeset/brave-groups-learn.md b/.changeset/brave-groups-learn.md new file mode 100644 index 0000000000..773e8eed49 --- /dev/null +++ b/.changeset/brave-groups-learn.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added `isPending` prop to Alert, Button, ButtonIcon, Table, and TableRoot as a replacement for the `loading` prop, aligning with React Aria naming conventions. The `loading` prop is now deprecated but still supported as an alias. CSS selectors now use `data-ispending` instead of `data-loading` for styling pending states; `data-loading` is still emitted for backward compatibility but will be removed alongside the `loading` prop. + +**Affected components:** Alert, Button, ButtonIcon, Table, TableRoot diff --git a/.changeset/fix-scheduler-sleep-overflow.md b/.changeset/fix-scheduler-sleep-overflow.md new file mode 100644 index 0000000000..bf51ade22d --- /dev/null +++ b/.changeset/fix-scheduler-sleep-overflow.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Fixed scheduler `sleep` firing immediately for durations longer than ~24.8 days, caused by Node.js `setTimeout` overflowing its 32-bit millisecond limit. diff --git a/.changeset/funny-areas-rescue.md b/.changeset/funny-areas-rescue.md new file mode 100644 index 0000000000..0b13201f82 --- /dev/null +++ b/.changeset/funny-areas-rescue.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': minor +--- + +Add support for flex item props (`grow`, `shrink`, and `basis`) to `Box`, `Card`, `Grid`, and `Flex` itself. + +**Affected components:** Box, Card, Grid, Flex diff --git a/.changeset/owner-column-cleanup.md b/.changeset/owner-column-cleanup.md new file mode 100644 index 0000000000..709f74f781 --- /dev/null +++ b/.changeset/owner-column-cleanup.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Simplified the `OwnerEntityColumn` in the task list to rely on `EntityRefLink` and the entity presentation API instead of manually fetching entities from the catalog. diff --git a/docs-ui/src/app/components/alert/components.tsx b/docs-ui/src/app/components/alert/components.tsx index afc5eb654c..2df11d4a3a 100644 --- a/docs-ui/src/app/components/alert/components.tsx +++ b/docs-ui/src/app/components/alert/components.tsx @@ -119,20 +119,20 @@ export const WithActionsAndDescriptions = () => { ); }; -export const LoadingStates = () => { +export const PendingStates = () => { return ( - + diff --git a/docs-ui/src/app/components/alert/page.mdx b/docs-ui/src/app/components/alert/page.mdx index cdfdf184e0..3df813b7e9 100644 --- a/docs-ui/src/app/components/alert/page.mdx +++ b/docs-ui/src/app/components/alert/page.mdx @@ -8,7 +8,7 @@ import { statusVariantsSnippet, withDescriptionSnippet, withActionsSnippet, - loadingStatesSnippet, + pendingStatesSnippet, withoutIconsSnippet, customIconSnippet, } from './snippets'; @@ -17,7 +17,7 @@ import { StatusVariants, WithDescription, WithActions, - LoadingStates, + PendingStates, WithoutIcons, CustomIcon, } from './components'; @@ -79,16 +79,16 @@ Include custom actions like buttons for interactive alerts. code={withActionsSnippet} /> -### Loading States +### Pending State -The loading spinner replaces the icon to indicate an ongoing process. +The pending spinner replaces the icon to indicate an ongoing process. } - code={loadingStatesSnippet} + preview={} + code={pendingStatesSnippet} /> ### Without Icons diff --git a/docs-ui/src/app/components/alert/props-definition.ts b/docs-ui/src/app/components/alert/props-definition.ts index 6f2650fe42..85b6e021c1 100644 --- a/docs-ui/src/app/components/alert/props-definition.ts +++ b/docs-ui/src/app/components/alert/props-definition.ts @@ -10,31 +10,45 @@ export const alertPropDefs: Record = { values: ['info', 'success', 'warning', 'danger'], responsive: true, default: 'info', + description: + 'Visual status of the alert, which determines color and default icon.', }, icon: { type: 'enum', values: ['boolean', 'React.ReactElement'], responsive: false, + description: + 'Set to true to show the default status icon, or pass a custom icon element. Set to false to hide the icon.', + }, + isPending: { + type: 'boolean', + default: 'false', + description: + 'Replaces the icon with a spinner to indicate a pending operation.', }, loading: { - type: 'enum', - values: ['boolean'], - responsive: false, + type: 'boolean', + default: 'false', + description: 'Deprecated. Use `isPending` instead.', }, title: { type: 'enum', values: ['React.ReactNode'], responsive: false, + description: 'Primary message displayed in the alert.', }, description: { type: 'enum', values: ['React.ReactNode'], responsive: false, + description: 'Additional detail shown below the title.', }, customActions: { type: 'enum', values: ['React.ReactNode'], responsive: false, + description: + 'Custom action buttons displayed on the right side of the alert.', }, m: { type: 'enum', diff --git a/docs-ui/src/app/components/alert/snippets.ts b/docs-ui/src/app/components/alert/snippets.ts index 739dfde9db..aa4f05ed1c 100644 --- a/docs-ui/src/app/components/alert/snippets.ts +++ b/docs-ui/src/app/components/alert/snippets.ts @@ -97,13 +97,13 @@ export const withActionsAndDescriptionsSnippet = ``; -export const loadingStatesSnippet = ` - - +export const pendingStatesSnippet = ` + + diff --git a/docs-ui/src/app/components/button-icon/components.tsx b/docs-ui/src/app/components/button-icon/components.tsx index 3ed421289c..ba376549d2 100644 --- a/docs-ui/src/app/components/button-icon/components.tsx +++ b/docs-ui/src/app/components/button-icon/components.tsx @@ -56,12 +56,12 @@ export const Disabled = () => { ); }; -export const Loading = () => { +export const Pending = () => { return ( } variant="primary" - loading + isPending aria-label="Cloud" /> ); diff --git a/docs-ui/src/app/components/button-icon/page.mdx b/docs-ui/src/app/components/button-icon/page.mdx index 51ff0ea5a0..6f4678d29c 100644 --- a/docs-ui/src/app/components/button-icon/page.mdx +++ b/docs-ui/src/app/components/button-icon/page.mdx @@ -7,9 +7,9 @@ import { variantsSnippet, sizesSnippet, disabledSnippet, - loadingSnippet, + isPendingSnippet, } from './snippets'; -import { Variants, Sizes, Disabled, Loading } from './components'; +import { Variants, Sizes, Disabled, Pending } from './components'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; import { ButtonIconDefinition } from '../../../utils/definitions'; @@ -63,7 +63,7 @@ export const reactAriaUrls = { code={disabledSnippet} /> -### Loading +### Pending Shows a spinner during async operations. @@ -71,8 +71,8 @@ Shows a spinner during async operations. align="center" py={4} open - preview={} - code={loadingSnippet} + preview={} + code={isPendingSnippet} /> diff --git a/docs-ui/src/app/components/button-icon/props-definition.tsx b/docs-ui/src/app/components/button-icon/props-definition.tsx index 05917cc2b4..d0b9a5717c 100644 --- a/docs-ui/src/app/components/button-icon/props-definition.tsx +++ b/docs-ui/src/app/components/button-icon/props-definition.tsx @@ -42,11 +42,16 @@ export const buttonIconPropDefs: Record = { default: 'false', description: 'Prevents interaction and applies disabled styling.', }, - loading: { + isPending: { type: 'boolean', default: 'false', description: 'Shows a spinner and disables the button.', }, + loading: { + type: 'boolean', + default: 'false', + description: 'Deprecated. Use `isPending` instead.', + }, type: { type: 'enum', values: ['button', 'submit', 'reset'], diff --git a/docs-ui/src/app/components/button-icon/snippets.ts b/docs-ui/src/app/components/button-icon/snippets.ts index b1ab27b77b..46e2eb2ab8 100644 --- a/docs-ui/src/app/components/button-icon/snippets.ts +++ b/docs-ui/src/app/components/button-icon/snippets.ts @@ -20,4 +20,4 @@ export const disabledSnippet = ` } variant="tertiary" aria-label="Cloud" /> `; -export const loadingSnippet = `} variant="primary" loading aria-label="Cloud" />`; +export const isPendingSnippet = `} variant="primary" isPending aria-label="Cloud" />`; diff --git a/docs-ui/src/app/components/button/components.tsx b/docs-ui/src/app/components/button/components.tsx index 1fe13f6630..5829bd978e 100644 --- a/docs-ui/src/app/components/button/components.tsx +++ b/docs-ui/src/app/components/button/components.tsx @@ -75,9 +75,9 @@ export const Destructive = () => { ); }; -export const Loading = () => { +export const Pending = () => { return ( - ); diff --git a/docs-ui/src/app/components/button/page.mdx b/docs-ui/src/app/components/button/page.mdx index 6434a7f4a4..8183f8ca6c 100644 --- a/docs-ui/src/app/components/button/page.mdx +++ b/docs-ui/src/app/components/button/page.mdx @@ -8,7 +8,7 @@ import { withIconsSnippet, disabledSnippet, destructiveSnippet, - loadingSnippet, + isPendingSnippet, asLinkSnippet, buttonSnippetUsage, buttonResponsiveSnippet, @@ -24,7 +24,7 @@ import { WithIcons, Disabled, Destructive, - Loading, + Pending, AsLink, } from './components'; @@ -34,7 +34,7 @@ export const reactAriaUrls = { } code={variantsSnippet} /> @@ -110,7 +110,7 @@ Use the `destructive` prop for dangerous actions like delete or remove. layout="side-by-side" /> -### Loading +### Pending Shows a spinner and disables interaction during async operations. @@ -118,8 +118,8 @@ Shows a spinner and disables interaction during async operations. align="center" py={4} open - preview={} - code={loadingSnippet} + preview={} + code={isPendingSnippet} layout="side-by-side" /> diff --git a/docs-ui/src/app/components/button/props-definition.tsx b/docs-ui/src/app/components/button/props-definition.tsx index 0f94fcffaf..3c82a59bb4 100644 --- a/docs-ui/src/app/components/button/props-definition.tsx +++ b/docs-ui/src/app/components/button/props-definition.tsx @@ -48,11 +48,16 @@ export const buttonPropDefs: Record = { default: 'false', description: 'Prevents interaction and applies disabled styling.', }, - loading: { + isPending: { type: 'boolean', default: 'false', description: 'Shows a spinner and disables the button.', }, + loading: { + type: 'boolean', + default: 'false', + description: 'Deprecated. Use `isPending` instead.', + }, children: { type: 'enum', values: ['ReactNode'], diff --git a/docs-ui/src/app/components/button/snippets.ts b/docs-ui/src/app/components/button/snippets.ts index 6b8aba7e26..2af42df2a7 100644 --- a/docs-ui/src/app/components/button/snippets.ts +++ b/docs-ui/src/app/components/button/snippets.ts @@ -55,7 +55,7 @@ export const destructiveSnippet = ` `; -export const loadingSnippet = ``; diff --git a/docs-ui/src/app/components/table/page.mdx b/docs-ui/src/app/components/table/page.mdx index d3ed0d5350..3bc6e0cd24 100644 --- a/docs-ui/src/app/components/table/page.mdx +++ b/docs-ui/src/app/components/table/page.mdx @@ -202,11 +202,11 @@ Use `mode: 'cursor'` when your API uses cursor-based pagination (common with Gra -### Loading States +### Pending States -When fetching data, the table shows a loading state. If the user triggers a new query (by paginating, sorting, or searching) while previous data is displayed, the table enters a "stale" state where it continues showing the previous data until new data arrives. This prevents jarring layout shifts. +When fetching data, the table shows a pending state. If the user triggers a new query (by paginating, sorting, or searching) while previous data is displayed, the table enters a "stale" state where it continues showing the previous data until new data arrives. This prevents jarring layout shifts. -You can access these states via `tableProps.loading` and `tableProps.isStale` if you need to render additional loading indicators. +You can access these states via `tableProps.isPending` and `tableProps.isStale` if you need to render additional pending indicators. ## Combining Features diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx index 30381e30f7..fcec3a6278 100644 --- a/docs-ui/src/app/components/table/props-definition.tsx +++ b/docs-ui/src/app/components/table/props-definition.tsx @@ -166,7 +166,7 @@ export const useTableReturnPropDefs: Record = { description: ( <> Props to spread onto the Table component. Includes data, - loading, error, pagination, and sort state. + isPending, error, pagination, and sort state. ), }, @@ -207,10 +207,15 @@ export const tablePropDefs: Record = { values: ['T[]'], description: 'Array of data items to display in the table.', }, + isPending: { + type: 'boolean', + default: 'false', + description: 'Whether the table is in a pending state.', + }, loading: { type: 'boolean', default: 'false', - description: 'Whether the table is in a loading state.', + description: 'Deprecated. Use `isPending` instead.', }, isStale: { type: 'boolean', @@ -466,17 +471,22 @@ export const tableRootPropDefs: Record = { ), }, - loading: { + isPending: { type: 'boolean', default: 'false', description: ( <> - Whether the table is in a loading state (e.g., initial data fetch). Adds{' '} - aria-busy attribute and data-loading data + Whether the table is in a pending state (e.g., initial data fetch). Adds{' '} + aria-busy attribute and data-ispending data attribute for styling. ), }, + loading: { + type: 'boolean', + default: 'false', + description: 'Deprecated. Use `isPending` instead.', + }, }; export const columnPropDefs: Record = { diff --git a/microsite/blog/2026-04-20-backstagecon-kubecon-26-amsterdam.mdx b/microsite/blog/2026-04-20-backstagecon-kubecon-26-amsterdam.mdx new file mode 100644 index 0000000000..6848e9c55b --- /dev/null +++ b/microsite/blog/2026-04-20-backstagecon-kubecon-26-amsterdam.mdx @@ -0,0 +1,64 @@ +--- +title: 'Backstage in Amsterdam: Highlights from BackstageCon and KubeCon + CloudNativeCon Europe 2026' +author: André Wanlin, Emma Indal & Ruxandra Stanciu, Spotify +--- + +![Backstage in Amsterdam: Highlights from BackstageCon and KubeCon + CloudNativeCon Europe 2026](assets/2026-04-20/backstagecon-kubecon-eu-2026-hero.png) + +Amsterdam delivered. From the moment BackstageCon opened its doors on March 23, the Backstage community was in full force — long-time contributors comparing notes with teams who had only just started their IDP journey. Across four days of BackstageCon and KubeCon + CloudNativeCon Europe 2026, there was a lot to take in: an energetic day of community talks, a documentary premiere, a standing room–only maintainers session, a live demo on the Keynote mainstage, and a ContribFest that turned issues into pull requests in real time. Read on for the highlights — and catch up on all the BackstageCon talks in the [full recordings playlist](https://youtube.com/playlist?list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995&si=dAZ-Gg2A0hspfNbx). + +{/* truncate */} + +## BackstageCon: What the community is building + +![BackstageCon Europe 2026 in Amsterdam](assets/2026-04-20/kc26ams-backstagecon-crowd.jpg) +📸 _[CNCF](https://www.flickr.com/photos/143247548@N03/albums/72177720332674037/)_ + +BackstageCon kicked off the week with a full day of talks organized by the community, for the community — emceed by [Balaji Sivasubramanian](https://www.linkedin.com/in/balajisiva/) (Red Hat) and [André Wanlin](https://www.linkedin.com/in/andr%C3%A9-wanlin-31a00a16a/) (Spotify). The [full schedule](https://colocatedeventseu2026.sched.com/overview/area/BackstageCon) had something for every stage of the Backstage journey, but a few themes stood out across the day. + +On the engineering side, Booking.com's [Symbat Nurbay](https://www.linkedin.com/in/symbat-nurbay-b981a6134/) and [Xicu Piñera](https://www.linkedin.com/in/xicupinera/) shared [how they're working toward a unified developer experience](https://www.youtube.com/watch?v=vtlI4KoK_Tc&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995) across a large, complex organization — a relatable challenge for many in the room. [Krzysztof Janota](https://www.linkedin.com/in/krzysztof-janota-91aba1143/) and [Dusan Askovic](https://www.linkedin.com/in/dusanaskovic/) from ING Bank N.V. went [deep on how to keep a Backstage deployment healthy and collaborative](https://www.youtube.com/watch?v=zJehyAxDhV8&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995) in a big institution, covering the governance patterns that make it scale without fragmenting. On the catalog and tooling side, [Sebastian Poxhofer](https://www.linkedin.com/in/sebastian-poxhofer/) from N26 showed [how adding a platform CLI on top of the Backstage catalog](https://www.youtube.com/watch?v=4BZC0TcoW1Y&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995) can open up new workflows and make the catalog more actionable for platform teams. + +One of the day's surprises came from the lightning talk slot: [Mathilde Ançay](https://www.linkedin.com/in/mathilde-an%C3%A7ay-2b4b84236/) from HEIG-VD took an [unexpected angle](https://www.youtube.com/watch?v=-tTJeEdQIHU&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995), tracing an unlikely path from philosophy to a Backstage plugin — it's the kind of talk that's hard to summarize, so just watch it. + +And the buzziest moment of the day? The panel — [Building a Healthy Backstage Plugins Ecosystem](https://www.youtube.com/watch?v=67fFjQMRKyM&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995) — with [Paul Schultz](https://www.linkedin.com/in/schultzp2020/) and [Hope Hadfield](https://www.linkedin.com/in/hope-hadfield/) (Red Hat), [Heikki Hellgren](https://www.linkedin.com/in/heikkihellgren/) (OP Financial Group), [Peter Macdonald](https://www.linkedin.com/in/peter-j-macdonald/) (VodafoneZiggo), and [Aramis Sennyey](https://www.linkedin.com/in/aramis-sennyey/) (DoorDash). The conversation was wide-ranging and the Q&A spilled past the scheduled time, which felt like a good sign. + +📺 Those are just a few picks — there's plenty more to explore in the [full BackstageCon playlist](https://youtube.com/playlist?list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995&si=dAZ-Gg2A0hspfNbx). + +## Now playing: The Backstage story, on film + +![The Backstage Documentary screening at KubeCon Amsterdam 2026](assets/2026-04-20/kc26ams-documentary.jpg) +📸 _[CNCF](https://www.flickr.com/photos/143247548@N03/albums/72177720332674037/)_ + +One of the week's most memorable moments had nothing to do with a slide deck. The [Backstage Documentary](https://www.youtube.com/watch?v=gJHYTlO0VwA&list=PLf1KFlSkDLIClkq80u8EBijuxHklONfo0) made its world premiere at KubeCon Amsterdam, and the room filled up with community members eager to watch the story of how Backstage evolved from an internal tool at Spotify into one of the most [widely adopted](https://www.youtube.com/watch?v=F7DKUThZ2I0&list=PLf1KFlSkDLIBmA5TLXn2BzEHmwWzckP8y) and [active](https://www.cncf.io/blog/2026/02/09/what-cncf-project-velocity-in-2025-reveals-about-cloud-natives-future/) open source projects in the cloud native ecosystem. The film surfaces voices from across the project's history — including some perspectives that even long-time contributors hadn't heard before. If you haven't watched it yet, grab a snack and set aside 30 minutes to see the past, present, and future of Backstage. + +## Standing room only: The State of Backstage in 2026 + +![Core maintainers present at KubeCon + CloudNativeCon Europe 2026](assets/2026-04-20/kc26ams-maintainers-talk.jpg) + +The State of Backstage talk has become one of the community's most anticipated events at every KubeCon. In Amsterdam, that anticipation was on full display: over 600 people were seated, close to 1,000 had registered, and more were turned away at the door. Core maintainers [Ben Lambert](https://www.linkedin.com/in/benlambert1/) and [Patrik Oldsberg](https://www.linkedin.com/in/patrik-oldsberg-326a216b/) [covered the full breadth](https://www.youtube.com/watch?v=tFsp5bpKwdk&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995) of what's been happening across the project — contributions, ecosystem growth, the New Frontend System now that it's adoption-ready, and the work underway on MCP support and an AI-native Backstage direction. + +## A demo on the big stage + +![The Backstage Keynote Demo at KubeCon + CloudNativeCon Europe 2026](assets/2026-04-20/kc26ams-keynote-demo.jpg) +📸 _[CNCF](https://www.flickr.com/photos/143247548@N03/albums/72177720332674037/)_ + +On Thursday morning, the core maintainers stepped up for something a little different: a [live demo on the KubeCon Keynote mainstage](https://www.youtube.com/watch?v=cTXlkhKXgyE&t=298s&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995). In front of over 1,500 attendees, they showcased some of Backstage's newest capabilities — including the MCP and AI-related features covered in the maintainers talk the day before. It's one thing to hear about new features in a talk; seeing them demonstrated live in a keynote setting, to an audience that large, is a different kind of moment for our open source project. + +## ContribFest: Open source, live + +![Backstage ContribFest at KubeCon + CloudNativeCon Europe 2026](assets/2026-04-20/kc26ams-contribfest.jpg) + +Rounding out the week was the fourth-ever Backstage ContribFest, co-hosted by [André Wanlin](https://www.linkedin.com/in/andr%C3%A9-wanlin-31a00a16a/) and [Emma Indal](https://www.linkedin.com/in/emma-indal/) (Spotify), [Heikki Hellgren](https://www.linkedin.com/in/heikkihellgren/) (OP Financial Group), and [Elaine Bezerra](https://www.linkedin.com/in/elaine-mattos/) (DB Systel GmbH). Around 50 attendees showed up ready to contribute — some experienced, some brand new to the project — and spent the session working through real issues in the Backstage and Community Plugins repositories alongside core maintainers and community contributors. + +Not every contribution makes it into a merged PR on the day, but ContribFest is often where the work starts. Keep an eye on the release notes — some of what was kicked off in Amsterdam may already be on its way to a future release. + +Want to see what came out of Amsterdam and past ContribFests? Head over to the [ContribFest web app](https://contribfest.backstage.io/contrib-champs/) to browse the full history of contributions from every session. + +## Tot ziens, Amsterdam! 🌷 + +![Goodbye, Amsterdam!](assets/2026-04-20/backstagecon-kubecon-eu-2026-closing.png) +📸 _[CNCF](https://www.flickr.com/photos/143247548@N03/albums/72177720332674037/)_ + +What a week. BackstageCon, a documentary debut, a packed maintainers room, a keynote demo, and a ContribFest — Amsterdam showed that the open source Backstage community has a lot of momentum and a lot to say. Catch up on everything you missed in the [BackstageCon playlist](https://www.youtube.com/playlist?list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995), and watch the [Backstage Documentary](https://www.youtube.com/watch?v=gJHYTlO0VwA&list=PLf1KFlSkDLIClkq80u8EBijuxHklONfo0) and [Keynote Demo](https://www.youtube.com/watch?v=cTXlkhKXgyE&t=298s&list=PL8iP9yIjU0Q0eZv3LncHLG3g5itFec995) if you haven't already. + +See you in Salt Lake City 🏔️ at [BackstageCon and KubeCon + CloudNativeCon North America](https://events.linuxfoundation.org/kubecon-cloudnativecon-north-america/), November 9-12, 2026! diff --git a/microsite/blog/assets/2026-04-20/backstagecon-kubecon-eu-2026-closing.png b/microsite/blog/assets/2026-04-20/backstagecon-kubecon-eu-2026-closing.png new file mode 100644 index 0000000000..3a2c32fe0e Binary files /dev/null and b/microsite/blog/assets/2026-04-20/backstagecon-kubecon-eu-2026-closing.png differ diff --git a/microsite/blog/assets/2026-04-20/backstagecon-kubecon-eu-2026-hero.png b/microsite/blog/assets/2026-04-20/backstagecon-kubecon-eu-2026-hero.png new file mode 100644 index 0000000000..5950a7cbbb Binary files /dev/null and b/microsite/blog/assets/2026-04-20/backstagecon-kubecon-eu-2026-hero.png differ diff --git a/microsite/blog/assets/2026-04-20/kc26ams-backstagecon-crowd.jpg b/microsite/blog/assets/2026-04-20/kc26ams-backstagecon-crowd.jpg new file mode 100644 index 0000000000..46cb3299b3 Binary files /dev/null and b/microsite/blog/assets/2026-04-20/kc26ams-backstagecon-crowd.jpg differ diff --git a/microsite/blog/assets/2026-04-20/kc26ams-contribfest.jpg b/microsite/blog/assets/2026-04-20/kc26ams-contribfest.jpg new file mode 100644 index 0000000000..385be4afb6 Binary files /dev/null and b/microsite/blog/assets/2026-04-20/kc26ams-contribfest.jpg differ diff --git a/microsite/blog/assets/2026-04-20/kc26ams-documentary.jpg b/microsite/blog/assets/2026-04-20/kc26ams-documentary.jpg new file mode 100644 index 0000000000..ab751b0eb6 Binary files /dev/null and b/microsite/blog/assets/2026-04-20/kc26ams-documentary.jpg differ diff --git a/microsite/blog/assets/2026-04-20/kc26ams-keynote-demo.jpg b/microsite/blog/assets/2026-04-20/kc26ams-keynote-demo.jpg new file mode 100644 index 0000000000..1f12a3a3fd Binary files /dev/null and b/microsite/blog/assets/2026-04-20/kc26ams-keynote-demo.jpg differ diff --git a/microsite/blog/assets/2026-04-20/kc26ams-maintainers-talk.jpg b/microsite/blog/assets/2026-04-20/kc26ams-maintainers-talk.jpg new file mode 100644 index 0000000000..2bdcf9d2eb Binary files /dev/null and b/microsite/blog/assets/2026-04-20/kc26ams-maintainers-talk.jpg differ diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts index cc8aac5f51..c91466f667 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/PluginTaskSchedulerImpl.test.ts @@ -150,13 +150,13 @@ describe('PluginTaskManagerImpl', () => { id: 'task1', timeout: Duration.fromMillis(5000), frequency: Duration.fromObject({ years: 1 }), - initialDelay: Duration.fromObject({ years: 1 }), + initialDelay: Duration.fromObject({ seconds: 60 }), fn, scope: 'global', }); await manager.triggerTask('task1'); - jest.advanceTimersByTime(5000); + await jest.advanceTimersByTimeAsync(65_000); await promise; expect(fn).toHaveBeenCalledWith(expect.any(AbortSignal)); diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts index e2536abb88..c4126588fb 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.test.ts @@ -56,6 +56,29 @@ describe('util', () => { await promise; expect(true).toBe(true); }, 1_000); + + it('handles durations longer than 2^31 ms by chunking setTimeout calls', async () => { + jest.useFakeTimers(); + try { + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + let resolved = false; + const promise = sleep(Duration.fromMillis(thirtyDaysMs)).then(() => { + resolved = true; + }); + + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(2 ** 30); + expect(resolved).toBe(false); + expect(jest.getTimerCount()).toBe(1); + + await jest.advanceTimersByTimeAsync(thirtyDaysMs - 2 ** 30); + await promise; + expect(resolved).toBe(true); + } finally { + jest.useRealTimers(); + } + }); }); describe('delegateAbortController', () => { diff --git a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts index 3e42d198df..06d6750ba0 100644 --- a/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts +++ b/packages/backend-defaults/src/entrypoints/scheduler/lib/util.ts @@ -54,6 +54,11 @@ export function nowPlus(duration: Duration | undefined, knex: Knex) { return knex.raw(`now() + interval '${seconds} seconds'`); } +// Node.js setTimeout uses a 32-bit signed integer internally, so timeouts +// longer than 2^31-1 ms (~24.8 days) fire immediately. We cap each individual +// wait at 2^30 ms (~12.4 days) and loop until the full duration has elapsed. +const MAX_TIMEOUT_MS = 2 ** 30; + /** * Sleep for the given duration, but return sooner if the abort signal * triggers. @@ -69,19 +74,34 @@ export async function sleep( return; } - await new Promise(resolve => { - let timeoutHandle: NodeJS.Timeout | undefined = undefined; + let remaining = duration.as('milliseconds'); + if (!Number.isFinite(remaining) || remaining <= 0) { + return; + } - const done = () => { + await new Promise(resolve => { + let timeoutHandle: NodeJS.Timeout | undefined; + + const finish = () => { if (timeoutHandle) { clearTimeout(timeoutHandle); } - abortSignal?.removeEventListener('abort', done); + abortSignal?.removeEventListener('abort', finish); resolve(); }; - timeoutHandle = setTimeout(done, duration.as('milliseconds')); - abortSignal?.addEventListener('abort', done); + const tick = () => { + if (remaining <= 0) { + finish(); + return; + } + const chunk = Math.min(remaining, MAX_TIMEOUT_MS); + remaining -= chunk; + timeoutHandle = setTimeout(tick, chunk); + }; + + abortSignal?.addEventListener('abort', finish); + tick(); }); } diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index cb19d612b5..08ec6887e0 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -222,6 +222,9 @@ export const AlertDefinition: { readonly dataAttribute: true; readonly default: 'info'; }; + readonly isPending: { + readonly dataAttribute: true; + }; readonly loading: { readonly dataAttribute: true; }; @@ -239,6 +242,7 @@ export const AlertDefinition: { export type AlertOwnProps = { status?: Responsive<'info' | 'success' | 'warning' | 'danger'>; icon?: boolean | ReactElement; + isPending?: boolean; loading?: boolean; customActions?: ReactNode; title?: ReactNode; @@ -431,6 +435,9 @@ export const BoxDefinition: { 'py', 'position', 'display', + 'grow', + 'shrink', + 'basis', 'width', 'minWidth', 'maxWidth', @@ -452,6 +459,7 @@ export type BoxOwnProps = { // @public (undocumented) export interface BoxProps extends SpaceProps, + FlexItemProps, BoxOwnProps, BoxUtilityProps, Omit, 'children'> {} @@ -510,6 +518,9 @@ export const ButtonDefinition: { readonly destructive: { readonly dataAttribute: true; }; + readonly isPending: { + readonly dataAttribute: true; + }; readonly loading: { readonly dataAttribute: true; }; @@ -545,6 +556,9 @@ export const ButtonIconDefinition: { readonly dataAttribute: true; readonly default: 'primary'; }; + readonly isPending: { + readonly dataAttribute: true; + }; readonly loading: { readonly dataAttribute: true; }; @@ -558,6 +572,7 @@ export type ButtonIconOwnProps = { size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; icon?: ReactElement; + isPending?: boolean; loading?: boolean; className?: string; }; @@ -623,6 +638,7 @@ export type ButtonOwnProps = { destructive?: boolean; iconStart?: ReactElement; iconEnd?: ReactElement; + isPending?: boolean; loading?: boolean; children?: ReactNode; className?: string; @@ -642,6 +658,7 @@ export const Card: ForwardRefExoticComponent< export type CardBaseProps = { children?: ReactNode; className?: string; + style?: CSSProperties; }; // @public @@ -702,7 +719,9 @@ export const CardDefinition: { readonly target: {}; readonly rel: {}; readonly download: {}; + readonly style: {}; }; + readonly utilityProps: readonly ['grow', 'shrink', 'basis']; }; // @public @@ -786,10 +805,12 @@ export type CardOwnProps = Pick< | 'target' | 'rel' | 'download' + | 'style' >; // @public export type CardProps = CardBaseProps & + FlexItemProps & Omit, 'onClick'> & (CardButtonVariant | CardLinkVariant | CardStaticVariant); @@ -1315,12 +1336,22 @@ export const FlexDefinition: { 'align', 'justify', 'direction', + 'grow', + 'shrink', + 'basis', ]; }; // @public (undocumented) export type FlexDirection = 'row' | 'column'; +// @public +export interface FlexItemProps { + basis?: Responsive; + grow?: Responsive; + shrink?: Responsive; +} + // @public (undocumented) export type FlexOwnProps = { children: ReactNode; @@ -1332,6 +1363,7 @@ export type FlexOwnProps = { // @public (undocumented) export interface FlexProps extends SpaceProps, + FlexItemProps, FlexOwnProps, Omit, 'children'> { // (undocumented) @@ -1422,6 +1454,9 @@ export const GridDefinition: { 'pt', 'px', 'py', + 'grow', + 'shrink', + 'basis', ]; }; @@ -1478,6 +1513,7 @@ export type GridOwnProps = { // @public (undocumented) export interface GridProps extends SpaceProps, + FlexItemProps, GridOwnProps, Omit, 'children'> { // (undocumented) @@ -2784,6 +2820,9 @@ export const TableDefinition: { readonly stale: { readonly dataAttribute: true; }; + readonly isPending: { + readonly dataAttribute: true; + }; readonly loading: { readonly dataAttribute: true; }; @@ -2887,8 +2926,10 @@ export interface TableProps { // (undocumented) error?: Error; // (undocumented) - isStale?: boolean; + isPending?: boolean; // (undocumented) + isStale?: boolean; + // @deprecated (undocumented) loading?: boolean; // (undocumented) pagination: TablePaginationType; @@ -2910,6 +2951,7 @@ export const TableRoot: (props: TableRootProps) => JSX_2.Element; // @public (undocumented) export type TableRootOwnProps = { stale?: boolean; + isPending?: boolean; loading?: boolean; }; diff --git a/packages/ui/src/components/Alert/Alert.stories.tsx b/packages/ui/src/components/Alert/Alert.stories.tsx index 589d55b7dd..486daad2ef 100644 --- a/packages/ui/src/components/Alert/Alert.stories.tsx +++ b/packages/ui/src/components/Alert/Alert.stories.tsx @@ -32,7 +32,7 @@ const meta = preview.meta({ icon: { control: 'boolean', }, - loading: { + isPending: { control: 'boolean', }, }, @@ -208,25 +208,25 @@ export const WithActionsAndDescriptions = WithActions.extend({ }, }); -export const LoadingVariants = meta.story({ +export const PendingVariants = meta.story({ render: () => ( Info Success - + Warning @@ -234,27 +234,27 @@ export const LoadingVariants = meta.story({ ), }); -export const LoadingWithDescription = meta.story({ +export const PendingWithDescription = meta.story({ render: () => ( diff --git a/packages/ui/src/components/Alert/Alert.tsx b/packages/ui/src/components/Alert/Alert.tsx index 54eb9cffae..ef08528cc4 100644 --- a/packages/ui/src/components/Alert/Alert.tsx +++ b/packages/ui/src/components/Alert/Alert.tsx @@ -32,7 +32,7 @@ import { AlertDefinition } from './definition'; * * @remarks * The Alert component supports multiple status variants (info, success, warning, danger) - * and can display icons, loading states, and custom actions. It automatically handles + * and can display icons, pending states, and custom actions. It automatically handles * icon selection based on status when the icon prop is set to true. * * @example @@ -53,14 +53,14 @@ import { AlertDefinition } from './definition'; * ``` * * @example - * With custom actions and loading state: + * With custom actions and pending state: * ```tsx * * @@ -76,13 +76,21 @@ export const Alert = forwardRef( (props: AlertProps, ref: Ref) => { const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition( AlertDefinition, - props, + // Merge deprecated `loading` into `isPending` so data attributes and + // internal logic only need to check a single prop. + { + ...props, + isPending: + props.isPending || props.loading + ? true + : props.isPending ?? props.loading, + }, ); const { classes, status, icon, - loading, + isPending, customActions, title, description, @@ -132,7 +140,7 @@ export const Alert = forwardRef( data-has-description={description ? 'true' : 'false'} >
- {loading ? ( + {isPending ? (
()({ }, propDefs: { status: { dataAttribute: true, default: 'info' }, + isPending: { dataAttribute: true }, loading: { dataAttribute: true }, icon: {}, customActions: {}, diff --git a/packages/ui/src/components/Alert/types.ts b/packages/ui/src/components/Alert/types.ts index ce36d4c5fd..7018f25f6b 100644 --- a/packages/ui/src/components/Alert/types.ts +++ b/packages/ui/src/components/Alert/types.ts @@ -21,6 +21,8 @@ import type { Responsive, MarginProps } from '../../types'; export type AlertOwnProps = { status?: Responsive<'info' | 'success' | 'warning' | 'danger'>; icon?: boolean | ReactElement; + isPending?: boolean; + /** @deprecated Use `isPending` instead. */ loading?: boolean; customActions?: ReactNode; title?: ReactNode; diff --git a/packages/ui/src/components/Box/Box.tsx b/packages/ui/src/components/Box/Box.tsx index 146292a344..1a2930dd2b 100644 --- a/packages/ui/src/components/Box/Box.tsx +++ b/packages/ui/src/components/Box/Box.tsx @@ -36,7 +36,7 @@ export const Box = forwardRef((props, ref) => { { ref, className: classes.root, - style: { ...ownProps.style, ...utilityStyle }, + style: { ...utilityStyle, ...ownProps.style }, ...dataAttributes, ...restProps, }, diff --git a/packages/ui/src/components/Box/definition.ts b/packages/ui/src/components/Box/definition.ts index 3837dc8ea4..c3a38326b2 100644 --- a/packages/ui/src/components/Box/definition.ts +++ b/packages/ui/src/components/Box/definition.ts @@ -52,6 +52,9 @@ export const BoxDefinition = defineComponent()({ 'py', 'position', 'display', + 'grow', + 'shrink', + 'basis', 'width', 'minWidth', 'maxWidth', diff --git a/packages/ui/src/components/Box/types.ts b/packages/ui/src/components/Box/types.ts index 1b4259099b..e333690e2e 100644 --- a/packages/ui/src/components/Box/types.ts +++ b/packages/ui/src/components/Box/types.ts @@ -15,7 +15,12 @@ */ import type { ReactNode, CSSProperties } from 'react'; -import type { Responsive, ProviderBg, SpaceProps } from '../../types'; +import type { + Responsive, + ProviderBg, + SpaceProps, + FlexItemProps, +} from '../../types'; /** @public */ export type BoxOwnProps = { @@ -43,6 +48,7 @@ export type BoxUtilityProps = { /** @public */ export interface BoxProps extends SpaceProps, + FlexItemProps, BoxOwnProps, BoxUtilityProps, Omit, 'children'> {} diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 8273e77d5a..ffffaf52f0 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -54,7 +54,7 @@ cursor: not-allowed; } - &[data-loading='true'] { + &[data-ispending='true'] { cursor: wait; } } @@ -66,7 +66,7 @@ --fg: var(--bui-fg-solid); &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg: var(--bui-bg-solid-disabled); --bg-hover: var(--bui-bg-solid-disabled); --bg-active: var(--bui-bg-solid-disabled); @@ -100,7 +100,7 @@ --fg: var(--fg-solid-danger); &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg: var(--bg-solid-danger-disabled); --bg-hover: var(--bg-solid-danger-disabled); --bg-active: var(--bg-solid-danger-disabled); @@ -137,7 +137,7 @@ } &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg-hover: var(--bg); --bg-active: var(--bg); --fg: var(--bui-fg-disabled); @@ -166,7 +166,7 @@ --fg: var(--bui-fg-danger); &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg-hover: var(--bg); --bg-active: var(--bg); --fg: var(--bui-fg-disabled); @@ -198,7 +198,7 @@ } &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg-hover: var(--bg); --bg-active: var(--bg); --fg: var(--bui-fg-disabled); @@ -226,7 +226,7 @@ --fg: var(--bui-fg-danger); &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg-hover: var(--bg); --bg-active: var(--bg); --fg: var(--bui-fg-disabled); @@ -268,7 +268,7 @@ width: 100%; transition: opacity var(--loading-duration) ease-out; - .bui-Button[data-loading='true'] & { + .bui-Button[data-ispending='true'] & { opacity: 0; } } @@ -282,7 +282,7 @@ opacity: 0; transition: opacity var(--loading-duration) ease-in; - .bui-Button[data-loading='true'] & { + .bui-Button[data-ispending='true'] & { opacity: 1; } diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index 5781daca6a..3eec3ce36d 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -107,7 +107,7 @@ export const Destructive = meta.story({ - @@ -124,7 +124,7 @@ export const Destructive = meta.story({ - @@ -141,7 +141,7 @@ export const Destructive = meta.story({ - @@ -254,94 +254,94 @@ export const Responsive = meta.story({ }, }); -export const Loading = meta.story({ +export const Pending = meta.story({ render: () => { - const [isLoading, setIsLoading] = useState(false); + const [isPending, setIsPending] = useState(false); const handleClick = () => { - setIsLoading(true); + setIsPending(true); setTimeout(() => { - setIsLoading(false); + setIsPending(false); }, 3000); }; return ( - ); }, }); -export const LoadingVariants = meta.story({ +export const PendingVariants = meta.story({ render: () => ( Primary - - - Secondary - - - Tertiary - - - Primary Destructive - - - Loading vs Disabled + Pending vs Disabled - - diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx index 069c8632e0..92675a4f50 100644 --- a/packages/ui/src/components/Button/Button.tsx +++ b/packages/ui/src/components/Button/Button.tsx @@ -43,7 +43,7 @@ import { ButtonDefinition } from './definition'; * variant="primary" * size="medium" * iconStart={} - * loading={isSubmitting} + * isPending={isSubmitting} * > * Submit * @@ -55,15 +55,23 @@ export const Button = forwardRef( (props: ButtonProps, ref: Ref) => { const { ownProps, restProps, dataAttributes } = useDefinition( ButtonDefinition, - props, + // Merge deprecated `loading` into `isPending` so data attributes and + // internal logic only need to check a single prop. + { + ...props, + isPending: + props.isPending || props.loading + ? true + : props.isPending ?? props.loading, + }, ); - const { classes, iconStart, iconEnd, loading, children } = ownProps; + const { classes, iconStart, iconEnd, isPending, children } = ownProps; return ( diff --git a/packages/ui/src/components/Button/definition.ts b/packages/ui/src/components/Button/definition.ts index 21d3c7017b..891e21360d 100644 --- a/packages/ui/src/components/Button/definition.ts +++ b/packages/ui/src/components/Button/definition.ts @@ -34,6 +34,7 @@ export const ButtonDefinition = defineComponent()({ size: { dataAttribute: true, default: 'small' }, variant: { dataAttribute: true, default: 'primary' }, destructive: { dataAttribute: true }, + isPending: { dataAttribute: true }, loading: { dataAttribute: true }, iconStart: {}, iconEnd: {}, diff --git a/packages/ui/src/components/Button/types.ts b/packages/ui/src/components/Button/types.ts index 2bf36d808e..4f7ed8c50b 100644 --- a/packages/ui/src/components/Button/types.ts +++ b/packages/ui/src/components/Button/types.ts @@ -25,6 +25,8 @@ export type ButtonOwnProps = { destructive?: boolean; iconStart?: ReactElement; iconEnd?: ReactElement; + isPending?: boolean; + /** @deprecated Use `isPending` instead. */ loading?: boolean; children?: ReactNode; className?: string; diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css index 8a345647c5..df4584cdea 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css @@ -54,7 +54,7 @@ cursor: not-allowed; } - &[data-loading='true'] { + &[data-ispending='true'] { cursor: wait; } } @@ -66,7 +66,7 @@ --fg: var(--bui-fg-solid); &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg: var(--bui-bg-solid-disabled); --bg-hover: var(--bui-bg-solid-disabled); --bg-active: var(--bui-bg-solid-disabled); @@ -104,7 +104,7 @@ } &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg-hover: var(--bg); --bg-active: var(--bg); --fg: var(--bui-fg-disabled); @@ -138,7 +138,7 @@ } &[data-disabled='true'], - &[data-loading='true'] { + &[data-ispending='true'] { --bg-hover: var(--bg); --bg-active: var(--bg); --fg: var(--bui-fg-disabled); @@ -179,7 +179,7 @@ width: 100%; transition: opacity var(--loading-duration) ease-out; - .bui-ButtonIcon[data-loading='true'] & { + .bui-ButtonIcon[data-ispending='true'] & { opacity: 0; } } @@ -193,7 +193,7 @@ opacity: 0; transition: opacity var(--loading-duration) ease-in; - .bui-ButtonIcon[data-loading='true'] & { + .bui-ButtonIcon[data-ispending='true'] & { opacity: 1; } diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx index be6dad7754..75d95fdc1f 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.stories.tsx @@ -82,14 +82,14 @@ export const Responsive = meta.story({ render: args => } />, }); -export const Loading = meta.story({ +export const Pending = meta.story({ render: () => { - const [isLoading, setIsLoading] = useState(false); + const [isPending, setIsPending] = useState(false); const handleClick = () => { - setIsLoading(true); + setIsPending(true); setTimeout(() => { - setIsLoading(false); + setIsPending(false); }, 3000); }; @@ -97,14 +97,14 @@ export const Loading = meta.story({ } - loading={isLoading} + isPending={isPending} onPress={handleClick} /> ); }, }); -export const LoadingVariants = meta.story({ +export const PendingVariants = meta.story({ render: () => ( Primary @@ -113,13 +113,13 @@ export const LoadingVariants = meta.story({ variant="primary" size="small" icon={} - loading + isPending /> } - loading + isPending /> @@ -129,13 +129,13 @@ export const LoadingVariants = meta.story({ variant="secondary" size="small" icon={} - loading + isPending /> } - loading + isPending /> @@ -145,24 +145,24 @@ export const LoadingVariants = meta.story({ variant="tertiary" size="small" icon={} - loading + isPending /> } - loading + isPending /> - Loading vs Disabled + Pending vs Disabled - } loading /> + } isPending /> } isDisabled /> } - loading + isPending isDisabled /> diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx index e5ac723f42..9aebc18d08 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.tsx @@ -30,15 +30,23 @@ export const ButtonIcon = forwardRef( (props: ButtonIconProps, ref: Ref) => { const { ownProps, restProps, dataAttributes } = useDefinition( ButtonIconDefinition, - props, + // Merge deprecated `loading` into `isPending` so data attributes and + // internal logic only need to check a single prop. + { + ...props, + isPending: + props.isPending || props.loading + ? true + : props.isPending ?? props.loading, + }, ); - const { classes, icon, loading } = ownProps; + const { classes, icon, isPending } = ownProps; return ( diff --git a/packages/ui/src/components/ButtonIcon/definition.ts b/packages/ui/src/components/ButtonIcon/definition.ts index 0027227094..6c065188d8 100644 --- a/packages/ui/src/components/ButtonIcon/definition.ts +++ b/packages/ui/src/components/ButtonIcon/definition.ts @@ -33,6 +33,7 @@ export const ButtonIconDefinition = defineComponent()({ propDefs: { size: { dataAttribute: true, default: 'small' }, variant: { dataAttribute: true, default: 'primary' }, + isPending: { dataAttribute: true }, loading: { dataAttribute: true }, icon: {}, className: {}, diff --git a/packages/ui/src/components/ButtonIcon/types.ts b/packages/ui/src/components/ButtonIcon/types.ts index f30f01ff99..69ebcc21ec 100644 --- a/packages/ui/src/components/ButtonIcon/types.ts +++ b/packages/ui/src/components/ButtonIcon/types.ts @@ -23,6 +23,8 @@ export type ButtonIconOwnProps = { size?: Responsive<'small' | 'medium'>; variant?: Responsive<'primary' | 'secondary' | 'tertiary'>; icon?: ReactElement; + isPending?: boolean; + /** @deprecated Use `isPending` instead. */ loading?: boolean; className?: string; }; diff --git a/packages/ui/src/components/Card/Card.tsx b/packages/ui/src/components/Card/Card.tsx index aecca09512..498fd5b021 100644 --- a/packages/ui/src/components/Card/Card.tsx +++ b/packages/ui/src/components/Card/Card.tsx @@ -41,7 +41,7 @@ const INTERACTIVE_ELEMENT_SELECTOR = * @public */ export const Card = forwardRef((props, ref) => { - const { ownProps, restProps, dataAttributes } = useDefinition( + const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition( CardDefinition, props, ); @@ -98,6 +98,7 @@ export const Card = forwardRef((props, ref) => { {...dataAttributes} {...restProps} onClick={isInteractive ? handleClick : undefined} + style={{ ...utilityStyle, ...ownProps.style }} > {href && ( ()({ target: {}, rel: {}, download: {}, + style: {}, }, + utilityProps: ['grow', 'shrink', 'basis'], }); /** diff --git a/packages/ui/src/components/Card/types.ts b/packages/ui/src/components/Card/types.ts index 5809384c16..83daf2de08 100644 --- a/packages/ui/src/components/Card/types.ts +++ b/packages/ui/src/components/Card/types.ts @@ -14,11 +14,16 @@ * limitations under the License. */ -import type { ReactNode } from 'react'; +import type { ReactNode, CSSProperties } from 'react'; import type { ButtonProps as RAButtonProps } from 'react-aria-components'; +import type { FlexItemProps } from '../../types'; /** @public */ -export type CardBaseProps = { children?: ReactNode; className?: string }; +export type CardBaseProps = { + children?: ReactNode; + className?: string; + style?: CSSProperties; +}; /** @public */ export type CardButtonVariant = { @@ -63,6 +68,7 @@ export type CardStaticVariant = { * @public */ export type CardProps = CardBaseProps & + FlexItemProps & Omit, 'onClick'> & (CardButtonVariant | CardLinkVariant | CardStaticVariant); @@ -81,6 +87,7 @@ export type CardOwnProps = Pick< | 'target' | 'rel' | 'download' + | 'style' >; /** @public */ diff --git a/packages/ui/src/components/Flex/Flex.stories.tsx b/packages/ui/src/components/Flex/Flex.stories.tsx index 91d4088004..b392b6e731 100644 --- a/packages/ui/src/components/Flex/Flex.stories.tsx +++ b/packages/ui/src/components/Flex/Flex.stories.tsx @@ -16,7 +16,9 @@ import preview from '../../../../../.storybook/preview'; import { Flex } from './Flex'; import { Text } from '../Text'; -import { Box } from '../Box'; +import { Box, BoxProps } from '../Box'; +import { Card, CardHeader, CardBody, CardFooter } from '../Card'; +import { Grid } from '../Grid'; const meta = preview.meta({ title: 'Backstage UI/Flex', @@ -41,10 +43,9 @@ const meta = preview.meta({ const DecorativeBox = ({ width = '48px', height = '48px', -}: { - width?: string; - height?: string; -}) => { + style, + ...props +}: Omit) => { const diagonalStripePattern = (() => { const svg = ` @@ -58,9 +59,11 @@ const DecorativeBox = ({ return ( , + Card: props => ( + + + Header + + + + This is the first paragraph of a long body text that + demonstrates how the Card component handles extensive content. + The card should adjust accordingly to display all the text + properly while maintaining its structure. + + + Here's a second paragraph that adds more content to our card + body. Having multiple paragraphs helps to visualize how spacing + works within the card component. + + + This third paragraph continues to add more text to ensure we + have a proper demonstration of a card with significant content. + This makes it easier to test scrolling behavior and overall + layout when content exceeds the initial view. + + + + Footer + + + ), + Grid: props => ( + + + + + + + + + + + + ), + Flex: props => ( + + + + + + ), + }, + }, + grow: { + control: 'radio', + options: [undefined, 0, 1, false, true], + }, + shrink: { + control: 'radio', + options: [undefined, 0, 1, false, true], + }, + basis: { + control: 'radio', + options: [undefined, '0%', '25%', '50%', '100%', 100, '250px', 'auto'], + }, + }, + render: ({ component: Component, ...args }) => { + return ( + +
+ + + +
+ + ); + }, +}); + export const ResponsiveGap = meta.story({ args: { gap: { xs: '4', md: '8', lg: '12' }, diff --git a/packages/ui/src/components/Flex/definition.ts b/packages/ui/src/components/Flex/definition.ts index 802464c4e1..1bc9c54217 100644 --- a/packages/ui/src/components/Flex/definition.ts +++ b/packages/ui/src/components/Flex/definition.ts @@ -53,5 +53,8 @@ export const FlexDefinition = defineComponent()({ 'align', 'justify', 'direction', + 'grow', + 'shrink', + 'basis', ], }); diff --git a/packages/ui/src/components/Flex/types.ts b/packages/ui/src/components/Flex/types.ts index 1c7f887e62..5b295e1825 100644 --- a/packages/ui/src/components/Flex/types.ts +++ b/packages/ui/src/components/Flex/types.ts @@ -15,7 +15,13 @@ */ import type { ReactNode, CSSProperties } from 'react'; -import type { Responsive, Space, SpaceProps, ProviderBg } from '../../types'; +import type { + Responsive, + Space, + SpaceProps, + ProviderBg, + FlexItemProps, +} from '../../types'; /** @public */ export type FlexOwnProps = { @@ -28,6 +34,7 @@ export type FlexOwnProps = { /** @public */ export interface FlexProps extends SpaceProps, + FlexItemProps, FlexOwnProps, Omit, 'children'> { gap?: Responsive; diff --git a/packages/ui/src/components/Grid/definition.ts b/packages/ui/src/components/Grid/definition.ts index 606b286b3c..2d83e9db6a 100644 --- a/packages/ui/src/components/Grid/definition.ts +++ b/packages/ui/src/components/Grid/definition.ts @@ -51,6 +51,9 @@ export const GridDefinition = defineComponent()({ 'pt', 'px', 'py', + 'grow', + 'shrink', + 'basis', ], }); diff --git a/packages/ui/src/components/Grid/types.ts b/packages/ui/src/components/Grid/types.ts index 6ad5d564c6..90699c59d2 100644 --- a/packages/ui/src/components/Grid/types.ts +++ b/packages/ui/src/components/Grid/types.ts @@ -21,6 +21,7 @@ import type { Responsive, Columns, ProviderBg, + FlexItemProps, } from '../../types'; /** @public */ @@ -34,6 +35,7 @@ export type GridOwnProps = { /** @public */ export interface GridProps extends SpaceProps, + FlexItemProps, GridOwnProps, Omit, 'children'> { columns?: Responsive; diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css index 953ccbed65..969d27b805 100644 --- a/packages/ui/src/components/Table/Table.module.css +++ b/packages/ui/src/components/Table/Table.module.css @@ -41,7 +41,7 @@ min-height: 0; &[data-stale='true'], - &[data-loading='true'] { + &[data-ispending='true'] { opacity: 0.6; } } diff --git a/packages/ui/src/components/Table/components/Table.tsx b/packages/ui/src/components/Table/components/Table.tsx index 450e40d281..24fd2a335e 100644 --- a/packages/ui/src/components/Table/components/Table.tsx +++ b/packages/ui/src/components/Table/components/Table.tsx @@ -107,6 +107,7 @@ function useLiveRegionLabel( export function Table({ columnConfig, data, + isPending = false, loading = false, isStale = false, error, @@ -119,6 +120,7 @@ export function Table({ style, virtualized, }: TableProps) { + const pending = isPending || loading; const { ownProps: { classes }, } = useDefinition(TableWrapperDefinition, { className }); @@ -137,7 +139,7 @@ export function Table({ onSelectionChange, } = selection || {}; - const isInitialLoading = loading && !data; + const isInitialLoading = pending && !data; if (error) { return ( @@ -202,7 +204,7 @@ export function Table({ onSortChange={sort?.onSortChange} disabledKeys={disabledRows} stale={isStale} - loading={isInitialLoading} + isPending={isInitialLoading} aria-describedby={liveRegionId} > diff --git a/packages/ui/src/components/Table/components/TableRoot.tsx b/packages/ui/src/components/Table/components/TableRoot.tsx index 5ea21046df..47c18b650b 100644 --- a/packages/ui/src/components/Table/components/TableRoot.tsx +++ b/packages/ui/src/components/Table/components/TableRoot.tsx @@ -28,14 +28,22 @@ import { TableRootProps } from '../types'; export const TableRoot = (props: TableRootProps) => { const { ownProps, restProps, dataAttributes } = useDefinition( TableDefinition, - props, + // Merge deprecated `loading` into `isPending` so data attributes and + // internal logic only need to check a single prop. + { + ...props, + isPending: + props.isPending || props.loading + ? true + : props.isPending ?? props.loading, + }, ); return ( diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts index e2236e21b7..95e8e8635b 100644 --- a/packages/ui/src/components/Table/definition.ts +++ b/packages/ui/src/components/Table/definition.ts @@ -52,6 +52,7 @@ export const TableDefinition = defineComponent()({ }, propDefs: { stale: { dataAttribute: true }, + isPending: { dataAttribute: true }, loading: { dataAttribute: true }, }, }); diff --git a/packages/ui/src/components/Table/hooks/types.ts b/packages/ui/src/components/Table/hooks/types.ts index 06c980efdf..01cc8ac02e 100644 --- a/packages/ui/src/components/Table/hooks/types.ts +++ b/packages/ui/src/components/Table/hooks/types.ts @@ -158,7 +158,7 @@ export interface UseTableResult { /** @internal */ export interface PaginationResult { data: T[] | undefined; - loading: boolean; + isPending: boolean; error: Error | undefined; totalCount: number | undefined; offset?: number; diff --git a/packages/ui/src/components/Table/hooks/useCompletePagination.ts b/packages/ui/src/components/Table/hooks/useCompletePagination.ts index f34e2c8c06..22711511d4 100644 --- a/packages/ui/src/components/Table/hooks/useCompletePagination.ts +++ b/packages/ui/src/components/Table/hooks/useCompletePagination.ts @@ -48,7 +48,7 @@ export function useCompletePagination( const { sort, filter, search } = query; const [items, setItems] = useState(undefined); - const [isLoading, setIsLoading] = useState(!data); + const [isPending, setIsPending] = useState(!data); const [error, setError] = useState(undefined); const [loadCount, setLoadCount] = useState(0); @@ -64,7 +64,7 @@ export function useCompletePagination( // Load data on mount and when loadCount changes (reload trigger) useEffect(() => { if (data) { - setIsLoading(false); + setIsPending(false); return; } @@ -73,7 +73,7 @@ export function useCompletePagination( } let cancelled = false; - setIsLoading(true); + setIsPending(true); setError(undefined); (async () => { @@ -82,12 +82,12 @@ export function useCompletePagination( const resolvedData = result instanceof Promise ? await result : result; if (!cancelled) { setItems(resolvedData); - setIsLoading(false); + setIsPending(false); } } catch (err) { if (!cancelled) { setError(err instanceof Error ? err : new Error(String(err))); - setIsLoading(false); + setIsPending(false); } } })(); @@ -164,7 +164,7 @@ export function useCompletePagination( return { data: paginatedData, - loading: isLoading, + isPending: isPending, error, totalCount, offset, diff --git a/packages/ui/src/components/Table/hooks/useCursorPagination.ts b/packages/ui/src/components/Table/hooks/useCursorPagination.ts index 5da56b3ce4..e7ce5b1eb5 100644 --- a/packages/ui/src/components/Table/hooks/useCursorPagination.ts +++ b/packages/ui/src/components/Table/hooks/useCursorPagination.ts @@ -78,7 +78,7 @@ export function useCursorPagination( return { data: cache.data, - loading: cache.loading, + isPending: cache.isPending, error: cache.error, totalCount: cache.totalCount, offset: undefined, diff --git a/packages/ui/src/components/Table/hooks/useOffsetPagination.ts b/packages/ui/src/components/Table/hooks/useOffsetPagination.ts index b34dbe8a6c..dd92f98167 100644 --- a/packages/ui/src/components/Table/hooks/useOffsetPagination.ts +++ b/packages/ui/src/components/Table/hooks/useOffsetPagination.ts @@ -91,7 +91,7 @@ export function useOffsetPagination( return { data: cache.data, - loading: cache.loading, + isPending: cache.isPending, error: cache.error, totalCount: cache.totalCount, offset: cache.currentCursor ?? 0, diff --git a/packages/ui/src/components/Table/hooks/usePageCache.ts b/packages/ui/src/components/Table/hooks/usePageCache.ts index 9bf864749b..7dc490c6c4 100644 --- a/packages/ui/src/components/Table/hooks/usePageCache.ts +++ b/packages/ui/src/components/Table/hooks/usePageCache.ts @@ -48,7 +48,7 @@ export interface UsePageCacheOptions { /** @internal */ export interface UsePageCacheResult { - loading: boolean; + isPending: boolean; error: Error | undefined; data: T[] | undefined; totalCount: number | undefined; @@ -149,7 +149,7 @@ export function usePageCache( const cacheStore = useRef(new PageCacheStore()).current; - const [loading, setLoading] = useState(true); + const [isPending, setIsPending] = useState(true); const [error, setError] = useState(undefined); const [totalCount, setTotalCount] = useState(undefined); @@ -189,7 +189,7 @@ export function usePageCache( const abortController = new AbortController(); abortControllerRef.current = abortController; - setLoading(true); + setIsPending(true); setError(undefined); try { @@ -215,14 +215,14 @@ export function usePageCache( setTotalCount(result.totalCount); } - setLoading(false); + setIsPending(false); } catch (err) { if (abortController.signal.aborted) { return; } setError(err instanceof Error ? err : new Error(String(err))); - setLoading(false); + setIsPending(false); } }, [getData, initialCurrentCursor, currentCursor, cacheStore], @@ -239,18 +239,18 @@ export function usePageCache( }, []); const onNextPage = useCallback(() => { - if (loading) return; + if (isPending) return; const page = cacheStore.get(currentCursor); if (!page?.nextCursor) return; goToPage('next'); - }, [loading, currentCursor, goToPage, cacheStore]); + }, [isPending, currentCursor, goToPage, cacheStore]); const onPreviousPage = useCallback(() => { - if (loading) return; + if (isPending) return; const page = cacheStore.get(currentCursor); if (!page?.prevCursor) return; goToPage('prev'); - }, [loading, currentCursor, goToPage, cacheStore]); + }, [isPending, currentCursor, goToPage, cacheStore]); const reload = useCallback( (reloadOptions?: { keepCurrentCursor?: boolean }) => { @@ -266,7 +266,7 @@ export function usePageCache( ); return { - loading, + isPending, error, data, totalCount, diff --git a/packages/ui/src/components/Table/hooks/useTable.ts b/packages/ui/src/components/Table/hooks/useTable.ts index 7d1019f541..492ac7a0c6 100644 --- a/packages/ui/src/components/Table/hooks/useTable.ts +++ b/packages/ui/src/components/Table/hooks/useTable.ts @@ -52,7 +52,7 @@ function useTableProps( } const displayData = paginationResult.data ?? previousDataRef.current; - const isStale = paginationResult.loading && displayData !== undefined; + const isStale = paginationResult.isPending && displayData !== undefined; const pagination = useMemo(() => { if (paginationOptions.type === 'none') { @@ -104,7 +104,8 @@ function useTableProps( return useMemo( () => ({ data: displayData, - loading: paginationResult.loading, + isPending: paginationResult.isPending, + loading: paginationResult.isPending, isStale, error: paginationResult.error, pagination, @@ -112,7 +113,7 @@ function useTableProps( }), [ displayData, - paginationResult.loading, + paginationResult.isPending, isStale, paginationResult.error, pagination, diff --git a/packages/ui/src/components/Table/stories/Table.visual.stories.tsx b/packages/ui/src/components/Table/stories/Table.visual.stories.tsx index 23810660f1..fd3bbe8218 100644 --- a/packages/ui/src/components/Table/stories/Table.visual.stories.tsx +++ b/packages/ui/src/components/Table/stories/Table.visual.stories.tsx @@ -274,7 +274,7 @@ export const LoadingState: Story = { ); diff --git a/packages/ui/src/components/Table/types.ts b/packages/ui/src/components/Table/types.ts index 800a56052a..20261bda59 100644 --- a/packages/ui/src/components/Table/types.ts +++ b/packages/ui/src/components/Table/types.ts @@ -42,6 +42,8 @@ export interface SortState { /** @public */ export type TableRootOwnProps = { stale?: boolean; + isPending?: boolean; + /** @deprecated Use `isPending` instead. */ loading?: boolean; }; @@ -263,6 +265,8 @@ export type VirtualizedProp = export interface TableProps { columnConfig: readonly ColumnConfig[]; data: T[] | undefined; + isPending?: boolean; + /** @deprecated Use `isPending` instead. */ loading?: boolean; isStale?: boolean; error?: Error; diff --git a/packages/ui/src/css/utilities/flex.css b/packages/ui/src/css/utilities/flex.css index 2248addfee..bf5bf392cd 100644 --- a/packages/ui/src/css/utilities/flex.css +++ b/packages/ui/src/css/utilities/flex.css @@ -67,6 +67,18 @@ flex-direction: column-reverse; } + .bui-grow { + flex-grow: var(--grow); + } + + .bui-shrink { + flex-shrink: var(--shrink); + } + + .bui-basis { + flex-basis: var(--basis); + } + /* Breakpoint xs */ @media (min-width: 640px) { .xs\:bui-align-start { @@ -116,6 +128,18 @@ .xs\:bui-fd-column-reverse { flex-direction: column-reverse; } + + .xs\:bui-grow { + flex-grow: var(--grow-xs); + } + + .xs\:bui-shrink { + flex-shrink: var(--shrink-xs); + } + + .xs\:bui-basis { + flex-basis: var(--basis-xs); + } } /* Breakpoint sm */ @@ -167,6 +191,18 @@ .sm\:bui-fd-column-reverse { flex-direction: column-reverse; } + + .sm\:bui-grow { + flex-grow: var(--grow-sm); + } + + .sm\:bui-shrink { + flex-shrink: var(--shrink-sm); + } + + .sm\:bui-basis { + flex-basis: var(--basis-sm); + } } /* Breakpoint md */ @@ -218,6 +254,18 @@ .md\:bui-fd-column-reverse { flex-direction: column-reverse; } + + .md\:bui-grow { + flex-grow: var(--grow-md); + } + + .md\:bui-shrink { + flex-shrink: var(--shrink-md); + } + + .md\:bui-basis { + flex-basis: var(--basis-md); + } } /* Breakpoint lg */ @@ -269,6 +317,18 @@ .lg\:bui-fd-column-reverse { flex-direction: column-reverse; } + + .lg\:bui-grow { + flex-grow: var(--grow-lg); + } + + .lg\:bui-shrink { + flex-shrink: var(--shrink-lg); + } + + .lg\:bui-basis { + flex-basis: var(--basis-lg); + } } /* Breakpoint xl */ @@ -320,5 +380,17 @@ .xl\:bui-fd-column-reverse { flex-direction: column-reverse; } + + .xl\:bui-grow { + flex-grow: var(--grow-xl); + } + + .xl\:bui-shrink { + flex-shrink: var(--shrink-xl); + } + + .xl\:bui-basis { + flex-basis: var(--basis-xl); + } } } diff --git a/packages/ui/src/hooks/useDefinition/helpers.ts b/packages/ui/src/hooks/useDefinition/helpers.ts index 8a990b4b6a..2169a26238 100644 --- a/packages/ui/src/hooks/useDefinition/helpers.ts +++ b/packages/ui/src/hooks/useDefinition/helpers.ts @@ -102,7 +102,7 @@ export function processUtilityProps( const handleUtilityValue = ( key: string, - val: unknown, + inputVal: unknown, prefix: string = '', ) => { // Get utility class configuration for this key @@ -113,6 +113,11 @@ export function processUtilityProps( return; } + const val = + 'transform' in utilityConfig + ? utilityConfig.transform(inputVal) + : inputVal; + // Check if value is in the list of valid values for this utility const values = utilityConfig.values as readonly (string | number)[]; if (values.length > 0 && values.includes(val as string | number)) { diff --git a/packages/ui/src/types.ts b/packages/ui/src/types.ts index 113eb06a5c..3a6633bc37 100644 --- a/packages/ui/src/types.ts +++ b/packages/ui/src/types.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import type { CSSProperties } from 'react'; + /** @public */ export type Breakpoint = 'initial' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'; @@ -120,6 +122,20 @@ export interface PaddingProps { /** @public */ export interface SpaceProps extends MarginProps, PaddingProps {} +/** + * Flex item properties. + * + * @public + */ +export interface FlexItemProps { + /** Controls the flex-grow property. Values of `true` or `false` are converted to `1` or `0` respectively. */ + grow?: Responsive; + /** Controls the flex-shrink property. Values of `true` or `false` are converted to `1` or `0` respectively. */ + shrink?: Responsive; + /** Controls the flex-basis property. */ + basis?: Responsive; +} + /** @public */ export type TextVariants = | 'title-large' diff --git a/packages/ui/src/utils/utilityClassMap.ts b/packages/ui/src/utils/utilityClassMap.ts index f70052d588..2b8106eef2 100644 --- a/packages/ui/src/utils/utilityClassMap.ts +++ b/packages/ui/src/utils/utilityClassMap.ts @@ -196,7 +196,30 @@ export const utilityClassMap = { class: 'bui-row-span', values: columnsValues, }, + grow: { + class: 'bui-grow', + cssVar: '--grow', + values: [], + transform: input => (typeof input === 'boolean' ? Number(input) : input), + }, + shrink: { + class: 'bui-shrink', + cssVar: '--shrink', + values: [], + transform: input => (typeof input === 'boolean' ? Number(input) : input), + }, + basis: { + class: 'bui-basis', + cssVar: '--basis', + values: [], + transform: input => (typeof input === 'number' ? `${input}px` : input), + }, } as const satisfies Record< string, - { class: string; cssVar?: string; values: readonly (string | number)[] } + { + class: string; + cssVar?: string; + values: readonly (string | number)[]; + transform?: (input: unknown) => unknown; + } >; diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx index 9843ebb5ee..ec144f4b51 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/ListTaskPage.test.tsx @@ -14,7 +14,6 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; import { renderInTestApp, TestApiProvider, @@ -46,20 +45,6 @@ describe('', () => { const mockPermissionApi = { authorize: jest.fn() }; it('should render the page', async () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'service', - metadata: { - name: 'test', - }, - spec: { - profile: { - displayName: 'BackUser', - }, - }, - }; - catalogApi.getEntityByRef.mockResolvedValue(entity); - scaffolderApiMock.listTasks.mockResolvedValue({ tasks: [], totalTasks: 0 }); const { getByText } = await renderInTestApp( @@ -87,19 +72,6 @@ describe('', () => { }); it('should render the task I am owner', async () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'User', - metadata: { - name: 'foo', - }, - spec: { - profile: { - displayName: 'BackUser', - }, - }, - }; - catalogApi.getEntityByRef.mockResolvedValue(entity); scaffolderApiMock.listTasks.mockResolvedValue({ tasks: [ { @@ -151,34 +123,10 @@ describe('', () => { expect(getByText('All tasks that have been started')).toBeInTheDocument(); expect(getByText('Tasks')).toBeInTheDocument(); expect(await findByText('One Template')).toBeInTheDocument(); - expect(await findByText('BackUser')).toBeInTheDocument(); + expect(await findByText('foo')).toBeInTheDocument(); }); it('should render all tasks', async () => { - const entity: Entity = { - apiVersion: 'v1', - kind: 'User', - metadata: { - name: 'foo', - }, - spec: { - profile: { - displayName: 'BackUser', - }, - }, - }; - catalogApi.getEntityByRef - .mockResolvedValue(entity) - .mockResolvedValue(entity) - .mockResolvedValue({ - ...entity, - spec: { - profile: { - displayName: 'OtherUser', - }, - }, - }); - scaffolderApiMock.listTasks .mockResolvedValue({ tasks: [ @@ -252,6 +200,6 @@ describe('', () => { offset: 0, }); expect(await findByText('One Template')).toBeInTheDocument(); - expect(await findByText('OtherUser')).toBeInTheDocument(); + expect(await findByText('boo')).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx b/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx index 2cc069af5d..5720bfd5b4 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.test.tsx @@ -14,48 +14,15 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; -import { - renderInTestApp, - TestApiProvider, - mockApis, -} from '@backstage/test-utils'; -import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; -import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; +import { entityRouteRef } from '@backstage/plugin-catalog-react'; import { OwnerEntityColumn } from './OwnerEntityColumn'; -import { identityApiRef } from '@backstage/core-plugin-api'; describe('', () => { - const catalogApi = catalogApiMock.mock(); - const identityApi = mockApis.identity(); - - it('should render the column with the user', async () => { - const props = { - entityRef: 'user:default/foo', - }; - - const entity: Entity = { - apiVersion: 'v1', - kind: 'User', - metadata: { - name: 'test', - }, - spec: { - profile: { - displayName: 'BackUser', - }, - }, - }; - catalogApi.getEntityByRef.mockResolvedValue(entity); - - const { getByText, getByRole } = await renderInTestApp( - - + it('should render a link to the user entity', async () => { + const { getByRole } = await renderInTestApp( + + , { mountedRoutes: { @@ -68,7 +35,20 @@ describe('', () => { 'href', '/catalog/default/user/foo', ); - const text = getByText('BackUser'); - expect(text).toBeDefined(); + }); + + it('should render Unknown when entityRef is missing', async () => { + const { getByText } = await renderInTestApp( + + + , + { + mountedRoutes: { + '/catalog/:namespace/:kind/:name': entityRouteRef, + }, + }, + ); + + expect(getByText('Unknown')).toBeInTheDocument(); }); }); diff --git a/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.tsx b/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.tsx index a6d53d2ea1..6c1ca0bde9 100644 --- a/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.tsx +++ b/plugins/scaffolder/src/components/ListTasksPage/columns/OwnerEntityColumn.tsx @@ -13,37 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useApi } from '@backstage/core-plugin-api'; - -import useAsync from 'react-use/esm/useAsync'; - -import { catalogApiRef, EntityRefLink } from '@backstage/plugin-catalog-react'; -import { parseEntityRef, UserEntity } from '@backstage/catalog-model'; +import { EntityRefLink } from '@backstage/plugin-catalog-react'; import Typography from '@material-ui/core/Typography'; export const OwnerEntityColumn = ({ entityRef }: { entityRef?: string }) => { - const catalogApi = useApi(catalogApiRef); - - const { value, loading, error } = useAsync( - () => catalogApi.getEntityByRef(entityRef || ''), - [catalogApi, entityRef], - ); - if (!entityRef) { - return Unknown; + return Unknown; } - if (loading || error) { - return null; - } - - return ( - - ); + return ; };