Add tests and deprecation warnings
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
a47f27c0a9
commit
eea95b8ae2
@@ -0,0 +1,56 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/core-plugin-api': patch
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Deprecated `AlertApi` in favor of the new `ToastApi`.
|
||||
|
||||
`AlertApi` is now deprecated and will be removed in a future release. Please migrate to `ToastApi` which provides richer notification features.
|
||||
|
||||
**Why migrate?**
|
||||
|
||||
`ToastApi` offers enhanced capabilities over `AlertApi`:
|
||||
|
||||
- **Title and Description**: Display a prominent title with optional description text
|
||||
- **Action Links**: Include clickable links within notifications
|
||||
- **Custom Icons**: Override default icons or disable them entirely
|
||||
- **Per-toast Timeout**: Control auto-dismiss timing for each notification individually
|
||||
- **Programmatic Dismiss**: Close notifications via the key returned from `post()`
|
||||
|
||||
**Migration Guide**
|
||||
|
||||
| AlertApi | ToastApi |
|
||||
| -------------------------------------------- | ------------------------------------------- |
|
||||
| `message: string` | `title: ReactNode` |
|
||||
| `severity: 'error'` | `status: 'danger'` |
|
||||
| `severity: 'success' \| 'info' \| 'warning'` | `status: 'success' \| 'info' \| 'warning'` |
|
||||
| `display: 'transient'` | `timeout: 5000` (or custom ms) |
|
||||
| `display: 'permanent'` | omit `timeout` |
|
||||
| `post()` returns `void` | `post()` returns `string` (key for dismiss) |
|
||||
|
||||
**Example Migration**
|
||||
|
||||
```typescript
|
||||
// Before (AlertApi)
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
|
||||
const alertApi = useApi(alertApiRef);
|
||||
alertApi.post({
|
||||
message: 'Entity saved successfully',
|
||||
severity: 'success',
|
||||
display: 'transient',
|
||||
});
|
||||
|
||||
// After (ToastApi)
|
||||
import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const toastApi = useApi(toastApiRef);
|
||||
toastApi.post({
|
||||
title: 'Entity saved successfully',
|
||||
status: 'success',
|
||||
timeout: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
**Note**: During the migration period, both APIs work simultaneously. The `ToastDisplay` component subscribes to both `AlertApi` and `ToastApi`, so existing code continues to work while you migrate incrementally.
|
||||
@@ -70,7 +70,7 @@ import { StorageValueSnapshot } from '@backstage/core-plugin-api';
|
||||
import { SubRouteRef } from '@backstage/core-plugin-api';
|
||||
import { vmwareCloudAuthApiRef } from '@backstage/core-plugin-api';
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export class AlertApiForwarder implements AlertApi {
|
||||
// (undocumented)
|
||||
alert$(): Observable<AlertMessage>;
|
||||
|
||||
@@ -26,13 +26,25 @@ import ObservableImpl from 'zen-observable';
|
||||
* missing alerts that were posted before subscription.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use ToastApi instead. AlertApi will be removed in a future release.
|
||||
*/
|
||||
export class AlertApiForwarder implements AlertApi {
|
||||
private readonly subject = new PublishSubject<AlertMessage>();
|
||||
private readonly recentAlerts: AlertMessage[] = [];
|
||||
private readonly maxBufferSize = 10;
|
||||
private hasWarnedDeprecation = false;
|
||||
|
||||
post(alert: AlertMessage) {
|
||||
if (!this.hasWarnedDeprecation) {
|
||||
this.hasWarnedDeprecation = true;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
'AlertApi is deprecated and will be removed in a future release. ' +
|
||||
'Please migrate to ToastApi from @backstage/frontend-plugin-api. ' +
|
||||
'ToastApi provides richer features including title/description, links, icons, and per-toast timeouts. ' +
|
||||
'Example: toastApi.post({ title: "Saved!", status: "success", timeout: 5000 })',
|
||||
);
|
||||
}
|
||||
this.recentAlerts.push(alert);
|
||||
if (this.recentAlerts.length > this.maxBufferSize) {
|
||||
this.recentAlerts.shift();
|
||||
|
||||
@@ -14,6 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @deprecated AlertApi is deprecated. Use ToastApi from `@backstage/frontend-plugin-api` instead.
|
||||
*
|
||||
* ToastApi provides richer notification features including title/description,
|
||||
* action links, custom icons, per-toast timeout control, and programmatic dismiss.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Before (AlertApi)
|
||||
* import { alertApiRef } from '@backstage/core-plugin-api';
|
||||
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
|
||||
*
|
||||
* // After (ToastApi)
|
||||
* import { toastApiRef } from '@backstage/frontend-plugin-api';
|
||||
* toastApi.post({ title: 'Saved!', status: 'success', timeout: 5000 });
|
||||
* ```
|
||||
*/
|
||||
export {
|
||||
type AlertApi,
|
||||
type AlertMessage,
|
||||
|
||||
@@ -24,16 +24,16 @@ import { ReactNode } from 'react';
|
||||
import { SwappableComponentRef as SwappableComponentRef_2 } from '@backstage/frontend-plugin-api';
|
||||
import type { z } from 'zod';
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type AlertApi = {
|
||||
post(alert: AlertMessage): void;
|
||||
alert$(): Observable<AlertMessage>;
|
||||
};
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export const alertApiRef: ApiRef<AlertApi>;
|
||||
|
||||
// @public
|
||||
// @public @deprecated
|
||||
export type AlertMessage = {
|
||||
message: string;
|
||||
severity?: 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
@@ -21,6 +21,14 @@ import { Observable } from '@backstage/types';
|
||||
* Message handled by the {@link AlertApi}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link ToastMessage} from {@link ToastApi} instead. AlertApi will be removed in a future release.
|
||||
*
|
||||
* Migration guide:
|
||||
* - `message` becomes `title`
|
||||
* - `severity: 'error'` becomes `status: 'danger'`
|
||||
* - `severity: 'success' | 'info' | 'warning'` becomes `status: 'success' | 'info' | 'warning'`
|
||||
* - `display: 'transient'` becomes `timeout: 5000` (or custom milliseconds)
|
||||
* - `display: 'permanent'` means omitting `timeout`
|
||||
*/
|
||||
export type AlertMessage = {
|
||||
message: string;
|
||||
@@ -33,6 +41,23 @@ export type AlertMessage = {
|
||||
* The alert API is used to report alerts to the app, and display them to the user.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link ToastApi} instead. AlertApi will be removed in a future release.
|
||||
*
|
||||
* ToastApi provides richer notification features including:
|
||||
* - Title and optional description
|
||||
* - Action links
|
||||
* - Custom icons
|
||||
* - Per-toast timeout control
|
||||
* - Programmatic dismiss via returned key
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Before (AlertApi)
|
||||
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
|
||||
*
|
||||
* // After (ToastApi)
|
||||
* toastApi.post({ title: 'Saved!', status: 'success', timeout: 5000 });
|
||||
* ```
|
||||
*/
|
||||
export type AlertApi = {
|
||||
/**
|
||||
@@ -50,6 +75,7 @@ export type AlertApi = {
|
||||
* The {@link ApiRef} of {@link AlertApi}.
|
||||
*
|
||||
* @public
|
||||
* @deprecated Use {@link toastApiRef} instead. AlertApi will be removed in a future release.
|
||||
*/
|
||||
export const alertApiRef: ApiRef<AlertApi> = createApiRef({
|
||||
id: 'core.alert',
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"@material-ui/core": "^4.9.13",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "^4.0.0-alpha.61",
|
||||
"@react-aria/button": "^3.13.3",
|
||||
"@react-aria/button": "^3.14.3",
|
||||
"@react-aria/toast": "^3.0.9",
|
||||
"@react-hookz/web": "^24.0.0",
|
||||
"@react-stately/toast": "^3.1.2",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ToastApiForwarder } from './ToastApiForwarder';
|
||||
|
||||
describe('ToastApiForwarder', () => {
|
||||
let forwarder: ToastApiForwarder;
|
||||
|
||||
beforeEach(() => {
|
||||
forwarder = new ToastApiForwarder();
|
||||
});
|
||||
|
||||
describe('post', () => {
|
||||
it('should return a unique key for each toast', () => {
|
||||
const key1 = forwarder.post({ title: 'Toast 1' });
|
||||
const key2 = forwarder.post({ title: 'Toast 2' });
|
||||
|
||||
expect(key1).toBeDefined();
|
||||
expect(key2).toBeDefined();
|
||||
expect(key1).not.toBe(key2);
|
||||
});
|
||||
|
||||
it('should emit toast to subscribers', () => {
|
||||
const received: Array<{ title: unknown; key: string }> = [];
|
||||
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
forwarder.post({ title: 'Test Toast', status: 'success' });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].title).toBe('Test Toast');
|
||||
expect(received[0].key).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include all toast properties in emitted message', () => {
|
||||
const received: Array<{
|
||||
title: unknown;
|
||||
description?: unknown;
|
||||
status?: string;
|
||||
timeout?: number;
|
||||
}> = [];
|
||||
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
forwarder.post({
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
status: 'warning',
|
||||
timeout: 5000,
|
||||
links: [{ label: 'Link', href: '/test' }],
|
||||
});
|
||||
|
||||
expect(received[0]).toMatchObject({
|
||||
title: 'Title',
|
||||
description: 'Description',
|
||||
status: 'warning',
|
||||
timeout: 5000,
|
||||
links: [{ label: 'Link', href: '/test' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should emit close event to subscribers', () => {
|
||||
const closedKeys: string[] = [];
|
||||
|
||||
forwarder.close$().subscribe(key => {
|
||||
closedKeys.push(key);
|
||||
});
|
||||
|
||||
const key = forwarder.post({ title: 'Test' });
|
||||
forwarder.close(key);
|
||||
|
||||
expect(closedKeys).toHaveLength(1);
|
||||
expect(closedKeys[0]).toBe(key);
|
||||
});
|
||||
|
||||
it('should remove toast from replay buffer', () => {
|
||||
const key = forwarder.post({ title: 'Test' });
|
||||
forwarder.close(key);
|
||||
|
||||
// New subscriber should not receive the closed toast
|
||||
const received: Array<{ key: string }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast$ replay', () => {
|
||||
it('should replay recent toasts to new subscribers', async () => {
|
||||
forwarder.post({ title: 'Toast 1' });
|
||||
forwarder.post({ title: 'Toast 2' });
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = forwarder.toast$().subscribe({
|
||||
next: toast => {
|
||||
received.push(toast);
|
||||
// After receiving replayed toasts, unsubscribe
|
||||
if (received.length === 2) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
// Also resolve after a short timeout in case no toasts are replayed
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(2);
|
||||
expect(received[0].title).toBe('Toast 1');
|
||||
expect(received[1].title).toBe('Toast 2');
|
||||
});
|
||||
|
||||
it('should not replay closed toasts to new subscribers', async () => {
|
||||
const key1 = forwarder.post({ title: 'Toast 1' });
|
||||
forwarder.post({ title: 'Toast 2' });
|
||||
|
||||
forwarder.close(key1);
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = forwarder.toast$().subscribe({
|
||||
next: toast => {
|
||||
received.push(toast);
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].title).toBe('Toast 2');
|
||||
});
|
||||
|
||||
it('should limit replay buffer size', async () => {
|
||||
// Post more than maxBufferSize (10) toasts
|
||||
for (let i = 0; i < 15; i++) {
|
||||
forwarder.post({ title: `Toast ${i}` });
|
||||
}
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
await new Promise<void>(resolve => {
|
||||
const subscription = forwarder.toast$().subscribe({
|
||||
next: toast => {
|
||||
received.push(toast);
|
||||
if (received.length === 10) {
|
||||
subscription.unsubscribe();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
setTimeout(() => resolve(), 100);
|
||||
});
|
||||
|
||||
// Should only have last 10 toasts
|
||||
expect(received).toHaveLength(10);
|
||||
expect(received[0].title).toBe('Toast 5');
|
||||
expect(received[9].title).toBe('Toast 14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('close$ observable', () => {
|
||||
it('should allow multiple subscribers', () => {
|
||||
const subscriber1: string[] = [];
|
||||
const subscriber2: string[] = [];
|
||||
|
||||
forwarder.close$().subscribe(key => subscriber1.push(key));
|
||||
forwarder.close$().subscribe(key => subscriber2.push(key));
|
||||
|
||||
const key = forwarder.post({ title: 'Test' });
|
||||
forwarder.close(key);
|
||||
|
||||
expect(subscriber1).toEqual([key]);
|
||||
expect(subscriber2).toEqual([key]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscription cleanup', () => {
|
||||
it('should stop receiving toasts after unsubscribe', () => {
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
const subscription = forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
forwarder.post({ title: 'Before unsubscribe' });
|
||||
subscription.unsubscribe();
|
||||
forwarder.post({ title: 'After unsubscribe' });
|
||||
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].title).toBe('Before unsubscribe');
|
||||
});
|
||||
|
||||
it('should stop receiving close events after unsubscribe', () => {
|
||||
const closedKeys: string[] = [];
|
||||
|
||||
const subscription = forwarder.close$().subscribe(key => {
|
||||
closedKeys.push(key);
|
||||
});
|
||||
|
||||
const key1 = forwarder.post({ title: 'Toast 1' });
|
||||
forwarder.close(key1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
|
||||
const key2 = forwarder.post({ title: 'Toast 2' });
|
||||
forwarder.close(key2);
|
||||
|
||||
expect(closedKeys).toHaveLength(1);
|
||||
expect(closedKeys[0]).toBe(key1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, act, waitFor } from '@testing-library/react';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import {
|
||||
alertApiRef,
|
||||
AlertApi,
|
||||
AlertMessage,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { toastApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
import { ToastDisplay } from './ToastDisplay';
|
||||
import { ToastApiForwarder } from '../../apis';
|
||||
import { toastQueue } from './ToastQueue';
|
||||
|
||||
// Mock AlertApi with proper Observable implementation
|
||||
class MockAlertApi implements AlertApi {
|
||||
private subscribers = new Set<
|
||||
ZenObservable.SubscriptionObserver<AlertMessage>
|
||||
>();
|
||||
|
||||
post(alert: AlertMessage) {
|
||||
this.subscribers.forEach(subscriber => subscriber.next(alert));
|
||||
}
|
||||
|
||||
alert$(): Observable<AlertMessage> {
|
||||
return new ObservableImpl<AlertMessage>(subscriber => {
|
||||
this.subscribers.add(subscriber);
|
||||
return () => {
|
||||
this.subscribers.delete(subscriber);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe('ToastDisplay', () => {
|
||||
let toastApi: ToastApiForwarder;
|
||||
let alertApi: MockAlertApi;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear the toast queue before each test
|
||||
while (toastQueue.visibleToasts.length > 0) {
|
||||
toastQueue.close(toastQueue.visibleToasts[0].key);
|
||||
}
|
||||
toastApi = new ToastApiForwarder();
|
||||
alertApi = new MockAlertApi();
|
||||
});
|
||||
|
||||
const renderToastDisplay = () => {
|
||||
return render(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[alertApiRef, alertApi],
|
||||
[toastApiRef, toastApi],
|
||||
]}
|
||||
>
|
||||
<ToastDisplay />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('ToastApi integration', () => {
|
||||
it('should display a toast when posted via ToastApi', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({
|
||||
title: 'Test Toast Title',
|
||||
status: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Test Toast Title'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display toast with description', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({
|
||||
title: 'Title',
|
||||
description: 'This is a description',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Title')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('This is a description'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display toast with links', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({
|
||||
title: 'Toast with link',
|
||||
links: [{ label: 'Click here', href: '/test' }],
|
||||
});
|
||||
});
|
||||
|
||||
const link = await screen.findByText('Click here');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/test');
|
||||
});
|
||||
|
||||
it('should display multiple toasts', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({ title: 'Toast 1', status: 'success' });
|
||||
toastApi.post({ title: 'Toast 2', status: 'warning' });
|
||||
toastApi.post({ title: 'Toast 3', status: 'danger' });
|
||||
});
|
||||
|
||||
await expect(screen.findByText('Toast 1')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Toast 2')).resolves.toBeInTheDocument();
|
||||
await expect(screen.findByText('Toast 3')).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow programmatic dismiss via close()', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
let toastKey: string;
|
||||
|
||||
await act(async () => {
|
||||
toastKey = toastApi.post({
|
||||
title: 'Dismissable Toast',
|
||||
status: 'info',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Dismissable Toast'),
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.close(toastKey!);
|
||||
// Wait for animation
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Dismissable Toast')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AlertApi integration (legacy)', () => {
|
||||
it('should display alert as toast when posted via AlertApi', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
alertApi.post({
|
||||
message: 'Legacy Alert Message',
|
||||
severity: 'success',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
screen.findByText('Legacy Alert Message'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should map alert error severity to danger status', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
alertApi.post({
|
||||
message: 'Error Alert',
|
||||
severity: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
const toast = await screen.findByText('Error Alert');
|
||||
expect(toast.closest('[data-status]')).toHaveAttribute(
|
||||
'data-status',
|
||||
'danger',
|
||||
);
|
||||
});
|
||||
|
||||
it('should default to success status when no severity', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
alertApi.post({
|
||||
message: 'No Severity Alert',
|
||||
});
|
||||
});
|
||||
|
||||
const toast = await screen.findByText('No Severity Alert');
|
||||
expect(toast.closest('[data-status]')).toHaveAttribute(
|
||||
'data-status',
|
||||
'success',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('concurrent usage', () => {
|
||||
it('should handle both ToastApi and AlertApi messages', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.post({ title: 'New Toast', status: 'success' });
|
||||
alertApi.post({ message: 'Legacy Alert', severity: 'warning' });
|
||||
});
|
||||
|
||||
await expect(screen.findByText('New Toast')).resolves.toBeInTheDocument();
|
||||
await expect(
|
||||
screen.findByText('Legacy Alert'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -39,15 +39,20 @@ function mapSeverity(
|
||||
* ToastDisplay bridges both the ToastApi and AlertApi with the Toast notification system.
|
||||
*
|
||||
* @remarks
|
||||
* This component subscribes to:
|
||||
* - `toastApi.toast$()` - New toast notifications with full features (title, description, links, icons)
|
||||
* - `alertApi.alert$()` - Legacy alerts for backward compatibility (message maps to title only)
|
||||
* This component provides a migration bridge between the deprecated AlertApi and the new ToastApi.
|
||||
* During the migration period, it subscribes to both APIs simultaneously, allowing plugins to
|
||||
* migrate incrementally without breaking existing functionality.
|
||||
*
|
||||
* For ToastApi:
|
||||
* **Subscriptions:**
|
||||
* - `toastApi.toast$()` - New toast notifications with full features (title, description, links, icons)
|
||||
* - `alertApi.alert$()` - Deprecated alerts for backward compatibility (message maps to title only)
|
||||
*
|
||||
* **ToastApi (recommended):**
|
||||
* - Uses toast content directly (title, description, status, icon, links)
|
||||
* - Uses the provided timeout from the toast message
|
||||
* - Supports programmatic dismiss via returned key
|
||||
*
|
||||
* For AlertApi (legacy):
|
||||
* **AlertApi (deprecated - please migrate to ToastApi):**
|
||||
* - `alert.message` → `toast.title`
|
||||
* - `alert.severity` → `toast.status` ('error' maps to 'danger')
|
||||
* - `alert.display` → `timeout` (transient gets default timeout, permanent stays until dismissed)
|
||||
@@ -57,7 +62,9 @@ function mapSeverity(
|
||||
* // In your app root element extension
|
||||
* <ToastDisplay transientTimeoutMs={5000} />
|
||||
*
|
||||
* // Using the new ToastApi:
|
||||
* // Using the new ToastApi (recommended):
|
||||
* import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
* const toastApi = useApi(toastApiRef);
|
||||
* toastApi.post({
|
||||
* title: 'Entity saved',
|
||||
* description: 'Your changes have been saved successfully.',
|
||||
@@ -65,7 +72,9 @@ function mapSeverity(
|
||||
* timeout: 5000,
|
||||
* });
|
||||
*
|
||||
* // Using the legacy AlertApi:
|
||||
* // Using the deprecated AlertApi (migrate to ToastApi):
|
||||
* import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
* const alertApi = useApi(alertApiRef);
|
||||
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
|
||||
* ```
|
||||
*
|
||||
@@ -115,7 +124,8 @@ export function ToastDisplay(props: ToastDisplayProps) {
|
||||
return () => subscription.unsubscribe();
|
||||
}, [toastApi]);
|
||||
|
||||
// Subscribe to AlertApi (legacy support)
|
||||
// Subscribe to AlertApi (deprecated - provides backward compatibility during migration)
|
||||
// This subscription will be removed when AlertApi is fully deprecated
|
||||
useEffect(() => {
|
||||
const subscription = alertApi.alert$().subscribe(alert => {
|
||||
const content: ToastContent = {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useInvertedThemeMode } from './useInvertedThemeMode';
|
||||
|
||||
describe('useInvertedThemeMode', () => {
|
||||
const originalBodyTheme = document.body.getAttribute('data-theme-mode');
|
||||
const originalHtmlTheme =
|
||||
document.documentElement.getAttribute('data-theme-mode');
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original attributes
|
||||
if (originalBodyTheme) {
|
||||
document.body.setAttribute('data-theme-mode', originalBodyTheme);
|
||||
} else {
|
||||
document.body.removeAttribute('data-theme-mode');
|
||||
}
|
||||
if (originalHtmlTheme) {
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme-mode',
|
||||
originalHtmlTheme,
|
||||
);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme-mode');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return dark when no theme is set', () => {
|
||||
document.body.removeAttribute('data-theme-mode');
|
||||
document.documentElement.removeAttribute('data-theme-mode');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
|
||||
it('should return light when body theme is dark', () => {
|
||||
document.body.setAttribute('data-theme-mode', 'dark');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should return dark when body theme is light', () => {
|
||||
document.body.setAttribute('data-theme-mode', 'light');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
|
||||
it('should prefer body theme over html theme', () => {
|
||||
document.documentElement.setAttribute('data-theme-mode', 'light');
|
||||
document.body.setAttribute('data-theme-mode', 'dark');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
// Body is dark, so inverted should be light
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should fall back to html theme when body has no theme', () => {
|
||||
document.body.removeAttribute('data-theme-mode');
|
||||
document.documentElement.setAttribute('data-theme-mode', 'dark');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should update when body theme changes', async () => {
|
||||
document.body.setAttribute('data-theme-mode', 'light');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
expect(result.current).toBe('dark');
|
||||
|
||||
// Change theme
|
||||
await act(async () => {
|
||||
document.body.setAttribute('data-theme-mode', 'dark');
|
||||
// Wait for MutationObserver to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should update when html theme changes', async () => {
|
||||
document.body.removeAttribute('data-theme-mode');
|
||||
document.documentElement.setAttribute('data-theme-mode', 'light');
|
||||
|
||||
const { result } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
expect(result.current).toBe('dark');
|
||||
|
||||
// Change theme
|
||||
await act(async () => {
|
||||
document.documentElement.setAttribute('data-theme-mode', 'dark');
|
||||
// Wait for MutationObserver to fire
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
|
||||
it('should clean up observer on unmount', () => {
|
||||
const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect');
|
||||
|
||||
const { unmount } = renderHook(() => useInvertedThemeMode());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
disconnectSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -4301,7 +4301,7 @@ __metadata:
|
||||
"@material-ui/core": "npm:^4.9.13"
|
||||
"@material-ui/icons": "npm:^4.9.1"
|
||||
"@material-ui/lab": "npm:^4.0.0-alpha.61"
|
||||
"@react-aria/button": "npm:^3.13.3"
|
||||
"@react-aria/button": "npm:^3.14.3"
|
||||
"@react-aria/toast": "npm:^3.0.9"
|
||||
"@react-hookz/web": "npm:^24.0.0"
|
||||
"@react-stately/toast": "npm:^3.1.2"
|
||||
@@ -15564,7 +15564,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/button@npm:^3.13.3, @react-aria/button@npm:^3.14.4":
|
||||
"@react-aria/button@npm:^3.14.3":
|
||||
version: 3.14.3
|
||||
resolution: "@react-aria/button@npm:3.14.3"
|
||||
dependencies:
|
||||
"@react-aria/interactions": "npm:^3.26.0"
|
||||
"@react-aria/toolbar": "npm:3.0.0-beta.22"
|
||||
"@react-aria/utils": "npm:^3.32.0"
|
||||
"@react-stately/toggle": "npm:^3.9.3"
|
||||
"@react-types/button": "npm:^3.14.1"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
"@swc/helpers": "npm:^0.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/383046c3b4ea5972f81f56251fc19b207d6d811d7ac06db866ccd809dbcc1b72358f4b1535fc65817929496c1acb15f7c2611e4777a371333bb1b150b954ccb8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/button@npm:^3.14.4":
|
||||
version: 3.14.4
|
||||
resolution: "@react-aria/button@npm:3.14.4"
|
||||
dependencies:
|
||||
@@ -15903,6 +15921,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/landmark@npm:^3.0.8":
|
||||
version: 3.0.8
|
||||
resolution: "@react-aria/landmark@npm:3.0.8"
|
||||
dependencies:
|
||||
"@react-aria/utils": "npm:^3.32.0"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
"@swc/helpers": "npm:^0.5.0"
|
||||
use-sync-external-store: "npm:^1.4.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/7711871d004bbce5d8fadec3d81350cb9ce1896b01e4cc5f247003fedb9b785d3ca24947ce3f688ca5a8145004dd2cbbf43f1ac44e884c8ed6a950fd40a9e9f2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/landmark@npm:^3.0.9":
|
||||
version: 3.0.9
|
||||
resolution: "@react-aria/landmark@npm:3.0.9"
|
||||
@@ -16310,7 +16343,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/toast@npm:^3.0.10, @react-aria/toast@npm:^3.0.9":
|
||||
"@react-aria/toast@npm:^3.0.10":
|
||||
version: 3.0.10
|
||||
resolution: "@react-aria/toast@npm:3.0.10"
|
||||
dependencies:
|
||||
@@ -16329,6 +16362,25 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/toast@npm:^3.0.9":
|
||||
version: 3.0.9
|
||||
resolution: "@react-aria/toast@npm:3.0.9"
|
||||
dependencies:
|
||||
"@react-aria/i18n": "npm:^3.12.14"
|
||||
"@react-aria/interactions": "npm:^3.26.0"
|
||||
"@react-aria/landmark": "npm:^3.0.8"
|
||||
"@react-aria/utils": "npm:^3.32.0"
|
||||
"@react-stately/toast": "npm:^3.1.2"
|
||||
"@react-types/button": "npm:^3.14.1"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
"@swc/helpers": "npm:^0.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/f63cf143eec3e763ddd2885172685c6b85aaac1fb4423ced81aeceba06121788664909bfde862815e2562a7acd03c54df9a83e3fd0ddc60885806f76df30d2bf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-aria/toggle@npm:^3.12.4":
|
||||
version: 3.12.4
|
||||
resolution: "@react-aria/toggle@npm:3.12.4"
|
||||
@@ -16608,7 +16660,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/disclosure@npm:^3.0.10, @react-stately/disclosure@npm:^3.0.9":
|
||||
"@react-stately/disclosure@npm:^3.0.10":
|
||||
version: 3.0.10
|
||||
resolution: "@react-stately/disclosure@npm:3.0.10"
|
||||
dependencies:
|
||||
@@ -16621,6 +16673,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/disclosure@npm:^3.0.9":
|
||||
version: 3.0.9
|
||||
resolution: "@react-stately/disclosure@npm:3.0.9"
|
||||
dependencies:
|
||||
"@react-stately/utils": "npm:^3.11.0"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
"@swc/helpers": "npm:^0.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/b0458bc5255672af6b587a0b478b55077c481f223abb6094af04eeb8a922774a0da524dce6c540aec014f6efab4eecfa4d43a72eadd94e232dbdd959d49afd36
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/dnd@npm:^3.7.2, @react-stately/dnd@npm:^3.7.3":
|
||||
version: 3.7.3
|
||||
resolution: "@react-stately/dnd@npm:3.7.3"
|
||||
@@ -16703,7 +16768,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/menu@npm:^3.9.10, @react-stately/menu@npm:^3.9.9":
|
||||
"@react-stately/menu@npm:^3.9.10":
|
||||
version: 3.9.10
|
||||
resolution: "@react-stately/menu@npm:3.9.10"
|
||||
dependencies:
|
||||
@@ -16717,6 +16782,20 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/menu@npm:^3.9.9":
|
||||
version: 3.9.9
|
||||
resolution: "@react-stately/menu@npm:3.9.9"
|
||||
dependencies:
|
||||
"@react-stately/overlays": "npm:^3.6.21"
|
||||
"@react-types/menu": "npm:^3.10.5"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
"@swc/helpers": "npm:^0.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/e71ac18a795fd125813d856c5889aaaa0dfd70ce01960e24b1926d2b98e7129610e04e586033c7b063a43594abdc4e88a8cc08f314630076091dad21f670e933
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/numberfield@npm:^3.10.3, @react-stately/numberfield@npm:^3.10.4":
|
||||
version: 3.10.4
|
||||
resolution: "@react-stately/numberfield@npm:3.10.4"
|
||||
@@ -16877,7 +16956,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/tooltip@npm:^3.5.10, @react-stately/tooltip@npm:^3.5.9":
|
||||
"@react-stately/tooltip@npm:^3.5.10":
|
||||
version: 3.5.10
|
||||
resolution: "@react-stately/tooltip@npm:3.5.10"
|
||||
dependencies:
|
||||
@@ -16890,6 +16969,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/tooltip@npm:^3.5.9":
|
||||
version: 3.5.9
|
||||
resolution: "@react-stately/tooltip@npm:3.5.9"
|
||||
dependencies:
|
||||
"@react-stately/overlays": "npm:^3.6.21"
|
||||
"@react-types/tooltip": "npm:^3.5.0"
|
||||
"@swc/helpers": "npm:^0.5.0"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/67c26c7e1777b61ee74411f12a0c971ad8550b5675eddc746761890b1854488d8774dcc0e91b30e46e5935c9c6751e7c2a38e189eba9a1efb4c97385a4b7a5e9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-stately/tree@npm:^3.9.4, @react-stately/tree@npm:^3.9.5":
|
||||
version: 3.9.5
|
||||
resolution: "@react-stately/tree@npm:3.9.5"
|
||||
@@ -17081,6 +17173,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-types/menu@npm:^3.10.5":
|
||||
version: 3.10.5
|
||||
resolution: "@react-types/menu@npm:3.10.5"
|
||||
dependencies:
|
||||
"@react-types/overlays": "npm:^3.9.2"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/0466f5b7602ccfc8cb8fec3cd2482d587acbfd1701d427c7d79a121eb3f2b137feca70e62fd401aea95022fd616460575c4d5dd541cf3d69d8644ca54a2446c8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-types/menu@npm:^3.10.6":
|
||||
version: 3.10.6
|
||||
resolution: "@react-types/menu@npm:3.10.6"
|
||||
@@ -17115,6 +17219,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-types/overlays@npm:^3.9.2":
|
||||
version: 3.9.2
|
||||
resolution: "@react-types/overlays@npm:3.9.2"
|
||||
dependencies:
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/6cab7f2cbb813f710696095db1169f902cfe7e4a9aeef496848343ff5116be3782bfea68dffbeaf3f984a0475c2fb6c4a26ad9fb563172c4ec3e47110ca1e672
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-types/overlays@npm:^3.9.3":
|
||||
version: 3.9.3
|
||||
resolution: "@react-types/overlays@npm:3.9.3"
|
||||
@@ -17236,6 +17351,18 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-types/tooltip@npm:^3.5.0":
|
||||
version: 3.5.0
|
||||
resolution: "@react-types/tooltip@npm:3.5.0"
|
||||
dependencies:
|
||||
"@react-types/overlays": "npm:^3.9.2"
|
||||
"@react-types/shared": "npm:^3.32.1"
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
|
||||
checksum: 10/66c20eb0e0d66628e739510e5fba8eb56534beec1887f7efcf1867fb5e43b8da3502c3f865059d08ce672e3c6e02876470497ab77dd3378f9ba6fbb8f1ca1de2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-types/tooltip@npm:^3.5.1":
|
||||
version: 3.5.1
|
||||
resolution: "@react-types/tooltip@npm:3.5.1"
|
||||
|
||||
Reference in New Issue
Block a user