Address another set of feedbacks
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
033ec31a33
commit
d007f23162
@@ -1,7 +1,5 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/core-plugin-api': patch
|
||||
'@backstage/core-app-api': patch
|
||||
---
|
||||
|
||||
Deprecated `AlertApi` in favor of the new `ToastApi`.
|
||||
@@ -16,18 +14,18 @@ Deprecated `AlertApi` in favor of the new `ToastApi`.
|
||||
- **Action Links**: Include clickable links within notifications
|
||||
- **Status Variants**: Support for neutral, info, success, warning, and danger statuses
|
||||
- **Per-toast Timeout**: Control auto-dismiss timing for each notification individually
|
||||
- **Programmatic Dismiss**: Close notifications via the key returned from `post()`
|
||||
- **Programmatic Dismiss**: Close notifications via the `close()` handle 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) |
|
||||
| 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 `{ close(): void }` |
|
||||
|
||||
**Example Migration**
|
||||
|
||||
@@ -46,11 +44,12 @@ alertApi.post({
|
||||
import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
|
||||
const toastApi = useApi(toastApiRef);
|
||||
toastApi.post({
|
||||
const toast = toastApi.post({
|
||||
title: 'Entity saved successfully',
|
||||
status: 'success',
|
||||
timeout: 5000,
|
||||
});
|
||||
// Later: toast.close() to dismiss programmatically
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
'@backstage/frontend-plugin-api': minor
|
||||
'@backstage/plugin-app': minor
|
||||
'@backstage/frontend-plugin-api': patch
|
||||
'@backstage/plugin-app': patch
|
||||
---
|
||||
|
||||
Introduced a new `ToastApi` for displaying rich toast notifications in the new frontend system.
|
||||
@@ -11,7 +11,7 @@ The new `ToastApi` provides enhanced notification capabilities compared to the e
|
||||
- **Custom Timeouts**: Each toast can specify its own timeout duration
|
||||
- **Links**: Toasts can include action links
|
||||
- **Status Variants**: Support for neutral, info, success, warning, and danger statuses
|
||||
- **Programmatic Dismiss**: Toasts can be dismissed programmatically using the key returned from `post()`
|
||||
- **Programmatic Dismiss**: Toasts can be dismissed programmatically using the `close()` handle returned from `post()`
|
||||
|
||||
**Usage:**
|
||||
|
||||
@@ -30,9 +30,9 @@ toastApi.post({
|
||||
});
|
||||
|
||||
// Programmatic dismiss
|
||||
const key = toastApi.post({ title: 'Uploading...', status: 'info' });
|
||||
const { close } = toastApi.post({ title: 'Uploading...', status: 'info' });
|
||||
// Later...
|
||||
toastApi.close(key);
|
||||
close();
|
||||
```
|
||||
|
||||
The `ToastDisplay` component subscribes to both `ToastApi` and `AlertApi`, providing a migration path where both systems work side by side until `AlertApi` is fully deprecated.
|
||||
|
||||
@@ -49,13 +49,33 @@ export type ToastApiMessage = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast message with key, as returned by the toast$() observable.
|
||||
* Handle returned by {@link ToastApi.post} that allows programmatic control
|
||||
* of the posted toast.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ToastApiPostResult = {
|
||||
/** Dismiss the toast. */
|
||||
close(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Toast message with key, as emitted by the toast$() observable.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type ToastApiMessageWithKey = ToastApiMessage & {
|
||||
/** Unique key for the toast, used for programmatic dismiss */
|
||||
/** Unique key for the toast, used internally for tracking */
|
||||
key: string;
|
||||
/** Dismiss this toast programmatically */
|
||||
close(): void;
|
||||
/**
|
||||
* Register a callback that fires when this toast is closed.
|
||||
* Used internally by the toast display to sync with the rendering queue.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
onClose(callback: () => void): void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -82,9 +102,9 @@ export type ToastApiMessageWithKey = ToastApiMessage & {
|
||||
* toastApi.post({ title: 'Processing...', status: 'info' });
|
||||
*
|
||||
* // Programmatic dismiss
|
||||
* const key = toastApi.post({ title: 'Uploading...', status: 'info' });
|
||||
* const { close } = toastApi.post({ title: 'Uploading...', status: 'info' });
|
||||
* // Later...
|
||||
* toastApi.close(key);
|
||||
* close();
|
||||
* ```
|
||||
*
|
||||
* @public
|
||||
@@ -94,27 +114,14 @@ export type ToastApi = {
|
||||
* Post a toast notification for display to the user.
|
||||
*
|
||||
* @param toast - The toast message to display
|
||||
* @returns A unique key that can be used to programmatically dismiss the toast
|
||||
* @returns A handle with a `close()` method to programmatically dismiss the toast
|
||||
*/
|
||||
post(toast: ToastApiMessage): string;
|
||||
|
||||
/**
|
||||
* Programmatically close/dismiss a toast by its key.
|
||||
*
|
||||
* @param key - The key returned from post()
|
||||
*/
|
||||
close(key: string): void;
|
||||
post(toast: ToastApiMessage): ToastApiPostResult;
|
||||
|
||||
/**
|
||||
* Observe toasts posted by other parts of the application.
|
||||
*/
|
||||
toast$(): Observable<ToastApiMessageWithKey>;
|
||||
|
||||
/**
|
||||
* Observe close events for programmatic toast dismissal.
|
||||
* Emits the key of the toast that should be closed.
|
||||
*/
|
||||
close$(): Observable<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,13 +24,11 @@ describe('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' });
|
||||
it('should return a result with a close method', () => {
|
||||
const result = forwarder.post({ title: 'Toast 1' });
|
||||
|
||||
expect(key1).toBeDefined();
|
||||
expect(key2).toBeDefined();
|
||||
expect(key1).not.toBe(key2);
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.close).toBe('function');
|
||||
});
|
||||
|
||||
it('should emit toast to subscribers', () => {
|
||||
@@ -78,23 +76,40 @@ describe('ToastApiForwarder', () => {
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should emit close event to subscribers', () => {
|
||||
const closedKeys: string[] = [];
|
||||
it('should notify onClose listeners when close() is called', () => {
|
||||
const onCloseFn = jest.fn();
|
||||
|
||||
forwarder.close$().subscribe(key => {
|
||||
closedKeys.push(key);
|
||||
const received: Array<{ onClose: (cb: () => void) => void }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
const key = forwarder.post({ title: 'Test' });
|
||||
forwarder.close(key);
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
received[0].onClose(onCloseFn);
|
||||
result.close();
|
||||
|
||||
expect(closedKeys).toHaveLength(1);
|
||||
expect(closedKeys[0]).toBe(key);
|
||||
expect(onCloseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should only close once even if called multiple times', () => {
|
||||
const onCloseFn = jest.fn();
|
||||
|
||||
const received: Array<{ onClose: (cb: () => void) => void }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
received[0].onClose(onCloseFn);
|
||||
result.close();
|
||||
result.close();
|
||||
|
||||
expect(onCloseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should remove toast from replay buffer', () => {
|
||||
const key = forwarder.post({ title: 'Test' });
|
||||
forwarder.close(key);
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
result.close();
|
||||
|
||||
// New subscriber should not receive the closed toast
|
||||
const received: Array<{ key: string }> = [];
|
||||
@@ -104,6 +119,22 @@ describe('ToastApiForwarder', () => {
|
||||
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should immediately call onClose callback if already closed', () => {
|
||||
const onCloseFn = jest.fn();
|
||||
|
||||
const received: Array<{ onClose: (cb: () => void) => void }> = [];
|
||||
forwarder.toast$().subscribe(toast => {
|
||||
received.push(toast);
|
||||
});
|
||||
|
||||
const result = forwarder.post({ title: 'Test' });
|
||||
result.close();
|
||||
|
||||
// Register callback after close - should fire immediately
|
||||
received[0].onClose(onCloseFn);
|
||||
expect(onCloseFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast$ replay', () => {
|
||||
@@ -134,10 +165,10 @@ describe('ToastApiForwarder', () => {
|
||||
});
|
||||
|
||||
it('should not replay closed toasts to new subscribers', async () => {
|
||||
const key1 = forwarder.post({ title: 'Toast 1' });
|
||||
const result1 = forwarder.post({ title: 'Toast 1' });
|
||||
forwarder.post({ title: 'Toast 2' });
|
||||
|
||||
forwarder.close(key1);
|
||||
result1.close();
|
||||
|
||||
const received: Array<{ title: unknown }> = [];
|
||||
|
||||
@@ -184,22 +215,6 @@ describe('ToastApiForwarder', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 }> = [];
|
||||
@@ -215,24 +230,5 @@ describe('ToastApiForwarder', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ToastApi,
|
||||
ToastApiMessage,
|
||||
ToastApiMessageWithKey,
|
||||
ToastApiPostResult,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { Observable } from '@backstage/types';
|
||||
import ObservableImpl from 'zen-observable';
|
||||
@@ -86,14 +87,51 @@ class PublishSubject<T> {
|
||||
*/
|
||||
export class ToastApiForwarder implements ToastApi {
|
||||
private readonly subject = new PublishSubject<ToastApiMessageWithKey>();
|
||||
private readonly closeSubject = new PublishSubject<string>();
|
||||
private readonly recentToasts: ToastApiMessageWithKey[] = [];
|
||||
private readonly closedKeys = new Set<string>();
|
||||
private readonly maxBufferSize = 10;
|
||||
|
||||
post(toast: ToastApiMessage): string {
|
||||
post(toast: ToastApiMessage): ToastApiPostResult {
|
||||
const key = generateToastKey();
|
||||
const toastWithKey: ToastApiMessageWithKey = { ...toast, key };
|
||||
const closeCallbacks: Array<() => void> = [];
|
||||
let closed = false;
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
|
||||
// Track closed keys to prevent replaying dismissed toasts
|
||||
this.closedKeys.add(key);
|
||||
|
||||
// Remove from recent buffer if still there
|
||||
const index = this.recentToasts.findIndex(t => t.key === key);
|
||||
if (index !== -1) {
|
||||
this.recentToasts.splice(index, 1);
|
||||
}
|
||||
|
||||
// Clean up old closed keys when buffer is cleared
|
||||
if (this.recentToasts.length === 0) {
|
||||
this.closedKeys.clear();
|
||||
}
|
||||
|
||||
// Notify registered listeners (e.g. the toast display)
|
||||
closeCallbacks.forEach(fn => fn());
|
||||
};
|
||||
|
||||
const onClose = (callback: () => void) => {
|
||||
if (closed) {
|
||||
callback();
|
||||
} else {
|
||||
closeCallbacks.push(callback);
|
||||
}
|
||||
};
|
||||
|
||||
const toastWithKey: ToastApiMessageWithKey = {
|
||||
...toast,
|
||||
key,
|
||||
close,
|
||||
onClose,
|
||||
};
|
||||
|
||||
this.recentToasts.push(toastWithKey);
|
||||
if (this.recentToasts.length > this.maxBufferSize) {
|
||||
@@ -101,24 +139,7 @@ export class ToastApiForwarder implements ToastApi {
|
||||
}
|
||||
this.subject.next(toastWithKey);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
close(key: string): void {
|
||||
// Track closed keys to prevent replaying dismissed toasts
|
||||
this.closedKeys.add(key);
|
||||
|
||||
// Remove from recent buffer if still there
|
||||
const index = this.recentToasts.findIndex(t => t.key === key);
|
||||
if (index !== -1) {
|
||||
this.recentToasts.splice(index, 1);
|
||||
}
|
||||
this.closeSubject.next(key);
|
||||
|
||||
// Clean up old closed keys when buffer is cleared
|
||||
if (this.recentToasts.length === 0) {
|
||||
this.closedKeys.clear();
|
||||
}
|
||||
return { close };
|
||||
}
|
||||
|
||||
toast$(): Observable<ToastApiMessageWithKey> {
|
||||
@@ -128,14 +149,4 @@ export class ToastApiForwarder implements ToastApi {
|
||||
);
|
||||
return this.subject.asObservable(activeToasts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe close requests for toasts.
|
||||
* This is used internally by the ToastDisplay to know when to dismiss a toast programmatically.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
close$(): Observable<string> {
|
||||
return this.closeSubject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ import { Button, Flex, Text } from '../../../../../packages/ui/src';
|
||||
/* eslint-enable @backstage/no-relative-monorepo-imports */
|
||||
import { ToastQueue } from '@react-stately/toast';
|
||||
import { ToastContainer } from './index';
|
||||
import type { ToastContent } from './types';
|
||||
import type { ToastApiMessageContent } from './types';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
const toastQueue = new ToastQueue<ToastContent>({ maxVisibleToasts: 4 });
|
||||
const toastQueue = new ToastQueue<ToastApiMessageContent>({
|
||||
maxVisibleToasts: 4,
|
||||
});
|
||||
|
||||
const meta = preview.meta({
|
||||
title: 'Plugins/App/Toast',
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
RiAlertLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react';
|
||||
import type { ToastProps } from './types';
|
||||
import type { ToastApiMessageProps } from './types';
|
||||
import styles from './Toast.module.css';
|
||||
|
||||
// Track which toasts are being manually closed (vs auto-timeout)
|
||||
@@ -43,7 +43,7 @@ const manuallyClosingToasts = new Set<string>();
|
||||
* @internal
|
||||
*/
|
||||
export const Toast = forwardRef(
|
||||
(props: ToastProps, ref: Ref<HTMLDivElement>) => {
|
||||
(props: ToastApiMessageProps, ref: Ref<HTMLDivElement>) => {
|
||||
const {
|
||||
toast,
|
||||
state,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { forwardRef, Ref, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { useToastRegion } from '@react-aria/toast';
|
||||
import { useToastQueue } from '@react-stately/toast';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import type { ToastContainerProps } from './types';
|
||||
import type { ToastApiMessageContainerProps } from './types';
|
||||
import { useInvertedThemeMode } from '../../hooks/useInvertedThemeMode';
|
||||
import { Toast } from './Toast';
|
||||
import styles from './Toast.module.css';
|
||||
@@ -36,7 +36,7 @@ import styles from './Toast.module.css';
|
||||
* @internal
|
||||
*/
|
||||
export const ToastContainer = forwardRef(
|
||||
(props: ToastContainerProps, ref: Ref<HTMLDivElement>) => {
|
||||
(props: ToastApiMessageContainerProps, ref: Ref<HTMLDivElement>) => {
|
||||
const { queue, className } = props;
|
||||
|
||||
// Subscribe to the toast queue state
|
||||
|
||||
@@ -134,13 +134,14 @@ describe('ToastDisplay', () => {
|
||||
it('should allow programmatic dismiss via close()', async () => {
|
||||
renderToastDisplay();
|
||||
|
||||
let toastKey: string;
|
||||
let closeToast: () => void;
|
||||
|
||||
await act(async () => {
|
||||
toastKey = toastApi.post({
|
||||
const result = toastApi.post({
|
||||
title: 'Dismissable Toast',
|
||||
status: 'info',
|
||||
});
|
||||
closeToast = result.close;
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -148,7 +149,7 @@ describe('ToastDisplay', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
toastApi.close(toastKey!);
|
||||
closeToast!();
|
||||
// Wait for animation
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
});
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
import { toastApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { ToastQueue } from '@react-stately/toast';
|
||||
import { ToastContainer } from './ToastContainer';
|
||||
import type { ToastDisplayProps, ToastContent } from './types';
|
||||
import type {
|
||||
ToastApiMessageDisplayProps,
|
||||
ToastApiMessageContent,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Maps AlertApi severity to Toast status.
|
||||
@@ -27,7 +30,7 @@ import type { ToastDisplayProps, ToastContent } from './types';
|
||||
*/
|
||||
function mapSeverity(
|
||||
severity: 'success' | 'info' | 'warning' | 'error' | undefined,
|
||||
): ToastContent['status'] {
|
||||
): ToastApiMessageContent['status'] {
|
||||
if (severity === 'error') {
|
||||
return 'danger';
|
||||
}
|
||||
@@ -49,7 +52,7 @@ function mapSeverity(
|
||||
* **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
|
||||
* - Supports programmatic dismiss via the returned `close()` handle
|
||||
*
|
||||
* **AlertApi (deprecated - please migrate to ToastApi):**
|
||||
* - `alert.message` → `toast.title`
|
||||
@@ -64,12 +67,13 @@ function mapSeverity(
|
||||
* // Using the new ToastApi (recommended):
|
||||
* import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
|
||||
* const toastApi = useApi(toastApiRef);
|
||||
* toastApi.post({
|
||||
* const { close } = toastApi.post({
|
||||
* title: 'Entity saved',
|
||||
* description: 'Your changes have been saved successfully.',
|
||||
* status: 'success',
|
||||
* timeout: 5000,
|
||||
* });
|
||||
* // Later: close() to dismiss programmatically
|
||||
*
|
||||
* // Using the deprecated AlertApi (migrate to ToastApi):
|
||||
* import { alertApiRef, useApi } from '@backstage/core-plugin-api';
|
||||
@@ -79,23 +83,20 @@ function mapSeverity(
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export function ToastDisplay(props: ToastDisplayProps) {
|
||||
export function ToastDisplay(props: ToastApiMessageDisplayProps) {
|
||||
const alertApi = useApi(alertApiRef);
|
||||
const toastApi = useApi(toastApiRef);
|
||||
const { transientTimeoutMs = 5000 } = props;
|
||||
|
||||
// Create toast queue once per component instance
|
||||
const [toastQueue] = useState(
|
||||
() => new ToastQueue<ToastContent>({ maxVisibleToasts: 4 }),
|
||||
() => new ToastQueue<ToastApiMessageContent>({ maxVisibleToasts: 4 }),
|
||||
);
|
||||
|
||||
// Track toast keys for programmatic close
|
||||
const toastKeyMap = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Subscribe to ToastApi
|
||||
useEffect(() => {
|
||||
const subscription = toastApi.toast$().subscribe(toast => {
|
||||
const content: ToastContent = {
|
||||
const content: ToastApiMessageContent = {
|
||||
title: toast.title,
|
||||
description: toast.description,
|
||||
status: toast.status ?? 'success',
|
||||
@@ -107,21 +108,8 @@ export function ToastDisplay(props: ToastDisplayProps) {
|
||||
|
||||
const queueKey = toastQueue.add(content, options);
|
||||
|
||||
// Track the mapping from API key to queue key for programmatic close
|
||||
toastKeyMap.current.set(toast.key, queueKey);
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [toastApi, toastQueue]);
|
||||
|
||||
// Subscribe to ToastApi close events for programmatic dismissal
|
||||
useEffect(() => {
|
||||
const subscription = toastApi.close$().subscribe(apiKey => {
|
||||
const queueKey = toastKeyMap.current.get(apiKey);
|
||||
if (queueKey) {
|
||||
toastQueue.close(queueKey);
|
||||
toastKeyMap.current.delete(apiKey);
|
||||
}
|
||||
// When the toast is programmatically closed, remove it from the queue
|
||||
toast.onClose(() => toastQueue.close(queueKey));
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
@@ -131,7 +119,7 @@ export function ToastDisplay(props: ToastDisplayProps) {
|
||||
// This subscription will be removed when AlertApi is fully deprecated
|
||||
useEffect(() => {
|
||||
const subscription = alertApi.alert$().subscribe(alert => {
|
||||
const content: ToastContent = {
|
||||
const content: ToastApiMessageContent = {
|
||||
title: alert.message,
|
||||
status: mapSeverity(alert.severity),
|
||||
};
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
|
||||
// Public exports
|
||||
export { ToastDisplay } from './ToastDisplay';
|
||||
export type { ToastDisplayProps } from './types';
|
||||
export type { ToastApiMessageDisplayProps } from './types';
|
||||
|
||||
// Internal exports (used within the plugin only)
|
||||
export { ToastContainer } from './ToastContainer';
|
||||
export type { ToastContent, ToastLink, ToastContainerProps } from './types';
|
||||
export type {
|
||||
ToastApiMessageContent,
|
||||
ToastApiMessageLink,
|
||||
ToastApiMessageContainerProps,
|
||||
} from './types';
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { ToastQueue, ToastState, QueuedToast } from 'react-stately';
|
||||
* Link item for toast notifications
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastLink {
|
||||
export interface ToastApiMessageLink {
|
||||
/** Display text for the link */
|
||||
label: string;
|
||||
/** URL the link points to */
|
||||
@@ -32,7 +32,7 @@ export interface ToastLink {
|
||||
* Content for a toast notification
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastContent {
|
||||
export interface ToastApiMessageContent {
|
||||
/** Title of the toast (required) */
|
||||
title: ReactNode;
|
||||
/** Optional description text */
|
||||
@@ -40,18 +40,18 @@ export interface ToastContent {
|
||||
/** Status variant of the toast */
|
||||
status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
||||
/** Optional array of links to display */
|
||||
links?: ToastLink[];
|
||||
links?: ToastApiMessageLink[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the Toast component
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastProps {
|
||||
export interface ToastApiMessageProps {
|
||||
/** Toast object from the queue */
|
||||
toast: QueuedToast<ToastContent>;
|
||||
toast: QueuedToast<ToastApiMessageContent>;
|
||||
/** Toast state from useToastQueue */
|
||||
state: ToastState<ToastContent>;
|
||||
state: ToastState<ToastApiMessageContent>;
|
||||
/** Index of the toast in the visible toasts array */
|
||||
index?: number;
|
||||
/** Whether the toast stack is expanded (hovered/focused) */
|
||||
@@ -74,9 +74,9 @@ export interface ToastProps {
|
||||
* Props for the ToastContainer component
|
||||
* @internal
|
||||
*/
|
||||
export interface ToastContainerProps {
|
||||
export interface ToastApiMessageContainerProps {
|
||||
/** Toast queue instance */
|
||||
queue: ToastQueue<ToastContent>;
|
||||
queue: ToastQueue<ToastApiMessageContent>;
|
||||
/** Custom class name */
|
||||
className?: string;
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export interface ToastContainerProps {
|
||||
* Props for the ToastDisplay component (AlertApi bridge)
|
||||
* @public
|
||||
*/
|
||||
export interface ToastDisplayProps {
|
||||
export interface ToastApiMessageDisplayProps {
|
||||
/**
|
||||
* Number of milliseconds a transient alert will stay open for.
|
||||
* Defaults to 5000ms.
|
||||
|
||||
@@ -14,57 +14,65 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useApi, appThemeApiRef } from '@backstage/core-plugin-api';
|
||||
import useObservable from 'react-use/esm/useObservable';
|
||||
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
/**
|
||||
* Detects the current theme mode from the DOM and returns the inverted value.
|
||||
* Checks both document.documentElement and document.body for the data-theme-mode attribute.
|
||||
* Returns the inverted theme mode based on the active app theme.
|
||||
* Uses the AppThemeApi to detect the current theme variant reactively.
|
||||
* When the theme is set to "auto" (no explicit selection), falls back to
|
||||
* the system preference via `prefers-color-scheme`.
|
||||
*
|
||||
* @returns The inverted theme mode ('light' when app is dark, 'dark' when app is light)
|
||||
* @internal
|
||||
*/
|
||||
export function useInvertedThemeMode(): ThemeMode {
|
||||
const [invertedThemeMode, setInvertedThemeMode] = useState<ThemeMode>('dark');
|
||||
const appThemeApi = useApi(appThemeApiRef);
|
||||
|
||||
const themeId = useObservable(
|
||||
appThemeApi.activeThemeId$(),
|
||||
appThemeApi.getActiveThemeId(),
|
||||
);
|
||||
|
||||
// Track system color scheme preference for "auto" mode
|
||||
const mediaQuery = useMemo(
|
||||
() => window.matchMedia('(prefers-color-scheme: dark)'),
|
||||
[],
|
||||
);
|
||||
const [prefersDark, setPrefersDark] = useState(mediaQuery.matches);
|
||||
|
||||
useEffect(() => {
|
||||
const detectTheme = (): ThemeMode => {
|
||||
// Check both html and body elements for the theme attribute
|
||||
const htmlTheme =
|
||||
document.documentElement.getAttribute('data-theme-mode');
|
||||
const bodyTheme = document.body.getAttribute('data-theme-mode');
|
||||
const listener = (e: MediaQueryListEvent) => setPrefersDark(e.matches);
|
||||
mediaQuery.addEventListener('change', listener);
|
||||
return () => mediaQuery.removeEventListener('change', listener);
|
||||
}, [mediaQuery]);
|
||||
|
||||
// Prefer body (used by UnifiedThemeProvider) over html
|
||||
const currentTheme = bodyTheme || htmlTheme;
|
||||
// Resolve the active theme's variant, matching AppThemeProvider's logic:
|
||||
// 1. If a theme is explicitly selected, use its variant
|
||||
// 2. Otherwise (auto mode), use system preference to pick dark or light
|
||||
// 3. Fall back to the first installed theme
|
||||
const themes = appThemeApi.getInstalledThemes();
|
||||
let currentVariant: ThemeMode | undefined;
|
||||
|
||||
// If current theme is dark, return light (inverted), otherwise return dark
|
||||
return currentTheme === 'dark' ? 'light' : 'dark';
|
||||
};
|
||||
if (themeId !== undefined) {
|
||||
currentVariant = themes.find(t => t.id === themeId)?.variant;
|
||||
}
|
||||
|
||||
// Initial detection
|
||||
setInvertedThemeMode(detectTheme());
|
||||
|
||||
// Watch for theme changes on both html and body
|
||||
const observer = new MutationObserver(() => {
|
||||
setInvertedThemeMode(detectTheme());
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme-mode'],
|
||||
});
|
||||
|
||||
// Body might not exist in some edge cases (e.g., during SSR or early lifecycle)
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme-mode'],
|
||||
});
|
||||
if (!currentVariant) {
|
||||
if (prefersDark) {
|
||||
currentVariant = themes.find(t => t.variant === 'dark')?.variant;
|
||||
}
|
||||
currentVariant ??= themes.find(t => t.variant === 'light')?.variant;
|
||||
currentVariant ??= themes[0]?.variant;
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return invertedThemeMode;
|
||||
// Invert: if current is dark, toast should be light, and vice versa
|
||||
// Default to 'dark' if we can't determine (safe for most light-themed apps)
|
||||
if (currentVariant === 'dark') {
|
||||
return 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ export { appPlugin as default } from './plugin';
|
||||
|
||||
// Toast components for alert display
|
||||
export { ToastDisplay } from './components/Toast';
|
||||
export type { ToastDisplayProps } from './components/Toast';
|
||||
export type { ToastApiMessageDisplayProps } from './components/Toast';
|
||||
|
||||
Reference in New Issue
Block a user