Merge branch 'master' into charlesdedreuille/act-355-header-improvements
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -119,20 +119,20 @@ export const WithActionsAndDescriptions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const LoadingStates = () => {
|
||||
export const PendingStates = () => {
|
||||
return (
|
||||
<Flex direction="column" gap="4">
|
||||
<Alert
|
||||
status="info"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Processing your request..."
|
||||
/>
|
||||
<Alert status="success" icon={true} loading title="Saving changes..." />
|
||||
<Alert status="success" icon={true} isPending title="Saving changes..." />
|
||||
<Alert
|
||||
status="info"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Processing your request"
|
||||
description="This may take a few moments. Please do not close this window."
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<LoadingStates />}
|
||||
code={loadingStatesSnippet}
|
||||
preview={<PendingStates />}
|
||||
code={pendingStatesSnippet}
|
||||
/>
|
||||
|
||||
### Without Icons
|
||||
|
||||
@@ -10,31 +10,45 @@ export const alertPropDefs: Record<string, PropDef> = {
|
||||
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',
|
||||
|
||||
@@ -97,13 +97,13 @@ export const withActionsAndDescriptionsSnippet = `<Alert
|
||||
}
|
||||
/>`;
|
||||
|
||||
export const loadingStatesSnippet = `<Flex direction="column" gap="4">
|
||||
<Alert status="info" icon={true} loading title="Processing your request..." />
|
||||
<Alert status="success" icon={true} loading title="Saving changes..." />
|
||||
export const pendingStatesSnippet = `<Flex direction="column" gap="4">
|
||||
<Alert status="info" icon={true} isPending title="Processing your request..." />
|
||||
<Alert status="success" icon={true} isPending title="Saving changes..." />
|
||||
<Alert
|
||||
status="info"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Processing your request"
|
||||
description="This may take a few moments. Please do not close this window."
|
||||
/>
|
||||
|
||||
@@ -56,12 +56,12 @@ export const Disabled = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Loading = () => {
|
||||
export const Pending = () => {
|
||||
return (
|
||||
<ButtonIcon
|
||||
icon={<RiCloudLine />}
|
||||
variant="primary"
|
||||
loading
|
||||
isPending
|
||||
aria-label="Cloud"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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={<Loading />}
|
||||
code={loadingSnippet}
|
||||
preview={<Pending />}
|
||||
code={isPendingSnippet}
|
||||
/>
|
||||
|
||||
<Theming definition={ButtonIconDefinition} />
|
||||
|
||||
@@ -42,11 +42,16 @@ export const buttonIconPropDefs: Record<string, PropDef> = {
|
||||
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'],
|
||||
|
||||
@@ -20,4 +20,4 @@ export const disabledSnippet = `<Flex direction="row" gap="2">
|
||||
<ButtonIcon isDisabled icon={<RiCloudLine />} variant="tertiary" aria-label="Cloud" />
|
||||
</Flex>`;
|
||||
|
||||
export const loadingSnippet = `<ButtonIcon icon={<RiCloudLine />} variant="primary" loading aria-label="Cloud" />`;
|
||||
export const isPendingSnippet = `<ButtonIcon icon={<RiCloudLine />} variant="primary" isPending aria-label="Cloud" />`;
|
||||
|
||||
@@ -75,9 +75,9 @@ export const Destructive = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Loading = () => {
|
||||
export const Pending = () => {
|
||||
return (
|
||||
<Button variant="primary" loading={true}>
|
||||
<Button variant="primary" isPending={true}>
|
||||
Load more items
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
<PageTitle
|
||||
title="Button"
|
||||
description="A button with primary, secondary, and tertiary variants, destructive styling, and loading state."
|
||||
description="A button with primary, secondary, and tertiary variants, destructive styling, and pending state."
|
||||
/>
|
||||
|
||||
<Snippet align="center" py={4} preview={<Variants />} 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={<Loading />}
|
||||
code={loadingSnippet}
|
||||
preview={<Pending />}
|
||||
code={isPendingSnippet}
|
||||
layout="side-by-side"
|
||||
/>
|
||||
|
||||
|
||||
@@ -48,11 +48,16 @@ export const buttonPropDefs: Record<string, PropDef> = {
|
||||
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'],
|
||||
|
||||
@@ -55,7 +55,7 @@ export const destructiveSnippet = `<Flex gap="4">
|
||||
</Button>
|
||||
</Flex>`;
|
||||
|
||||
export const loadingSnippet = `<Button variant="primary" loading={true}>
|
||||
export const isPendingSnippet = `<Button variant="primary" isPending={true}>
|
||||
Load more items
|
||||
</Button>`;
|
||||
|
||||
|
||||
@@ -202,11 +202,11 @@ Use `mode: 'cursor'` when your API uses cursor-based pagination (common with Gra
|
||||
|
||||
<CodeBlock code={tableCursorPaginationSnippet} />
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export const useTableReturnPropDefs: Record<string, PropDef> = {
|
||||
description: (
|
||||
<>
|
||||
Props to spread onto the <Chip>Table</Chip> component. Includes data,
|
||||
loading, error, pagination, and sort state.
|
||||
isPending, error, pagination, and sort state.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -207,10 +207,15 @@ export const tablePropDefs: Record<string, PropDef> = {
|
||||
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<string, PropDef> = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
loading: {
|
||||
isPending: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: (
|
||||
<>
|
||||
Whether the table is in a loading state (e.g., initial data fetch). Adds{' '}
|
||||
<Chip>aria-busy</Chip> attribute and <Chip>data-loading</Chip> data
|
||||
Whether the table is in a pending state (e.g., initial data fetch). Adds{' '}
|
||||
<Chip>aria-busy</Chip> attribute and <Chip>data-ispending</Chip> data
|
||||
attribute for styling.
|
||||
</>
|
||||
),
|
||||
},
|
||||
loading: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
description: 'Deprecated. Use `isPending` instead.',
|
||||
},
|
||||
};
|
||||
|
||||
export const columnPropDefs: Record<string, PropDef> = {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: 'Backstage in Amsterdam: Highlights from BackstageCon and KubeCon + CloudNativeCon Europe 2026'
|
||||
author: André Wanlin, Emma Indal & Ruxandra Stanciu, Spotify
|
||||
---
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
📸 _[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
|
||||
|
||||

|
||||
📸 _[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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
📸 _[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
|
||||
|
||||

|
||||
|
||||
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! 🌷
|
||||
|
||||

|
||||
📸 _[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!
|
||||
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 3.9 MiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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));
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<void>(resolve => {
|
||||
let timeoutHandle: NodeJS.Timeout | undefined = undefined;
|
||||
let remaining = duration.as('milliseconds');
|
||||
if (!Number.isFinite(remaining) || remaining <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const done = () => {
|
||||
await new Promise<void>(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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<React.HTMLAttributes<HTMLDivElement>, '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<React.HTMLAttributes<HTMLDivElement>, '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<CSSProperties['flexBasis']>;
|
||||
grow?: Responsive<number | boolean>;
|
||||
shrink?: Responsive<number | boolean>;
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export type FlexOwnProps = {
|
||||
children: ReactNode;
|
||||
@@ -1332,6 +1363,7 @@ export type FlexOwnProps = {
|
||||
// @public (undocumented)
|
||||
export interface FlexProps
|
||||
extends SpaceProps,
|
||||
FlexItemProps,
|
||||
FlexOwnProps,
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, '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<HTMLAttributes<HTMLDivElement>, '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<T extends TableItem> {
|
||||
// (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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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: () => (
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Info</Text>
|
||||
<Alert
|
||||
status="info"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Processing your request..."
|
||||
/>
|
||||
|
||||
<Text>Success</Text>
|
||||
<Alert status="success" icon={true} loading title="Saving changes..." />
|
||||
<Alert status="success" icon={true} isPending title="Saving changes..." />
|
||||
|
||||
<Text>Warning</Text>
|
||||
<Alert
|
||||
status="warning"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Checking for issues..."
|
||||
/>
|
||||
|
||||
@@ -234,27 +234,27 @@ export const LoadingVariants = meta.story({
|
||||
<Alert
|
||||
status="danger"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Attempting recovery..."
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
});
|
||||
|
||||
export const LoadingWithDescription = meta.story({
|
||||
export const PendingWithDescription = meta.story({
|
||||
render: () => (
|
||||
<Flex direction="column" gap="4">
|
||||
<Alert
|
||||
status="info"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Processing your request"
|
||||
description="This may take a few moments. Please do not close this window."
|
||||
/>
|
||||
<Alert
|
||||
status="success"
|
||||
icon={true}
|
||||
loading
|
||||
isPending
|
||||
title="Deployment in Progress"
|
||||
description="Your application is being deployed to production. You'll receive a notification when complete."
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
* <Alert
|
||||
* status="success"
|
||||
* icon={true}
|
||||
* title="Operation completed"
|
||||
* description="Your changes have been saved successfully."
|
||||
* loading={isProcessing}
|
||||
* isPending={isProcessing}
|
||||
* customActions={
|
||||
* <>
|
||||
* <Button size="small" variant="tertiary">Dismiss</Button>
|
||||
@@ -76,13 +76,21 @@ export const Alert = forwardRef(
|
||||
(props: AlertProps, ref: Ref<HTMLDivElement>) => {
|
||||
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'}
|
||||
>
|
||||
<div className={classes.contentWrapper}>
|
||||
{loading ? (
|
||||
{isPending ? (
|
||||
<div className={classes.icon}>
|
||||
<ProgressBar
|
||||
aria-label="Loading"
|
||||
|
||||
@@ -36,6 +36,7 @@ export const AlertDefinition = defineComponent<AlertOwnProps>()({
|
||||
},
|
||||
propDefs: {
|
||||
status: { dataAttribute: true, default: 'info' },
|
||||
isPending: { dataAttribute: true },
|
||||
loading: { dataAttribute: true },
|
||||
icon: {},
|
||||
customActions: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,7 +36,7 @@ export const Box = forwardRef<HTMLDivElement, BoxProps>((props, ref) => {
|
||||
{
|
||||
ref,
|
||||
className: classes.root,
|
||||
style: { ...ownProps.style, ...utilityStyle },
|
||||
style: { ...utilityStyle, ...ownProps.style },
|
||||
...dataAttributes,
|
||||
...restProps,
|
||||
},
|
||||
|
||||
@@ -52,6 +52,9 @@ export const BoxDefinition = defineComponent<BoxOwnProps>()({
|
||||
'py',
|
||||
'position',
|
||||
'display',
|
||||
'grow',
|
||||
'shrink',
|
||||
'basis',
|
||||
'width',
|
||||
'minWidth',
|
||||
'maxWidth',
|
||||
|
||||
@@ -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<React.HTMLAttributes<HTMLDivElement>, 'children'> {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export const Destructive = meta.story({
|
||||
<Button variant="primary" destructive isDisabled>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button variant="primary" destructive loading>
|
||||
<Button variant="primary" destructive isPending>
|
||||
Loading
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -124,7 +124,7 @@ export const Destructive = meta.story({
|
||||
<Button variant="secondary" destructive isDisabled>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button variant="secondary" destructive loading>
|
||||
<Button variant="secondary" destructive isPending>
|
||||
Loading
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -141,7 +141,7 @@ export const Destructive = meta.story({
|
||||
<Button variant="tertiary" destructive isDisabled>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button variant="tertiary" destructive loading>
|
||||
<Button variant="tertiary" destructive isPending>
|
||||
Loading
|
||||
</Button>
|
||||
</Flex>
|
||||
@@ -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 (
|
||||
<Button variant="primary" loading={isLoading} onPress={handleClick}>
|
||||
<Button variant="primary" isPending={isPending} onPress={handleClick}>
|
||||
Load more items
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const LoadingVariants = meta.story({
|
||||
export const PendingVariants = meta.story({
|
||||
render: () => (
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Primary</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Button variant="primary" size="small" loading>
|
||||
<Button variant="primary" size="small" isPending>
|
||||
Small Loading
|
||||
</Button>
|
||||
<Button variant="primary" size="medium" loading>
|
||||
<Button variant="primary" size="medium" isPending>
|
||||
Medium Loading
|
||||
</Button>
|
||||
<Button variant="primary" loading iconStart={<RiCloudLine />}>
|
||||
<Button variant="primary" isPending iconStart={<RiCloudLine />}>
|
||||
With Icon
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Text>Secondary</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Button variant="secondary" size="small" loading>
|
||||
<Button variant="secondary" size="small" isPending>
|
||||
Small Loading
|
||||
</Button>
|
||||
<Button variant="secondary" size="medium" loading>
|
||||
<Button variant="secondary" size="medium" isPending>
|
||||
Medium Loading
|
||||
</Button>
|
||||
<Button variant="secondary" loading iconStart={<RiCloudLine />}>
|
||||
<Button variant="secondary" isPending iconStart={<RiCloudLine />}>
|
||||
With Icon
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Text>Tertiary</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Button variant="tertiary" size="small" loading>
|
||||
<Button variant="tertiary" size="small" isPending>
|
||||
Small Loading
|
||||
</Button>
|
||||
<Button variant="tertiary" size="medium" loading>
|
||||
<Button variant="tertiary" size="medium" isPending>
|
||||
Medium Loading
|
||||
</Button>
|
||||
<Button variant="tertiary" loading iconStart={<RiCloudLine />}>
|
||||
<Button variant="tertiary" isPending iconStart={<RiCloudLine />}>
|
||||
With Icon
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Text>Primary Destructive</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Button variant="primary" destructive size="small" loading>
|
||||
<Button variant="primary" destructive size="small" isPending>
|
||||
Small Loading
|
||||
</Button>
|
||||
<Button variant="primary" destructive size="medium" loading>
|
||||
<Button variant="primary" destructive size="medium" isPending>
|
||||
Medium Loading
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
destructive
|
||||
loading
|
||||
isPending
|
||||
iconStart={<RiCloudLine />}
|
||||
>
|
||||
With Icon
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Text>Loading vs Disabled</Text>
|
||||
<Text>Pending vs Disabled</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<Button variant="primary" loading>
|
||||
<Button variant="primary" isPending>
|
||||
Loading
|
||||
</Button>
|
||||
<Button variant="primary" isDisabled>
|
||||
Disabled
|
||||
</Button>
|
||||
<Button variant="primary" loading isDisabled>
|
||||
<Button variant="primary" isPending isDisabled>
|
||||
Both (Disabled Wins)
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
@@ -43,7 +43,7 @@ import { ButtonDefinition } from './definition';
|
||||
* variant="primary"
|
||||
* size="medium"
|
||||
* iconStart={<IconComponent />}
|
||||
* loading={isSubmitting}
|
||||
* isPending={isSubmitting}
|
||||
* >
|
||||
* Submit
|
||||
* </Button>
|
||||
@@ -55,15 +55,23 @@ export const Button = forwardRef(
|
||||
(props: ButtonProps, ref: Ref<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<RAButton
|
||||
className={classes.root}
|
||||
ref={ref}
|
||||
isPending={loading}
|
||||
isPending={isPending}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
>
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ButtonDefinition = defineComponent<ButtonOwnProps>()({
|
||||
size: { dataAttribute: true, default: 'small' },
|
||||
variant: { dataAttribute: true, default: 'primary' },
|
||||
destructive: { dataAttribute: true },
|
||||
isPending: { dataAttribute: true },
|
||||
loading: { dataAttribute: true },
|
||||
iconStart: {},
|
||||
iconEnd: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -82,14 +82,14 @@ export const Responsive = meta.story({
|
||||
render: args => <ButtonIcon {...args} icon={<RiCloudLine />} />,
|
||||
});
|
||||
|
||||
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({
|
||||
<ButtonIcon
|
||||
variant="primary"
|
||||
icon={<RiCloudLine />}
|
||||
loading={isLoading}
|
||||
isPending={isPending}
|
||||
onPress={handleClick}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const LoadingVariants = meta.story({
|
||||
export const PendingVariants = meta.story({
|
||||
render: () => (
|
||||
<Flex direction="column" gap="4">
|
||||
<Text>Primary</Text>
|
||||
@@ -113,13 +113,13 @@ export const LoadingVariants = meta.story({
|
||||
variant="primary"
|
||||
size="small"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
/>
|
||||
<ButtonIcon
|
||||
variant="primary"
|
||||
size="medium"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -129,13 +129,13 @@ export const LoadingVariants = meta.story({
|
||||
variant="secondary"
|
||||
size="small"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
/>
|
||||
<ButtonIcon
|
||||
variant="secondary"
|
||||
size="medium"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -145,24 +145,24 @@ export const LoadingVariants = meta.story({
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
/>
|
||||
<ButtonIcon
|
||||
variant="tertiary"
|
||||
size="medium"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Text>Loading vs Disabled</Text>
|
||||
<Text>Pending vs Disabled</Text>
|
||||
<Flex align="center" gap="4">
|
||||
<ButtonIcon variant="primary" icon={<RiCloudLine />} loading />
|
||||
<ButtonIcon variant="primary" icon={<RiCloudLine />} isPending />
|
||||
<ButtonIcon variant="primary" icon={<RiCloudLine />} isDisabled />
|
||||
<ButtonIcon
|
||||
variant="primary"
|
||||
icon={<RiCloudLine />}
|
||||
loading
|
||||
isPending
|
||||
isDisabled
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -30,15 +30,23 @@ export const ButtonIcon = forwardRef(
|
||||
(props: ButtonIconProps, ref: Ref<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<RAButton
|
||||
className={classes.root}
|
||||
ref={ref}
|
||||
isPending={loading}
|
||||
isPending={isPending}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
>
|
||||
|
||||
@@ -33,6 +33,7 @@ export const ButtonIconDefinition = defineComponent<ButtonIconOwnProps>()({
|
||||
propDefs: {
|
||||
size: { dataAttribute: true, default: 'small' },
|
||||
variant: { dataAttribute: true, default: 'primary' },
|
||||
isPending: { dataAttribute: true },
|
||||
loading: { dataAttribute: true },
|
||||
icon: {},
|
||||
className: {},
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -41,7 +41,7 @@ const INTERACTIVE_ELEMENT_SELECTOR =
|
||||
* @public
|
||||
*/
|
||||
export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
|
||||
const { ownProps, restProps, dataAttributes } = useDefinition(
|
||||
const { ownProps, restProps, dataAttributes, utilityStyle } = useDefinition(
|
||||
CardDefinition,
|
||||
props,
|
||||
);
|
||||
@@ -98,6 +98,7 @@ export const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => {
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
onClick={isInteractive ? handleClick : undefined}
|
||||
style={{ ...utilityStyle, ...ownProps.style }}
|
||||
>
|
||||
{href && (
|
||||
<Link
|
||||
|
||||
@@ -42,7 +42,9 @@ export const CardDefinition = defineComponent<CardOwnProps>()({
|
||||
target: {},
|
||||
rel: {},
|
||||
download: {},
|
||||
style: {},
|
||||
},
|
||||
utilityProps: ['grow', 'shrink', 'basis'],
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<React.HTMLAttributes<HTMLDivElement>, 'onClick'> &
|
||||
(CardButtonVariant | CardLinkVariant | CardStaticVariant);
|
||||
|
||||
@@ -81,6 +87,7 @@ export type CardOwnProps = Pick<
|
||||
| 'target'
|
||||
| 'rel'
|
||||
| 'download'
|
||||
| 'style'
|
||||
>;
|
||||
|
||||
/** @public */
|
||||
|
||||
@@ -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<BoxProps, 'children'>) => {
|
||||
const diagonalStripePattern = (() => {
|
||||
const svg = `
|
||||
<svg width="6" height="6" viewBox="0 0 6 6" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -58,9 +59,11 @@ const DecorativeBox = ({
|
||||
|
||||
return (
|
||||
<Box
|
||||
{...props}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
...style,
|
||||
background: '#eaf2fd',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #2563eb',
|
||||
@@ -199,6 +202,122 @@ export const ResponsiveAlign = meta.story({
|
||||
),
|
||||
});
|
||||
|
||||
export const FlexItems = meta.story({
|
||||
args: {
|
||||
component: 'Box',
|
||||
grow: 1,
|
||||
shrink: 0,
|
||||
basis: 'auto',
|
||||
},
|
||||
argTypes: {
|
||||
component: {
|
||||
control: { type: 'select' },
|
||||
options: ['Box', 'Card', 'Grid', 'Flex'],
|
||||
mapping: {
|
||||
Box: props => <DecorativeBox height="100%" width="256px" {...props} />,
|
||||
Card: props => (
|
||||
<Card style={{ height: '100%', width: '256px' }} {...props}>
|
||||
<CardHeader>
|
||||
<Text>Header</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
<Text>
|
||||
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.
|
||||
</Text>
|
||||
</CardBody>
|
||||
<CardFooter>
|
||||
<Text>Footer</Text>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
),
|
||||
Grid: props => (
|
||||
<Grid.Root
|
||||
{...props}
|
||||
height="128px"
|
||||
style={{ width: '256px' }}
|
||||
columns="3"
|
||||
>
|
||||
<Grid.Item colSpan="1" rowSpan="2">
|
||||
<DecorativeBox height="100%" width="100%" />
|
||||
</Grid.Item>
|
||||
<Grid.Item colSpan="2">
|
||||
<DecorativeBox height="100%" width="100%" />
|
||||
</Grid.Item>
|
||||
<Grid.Item colSpan="2">
|
||||
<DecorativeBox height="100%" width="100%" />
|
||||
</Grid.Item>
|
||||
</Grid.Root>
|
||||
),
|
||||
Flex: props => (
|
||||
<Flex
|
||||
{...props}
|
||||
height="128px"
|
||||
style={{ width: '256px' }}
|
||||
justify="between"
|
||||
>
|
||||
<DecorativeBox height="100%" />
|
||||
<DecorativeBox height="100%" />
|
||||
<DecorativeBox height="100%" />
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
},
|
||||
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 (
|
||||
<Flex style={{ width: '100%', height: '256px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '256px',
|
||||
flex: '1 1 auto',
|
||||
background:
|
||||
'repeating-linear-gradient(-45deg, transparent 0px, transparent 5px, #e8e8e8 5px, #e8e8e8 10px)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Component {...args} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
width: '256px',
|
||||
flex: '1 1 auto',
|
||||
background:
|
||||
'repeating-linear-gradient(-45deg, transparent 0px, transparent 5px, #e8e8e8 5px, #e8e8e8 10px)',
|
||||
borderRadius: '12px',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const ResponsiveGap = meta.story({
|
||||
args: {
|
||||
gap: { xs: '4', md: '8', lg: '12' },
|
||||
|
||||
@@ -53,5 +53,8 @@ export const FlexDefinition = defineComponent<FlexOwnProps>()({
|
||||
'align',
|
||||
'justify',
|
||||
'direction',
|
||||
'grow',
|
||||
'shrink',
|
||||
'basis',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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<React.HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
gap?: Responsive<Space>;
|
||||
|
||||
@@ -51,6 +51,9 @@ export const GridDefinition = defineComponent<GridOwnProps>()({
|
||||
'pt',
|
||||
'px',
|
||||
'py',
|
||||
'grow',
|
||||
'shrink',
|
||||
'basis',
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
columns?: Responsive<Columns>;
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
min-height: 0;
|
||||
|
||||
&[data-stale='true'],
|
||||
&[data-loading='true'] {
|
||||
&[data-ispending='true'] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ function useLiveRegionLabel(
|
||||
export function Table<T extends TableItem>({
|
||||
columnConfig,
|
||||
data,
|
||||
isPending = false,
|
||||
loading = false,
|
||||
isStale = false,
|
||||
error,
|
||||
@@ -119,6 +120,7 @@ export function Table<T extends TableItem>({
|
||||
style,
|
||||
virtualized,
|
||||
}: TableProps<T>) {
|
||||
const pending = isPending || loading;
|
||||
const {
|
||||
ownProps: { classes },
|
||||
} = useDefinition(TableWrapperDefinition, { className });
|
||||
@@ -137,7 +139,7 @@ export function Table<T extends TableItem>({
|
||||
onSelectionChange,
|
||||
} = selection || {};
|
||||
|
||||
const isInitialLoading = loading && !data;
|
||||
const isInitialLoading = pending && !data;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -202,7 +204,7 @@ export function Table<T extends TableItem>({
|
||||
onSortChange={sort?.onSortChange}
|
||||
disabledKeys={disabledRows}
|
||||
stale={isStale}
|
||||
loading={isInitialLoading}
|
||||
isPending={isInitialLoading}
|
||||
aria-describedby={liveRegionId}
|
||||
>
|
||||
<TableHeader columns={visibleColumns}>
|
||||
|
||||
@@ -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 (
|
||||
<ReactAriaTable
|
||||
className={ownProps.classes.root}
|
||||
aria-label="Data table"
|
||||
aria-busy={ownProps.stale || ownProps.loading}
|
||||
aria-busy={ownProps.stale || ownProps.isPending}
|
||||
{...dataAttributes}
|
||||
{...restProps}
|
||||
/>
|
||||
|
||||
@@ -52,6 +52,7 @@ export const TableDefinition = defineComponent<TableRootOwnProps>()({
|
||||
},
|
||||
propDefs: {
|
||||
stale: { dataAttribute: true },
|
||||
isPending: { dataAttribute: true },
|
||||
loading: { dataAttribute: true },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -158,7 +158,7 @@ export interface UseTableResult<T extends TableItem, TFilter = unknown> {
|
||||
/** @internal */
|
||||
export interface PaginationResult<T> {
|
||||
data: T[] | undefined;
|
||||
loading: boolean;
|
||||
isPending: boolean;
|
||||
error: Error | undefined;
|
||||
totalCount: number | undefined;
|
||||
offset?: number;
|
||||
|
||||
@@ -48,7 +48,7 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
const { sort, filter, search } = query;
|
||||
|
||||
const [items, setItems] = useState<T[] | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!data);
|
||||
const [isPending, setIsPending] = useState(!data);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const [loadCount, setLoadCount] = useState(0);
|
||||
|
||||
@@ -64,7 +64,7 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
// 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<T extends TableItem, TFilter>(
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setIsPending(true);
|
||||
setError(undefined);
|
||||
|
||||
(async () => {
|
||||
@@ -82,12 +82,12 @@ export function useCompletePagination<T extends TableItem, TFilter>(
|
||||
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<T extends TableItem, TFilter>(
|
||||
|
||||
return {
|
||||
data: paginatedData,
|
||||
loading: isLoading,
|
||||
isPending: isPending,
|
||||
error,
|
||||
totalCount,
|
||||
offset,
|
||||
|
||||
@@ -78,7 +78,7 @@ export function useCursorPagination<T extends TableItem, TFilter>(
|
||||
|
||||
return {
|
||||
data: cache.data,
|
||||
loading: cache.loading,
|
||||
isPending: cache.isPending,
|
||||
error: cache.error,
|
||||
totalCount: cache.totalCount,
|
||||
offset: undefined,
|
||||
|
||||
@@ -91,7 +91,7 @@ export function useOffsetPagination<T extends TableItem, TFilter>(
|
||||
|
||||
return {
|
||||
data: cache.data,
|
||||
loading: cache.loading,
|
||||
isPending: cache.isPending,
|
||||
error: cache.error,
|
||||
totalCount: cache.totalCount,
|
||||
offset: cache.currentCursor ?? 0,
|
||||
|
||||
@@ -48,7 +48,7 @@ export interface UsePageCacheOptions<T, TCursor extends CursorType = string> {
|
||||
|
||||
/** @internal */
|
||||
export interface UsePageCacheResult<T, TCursor extends CursorType = string> {
|
||||
loading: boolean;
|
||||
isPending: boolean;
|
||||
error: Error | undefined;
|
||||
data: T[] | undefined;
|
||||
totalCount: number | undefined;
|
||||
@@ -149,7 +149,7 @@ export function usePageCache<T, TCursor extends CursorType = string>(
|
||||
|
||||
const cacheStore = useRef(new PageCacheStore<T, TCursor>()).current;
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isPending, setIsPending] = useState(true);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const [totalCount, setTotalCount] = useState<number | undefined>(undefined);
|
||||
|
||||
@@ -189,7 +189,7 @@ export function usePageCache<T, TCursor extends CursorType = string>(
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
setLoading(true);
|
||||
setIsPending(true);
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
@@ -215,14 +215,14 @@ export function usePageCache<T, TCursor extends CursorType = string>(
|
||||
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<T, TCursor extends CursorType = string>(
|
||||
}, []);
|
||||
|
||||
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<T, TCursor extends CursorType = string>(
|
||||
);
|
||||
|
||||
return {
|
||||
loading,
|
||||
isPending,
|
||||
error,
|
||||
data,
|
||||
totalCount,
|
||||
|
||||
@@ -52,7 +52,7 @@ function useTableProps<T extends TableItem>(
|
||||
}
|
||||
|
||||
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<T extends TableItem>(
|
||||
return useMemo(
|
||||
() => ({
|
||||
data: displayData,
|
||||
loading: paginationResult.loading,
|
||||
isPending: paginationResult.isPending,
|
||||
loading: paginationResult.isPending,
|
||||
isStale,
|
||||
error: paginationResult.error,
|
||||
pagination,
|
||||
@@ -112,7 +113,7 @@ function useTableProps<T extends TableItem>(
|
||||
}),
|
||||
[
|
||||
displayData,
|
||||
paginationResult.loading,
|
||||
paginationResult.isPending,
|
||||
isStale,
|
||||
paginationResult.error,
|
||||
pagination,
|
||||
|
||||
@@ -274,7 +274,7 @@ export const LoadingState: Story = {
|
||||
<Table
|
||||
columnConfig={columns}
|
||||
data={undefined}
|
||||
loading={true}
|
||||
isPending
|
||||
pagination={{ type: 'none' }}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<T extends TableItem> {
|
||||
columnConfig: readonly ColumnConfig<T>[];
|
||||
data: T[] | undefined;
|
||||
isPending?: boolean;
|
||||
/** @deprecated Use `isPending` instead. */
|
||||
loading?: boolean;
|
||||
isStale?: boolean;
|
||||
error?: Error;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function processUtilityProps<Keys extends string>(
|
||||
|
||||
const handleUtilityValue = (
|
||||
key: string,
|
||||
val: unknown,
|
||||
inputVal: unknown,
|
||||
prefix: string = '',
|
||||
) => {
|
||||
// Get utility class configuration for this key
|
||||
@@ -113,6 +113,11 @@ export function processUtilityProps<Keys extends string>(
|
||||
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)) {
|
||||
|
||||
@@ -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<number | boolean>;
|
||||
/** Controls the flex-shrink property. Values of `true` or `false` are converted to `1` or `0` respectively. */
|
||||
shrink?: Responsive<number | boolean>;
|
||||
/** Controls the flex-basis property. */
|
||||
basis?: Responsive<CSSProperties['flexBasis']>;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type TextVariants =
|
||||
| 'title-large'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
renderInTestApp,
|
||||
TestApiProvider,
|
||||
@@ -46,20 +45,6 @@ describe('<ListTasksPage />', () => {
|
||||
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('<ListTasksPage />', () => {
|
||||
});
|
||||
|
||||
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('<ListTasksPage />', () => {
|
||||
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('<ListTasksPage />', () => {
|
||||
offset: 0,
|
||||
});
|
||||
expect(await findByText('One Template')).toBeInTheDocument();
|
||||
expect(await findByText('OtherUser')).toBeInTheDocument();
|
||||
expect(await findByText('boo')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('<OwnerEntityColumn />', () => {
|
||||
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(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[catalogApiRef, catalogApi],
|
||||
[identityApiRef, identityApi],
|
||||
]}
|
||||
>
|
||||
<OwnerEntityColumn {...props} />
|
||||
it('should render a link to the user entity', async () => {
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<TestApiProvider apis={[]}>
|
||||
<OwnerEntityColumn entityRef="user:default/foo" />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
@@ -68,7 +35,20 @@ describe('<OwnerEntityColumn />', () => {
|
||||
'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(
|
||||
<TestApiProvider apis={[]}>
|
||||
<OwnerEntityColumn />
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/catalog/:namespace/:kind/:name': entityRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(getByText('Unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 <Typography paragraph>Unknown</Typography>;
|
||||
return <Typography>Unknown</Typography>;
|
||||
}
|
||||
|
||||
if (loading || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityRefLink
|
||||
entityRef={parseEntityRef(entityRef)}
|
||||
title={
|
||||
(value as UserEntity)?.spec?.profile?.displayName ??
|
||||
value?.metadata.name
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <EntityRefLink entityRef={entityRef} />;
|
||||
};
|
||||
|
||||