From eea95b8ae2674f4eeb4acd6bb5a6b8ce4ecc91a0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 5 Feb 2026 21:34:31 +0000 Subject: [PATCH] Add tests and deprecation warnings Signed-off-by: Charles de Dreuille --- .changeset/deprecate-alert-api.md | 56 +++++ packages/core-app-api/report.api.md | 2 +- .../AlertApi/AlertApiForwarder.ts | 12 + .../src/apis/definitions/AlertApi.ts | 17 ++ packages/frontend-plugin-api/report.api.md | 6 +- .../src/apis/definitions/AlertApi.ts | 26 ++ plugins/app/package.json | 2 +- .../app/src/apis/ToastApiForwarder.test.ts | 238 ++++++++++++++++++ .../components/Toast/ToastDisplay.test.tsx | 232 +++++++++++++++++ .../app/src/components/Toast/ToastDisplay.tsx | 26 +- .../src/hooks/useInvertedThemeMode.test.ts | 131 ++++++++++ yarn.lock | 139 +++++++++- 12 files changed, 868 insertions(+), 19 deletions(-) create mode 100644 .changeset/deprecate-alert-api.md create mode 100644 plugins/app/src/apis/ToastApiForwarder.test.ts create mode 100644 plugins/app/src/components/Toast/ToastDisplay.test.tsx create mode 100644 plugins/app/src/hooks/useInvertedThemeMode.test.ts diff --git a/.changeset/deprecate-alert-api.md b/.changeset/deprecate-alert-api.md new file mode 100644 index 0000000000..4041c9ad85 --- /dev/null +++ b/.changeset/deprecate-alert-api.md @@ -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. diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 51bdb676ce..35b16ad475 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -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; diff --git a/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts index 138d00c09c..8f82927763 100644 --- a/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts +++ b/packages/core-app-api/src/apis/implementations/AlertApi/AlertApiForwarder.ts @@ -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(); 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(); diff --git a/packages/core-plugin-api/src/apis/definitions/AlertApi.ts b/packages/core-plugin-api/src/apis/definitions/AlertApi.ts index a451195476..7394e5d0e8 100644 --- a/packages/core-plugin-api/src/apis/definitions/AlertApi.ts +++ b/packages/core-plugin-api/src/apis/definitions/AlertApi.ts @@ -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, diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index dd3ce7dc92..0373cea873 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -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; }; -// @public +// @public @deprecated export const alertApiRef: ApiRef; -// @public +// @public @deprecated export type AlertMessage = { message: string; severity?: 'success' | 'info' | 'warning' | 'error'; diff --git a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts index afdcb6a617..dbb05d642f 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts @@ -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 = createApiRef({ id: 'core.alert', diff --git a/plugins/app/package.json b/plugins/app/package.json index 62ee98208c..e9d0b63697 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -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", diff --git a/plugins/app/src/apis/ToastApiForwarder.test.ts b/plugins/app/src/apis/ToastApiForwarder.test.ts new file mode 100644 index 0000000000..1dbebb1ef7 --- /dev/null +++ b/plugins/app/src/apis/ToastApiForwarder.test.ts @@ -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(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(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(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); + }); + }); +}); diff --git a/plugins/app/src/components/Toast/ToastDisplay.test.tsx b/plugins/app/src/components/Toast/ToastDisplay.test.tsx new file mode 100644 index 0000000000..dc2839c9b7 --- /dev/null +++ b/plugins/app/src/components/Toast/ToastDisplay.test.tsx @@ -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 + >(); + + post(alert: AlertMessage) { + this.subscribers.forEach(subscriber => subscriber.next(alert)); + } + + alert$(): Observable { + return new ObservableImpl(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( + + + , + ); + }; + + 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(); + }); + }); +}); diff --git a/plugins/app/src/components/Toast/ToastDisplay.tsx b/plugins/app/src/components/Toast/ToastDisplay.tsx index 02be667e35..984a484fd0 100644 --- a/plugins/app/src/components/Toast/ToastDisplay.tsx +++ b/plugins/app/src/components/Toast/ToastDisplay.tsx @@ -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 * * - * // 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 = { diff --git a/plugins/app/src/hooks/useInvertedThemeMode.test.ts b/plugins/app/src/hooks/useInvertedThemeMode.test.ts new file mode 100644 index 0000000000..d0e8e45ab6 --- /dev/null +++ b/plugins/app/src/hooks/useInvertedThemeMode.test.ts @@ -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(); + }); +}); diff --git a/yarn.lock b/yarn.lock index 8fbf1497d6..830c543725 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"