diff --git a/.changeset/deep-crabs-dig.md b/.changeset/deep-crabs-dig.md new file mode 100644 index 0000000000..0e0769c4ea --- /dev/null +++ b/.changeset/deep-crabs-dig.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Fixed --bui-fg-success token in light mode to be more accessible. diff --git a/.changeset/deprecate-alert-api-core-app.md b/.changeset/deprecate-alert-api-core-app.md new file mode 100644 index 0000000000..d25da2b8cf --- /dev/null +++ b/.changeset/deprecate-alert-api-core-app.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Deprecated `AlertApiForwarder` in favor of the new `ToastApi`. The `AlertApiForwarder` now emits a console warning on first use, guiding developers to migrate to `ToastApi` from `@backstage/frontend-plugin-api`. diff --git a/.changeset/deprecate-alert-api-core-plugin.md b/.changeset/deprecate-alert-api-core-plugin.md new file mode 100644 index 0000000000..809652a84c --- /dev/null +++ b/.changeset/deprecate-alert-api-core-plugin.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-plugin-api': patch +--- + +Deprecated `AlertApi`, `AlertMessage`, and `alertApiRef` in favor of the new `ToastApi` from `@backstage/frontend-plugin-api`. diff --git a/.changeset/deprecate-alert-api.md b/.changeset/deprecate-alert-api.md new file mode 100644 index 0000000000..6334d03167 --- /dev/null +++ b/.changeset/deprecate-alert-api.md @@ -0,0 +1,55 @@ +--- +'@backstage/frontend-plugin-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 +- **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 `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 `{ close(): void }` | + +**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); +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. diff --git a/.changeset/toast-api-introduction.md b/.changeset/toast-api-introduction.md new file mode 100644 index 0000000000..8f44c4eae1 --- /dev/null +++ b/.changeset/toast-api-introduction.md @@ -0,0 +1,38 @@ +--- +'@backstage/frontend-plugin-api': patch +'@backstage/plugin-app': patch +--- + +Introduced a new `ToastApi` for displaying rich toast notifications in the new frontend system. + +The new `ToastApi` provides enhanced notification capabilities compared to the existing `AlertApi`: + +- **Title and Description**: Toasts support both a title and an optional description +- **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 `close()` handle returned from `post()` + +**Usage:** + +```typescript +import { toastApiRef, useApi } from '@backstage/frontend-plugin-api'; + +const toastApi = useApi(toastApiRef); + +// Full-featured toast +toastApi.post({ + title: 'Entity saved', + description: 'Your changes have been saved successfully.', + status: 'success', + timeout: 5000, + links: [{ label: 'View entity', href: '/catalog/entity' }], +}); + +// Programmatic dismiss +const { close } = toastApi.post({ title: 'Uploading...', status: 'info' }); +// Later... +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. diff --git a/.storybook/main.ts b/.storybook/main.ts index f891cd9692..a9cb4b243c 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -18,6 +18,7 @@ const allStories = isChromatic 'packages/ui', 'packages/core-components', 'packages/app', + 'plugins/app', 'plugins/org', 'plugins/search', 'plugins/search-react', 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-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 512475c711..9fa6b2d91a 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -1026,6 +1026,8 @@ describe('createApp', () => { + + diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 06456730f8..b9c5bf269a 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -25,18 +25,18 @@ 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_2 & { readonly $$type: '@backstage/ApiRef'; }; -// @public +// @public @deprecated export type AlertMessage = { message: string; severity?: 'success' | 'info' | 'warning' | 'error'; @@ -2241,6 +2241,34 @@ export const swappableComponentsApiRef: ApiRef_2< readonly $$type: '@backstage/ApiRef'; }; +// @public +export type ToastApi = { + post(toast: ToastApiMessage): ToastApiPostResult; +}; + +// @public +export type ToastApiMessage = { + title: ReactNode; + description?: ReactNode; + status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + links?: ToastApiMessageLink[]; + timeout?: number; +}; + +// @public +export type ToastApiMessageLink = { + label: string; + href: string; +}; + +// @public +export type ToastApiPostResult = { + close(): void; +}; + +// @public +export const toastApiRef: ApiRef; + // @public (undocumented) export type TranslationApi = { getTranslation< diff --git a/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts b/packages/frontend-plugin-api/src/apis/definitions/AlertApi.ts index 3dd7c8e443..88de012707 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 ToastApiMessage} 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 = createApiRef().with({ id: 'core.alert', diff --git a/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts new file mode 100644 index 0000000000..0a76632c29 --- /dev/null +++ b/packages/frontend-plugin-api/src/apis/definitions/ToastApi.ts @@ -0,0 +1,109 @@ +/* + * 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 { createApiRef, ApiRef } from '../system'; +import { ReactNode } from 'react'; + +/** + * Link item for toast notifications. + * + * @public + */ +export type ToastApiMessageLink = { + /** Display text for the link */ + label: string; + /** URL the link points to */ + href: string; +}; + +/** + * Message handled by the {@link ToastApi}. + * + * @public + */ +export type ToastApiMessage = { + /** Title of the toast (required) */ + title: ReactNode; + /** Optional description text */ + description?: ReactNode; + /** Status variant of the toast - defaults to 'success' */ + status?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'; + /** Optional array of links to display */ + links?: ToastApiMessageLink[]; + /** Timeout in milliseconds before auto-dismiss. If not set, toast is permanent. */ + timeout?: number; +}; + +/** + * Handle returned by {@link ToastApi.post} that allows programmatic control + * of the posted toast. + * + * @public + */ +export type ToastApiPostResult = { + /** Dismiss the toast. */ + close(): void; +}; + +/** + * The toast API is used to display toast notifications to the user. + * + * @remarks + * This API provides richer notification capabilities than the AlertApi, + * including title/description, links, and per-toast timeout control. + * + * @example + * ```tsx + * const toastApi = useApi(toastApiRef); + * + * // Full-featured toast + * toastApi.post({ + * title: 'Entity saved', + * description: 'Your changes have been saved successfully.', + * status: 'success', + * timeout: 5000, + * links: [{ label: 'View entity', href: '/catalog/default/component/my-service' }], + * }); + * + * // Simple toast + * toastApi.post({ title: 'Processing...', status: 'info' }); + * + * // Programmatic dismiss + * const { close } = toastApi.post({ title: 'Uploading...', status: 'info' }); + * // Later... + * close(); + * ``` + * + * @public + */ +export type ToastApi = { + /** + * Post a toast notification for display to the user. + * + * @param toast - The toast message to display + * @returns A handle with a `close()` method to programmatically dismiss the toast + */ + post(toast: ToastApiMessage): ToastApiPostResult; +}; + +/** + * The {@link ApiRef} of {@link ToastApi}. + * + * @public + */ +export const toastApiRef: ApiRef = createApiRef({ + id: 'core.toast', +}); diff --git a/packages/frontend-plugin-api/src/apis/definitions/index.ts b/packages/frontend-plugin-api/src/apis/definitions/index.ts index 582eacf345..e33834ff5e 100644 --- a/packages/frontend-plugin-api/src/apis/definitions/index.ts +++ b/packages/frontend-plugin-api/src/apis/definitions/index.ts @@ -48,6 +48,7 @@ export * from './OAuthRequestApi'; export * from './RouteResolutionApi'; export * from './StorageApi'; export * from './AnalyticsApi'; +export * from './ToastApi'; export * from './TranslationApi'; export * from './PluginHeaderActionsApi'; export * from './PluginWrapperApi'; diff --git a/packages/ui/src/css/tokens.css b/packages/ui/src/css/tokens.css index 166548db0a..9426993ea5 100644 --- a/packages/ui/src/css/tokens.css +++ b/packages/ui/src/css/tokens.css @@ -15,8 +15,9 @@ */ @layer tokens { - /* Light theme tokens */ - :root { + /* Light theme tokens (also used for nested light theme switching) */ + :root, + [data-theme-mode='light'] { /* Font families */ --bui-font-regular: system-ui; --bui-font-monospace: ui-monospace, 'Menlo', 'Monaco', 'Consolas', @@ -119,7 +120,7 @@ --bui-fg-info-on-bg: #173da6; --bui-fg-danger: #ec3b18; --bui-fg-warning: #ef7a32; - --bui-fg-success: #1ed760; + --bui-fg-success: #1aaf4f; --bui-fg-info: #0d74ce; /* Border colors */ diff --git a/plugins/app/package.json b/plugins/app/package.json index a7d03eb61f..974a39853b 100644 --- a/plugins/app/package.json +++ b/plugins/app/package.json @@ -65,8 +65,14 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", + "@react-aria/button": "^3.14.3", + "@react-aria/toast": "^3.0.9", "@react-hookz/web": "^24.0.0", + "@react-stately/toast": "^3.1.2", + "@remixicon/react": "^4.6.0", + "motion": "^12.0.0", "react-use": "^17.2.4", + "zen-observable": "^0.10.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index 4da77d055e..4c9b2a8ce4 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -720,6 +720,36 @@ const appPlugin: OverridableFrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; + 'api:app/toast': OverridableExtensionDefinition<{ + kind: 'api'; + name: 'toast'; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; + 'api:app/toast-forwarder': OverridableExtensionDefinition<{ + kind: 'api'; + name: 'toast-forwarder'; + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: {}; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; 'api:app/translations': OverridableExtensionDefinition<{ config: {}; configInput: {}; diff --git a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx index c7f1e5a68d..c657eaa633 100644 --- a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx +++ b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx @@ -100,6 +100,14 @@ describe('appModulePublicSignIn', () => { data-unified-theme-stack="[{"mode":"light","name":"backstage"}]" >
+
{ + let forwarder: ToastApiForwarder; + + beforeEach(() => { + forwarder = new ToastApiForwarder(); + }); + + describe('post', () => { + it('should return a result with a close method', () => { + const result = forwarder.post({ title: 'Toast 1' }); + + expect(result).toBeDefined(); + expect(typeof result.close).toBe('function'); + }); + + 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 notify onClose listeners when close() is called', () => { + 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(); + + 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 result = forwarder.post({ title: 'Test' }); + result.close(); + + // New subscriber should not receive the closed toast + const received: Array<{ key: string }> = []; + forwarder.toast$().subscribe(toast => { + received.push(toast); + }); + + 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', () => { + 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 result1 = forwarder.post({ title: 'Toast 1' }); + forwarder.post({ title: 'Toast 2' }); + + result1.close(); + + 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('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'); + }); + }); +}); diff --git a/plugins/app/src/apis/ToastApiForwarder.ts b/plugins/app/src/apis/ToastApiForwarder.ts new file mode 100644 index 0000000000..10a778f388 --- /dev/null +++ b/plugins/app/src/apis/ToastApiForwarder.ts @@ -0,0 +1,154 @@ +/* + * 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 { + ToastApiMessage, + ToastApiPostResult, +} from '@backstage/frontend-plugin-api'; +import { Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; +import { + ToastApiForwarderApi, + ToastApiForwarderMessage, +} from './toastApiForwarderRef'; + +let toastKeyCounter = 0; + +/** + * Generates a unique key for a toast message. + */ +function generateToastKey(): string { + toastKeyCounter += 1; + return `toast-${toastKeyCounter}-${Date.now()}`; +} + +/** + * A simple publish subject for broadcasting values to subscribers. + */ +class PublishSubject { + private subscribers = new Set>(); + private isClosed = false; + + private readonly observable = new ObservableImpl(subscriber => { + if (this.isClosed) { + subscriber.complete(); + return () => {}; + } + this.subscribers.add(subscriber); + return () => { + this.subscribers.delete(subscriber); + }; + }); + + next(value: T) { + if (this.isClosed) { + throw new Error('PublishSubject is closed'); + } + this.subscribers.forEach(subscriber => subscriber.next(value)); + } + + subscribe(observer: ZenObservable.Observer): ZenObservable.Subscription { + return this.observable.subscribe(observer); + } + + /** + * Creates an Observable that replays buffered values and then subscribes to live updates. + */ + asObservable(replayBuffer: T[] = []): Observable { + return new ObservableImpl(subscriber => { + // Replay buffered values + for (const value of replayBuffer) { + subscriber.next(value); + } + // Subscribe to live updates + return this.subscribe(subscriber); + }); + } +} + +/** + * Base implementation for the ToastApi that forwards toast messages to consumers. + * + * Recent toasts are buffered and replayed to new subscribers to prevent + * missing toasts that were posted before subscription. + * + * @internal + */ +export class ToastApiForwarder implements ToastApiForwarderApi { + private readonly subject = new PublishSubject(); + private readonly recentToasts: ToastApiForwarderMessage[] = []; + private readonly closedKeys = new Set(); + private readonly maxBufferSize = 10; + + post(toast: ToastApiMessage): ToastApiPostResult { + const key = generateToastKey(); + 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: ToastApiForwarderMessage = { + ...toast, + key, + close, + onClose, + }; + + this.recentToasts.push(toastWithKey); + if (this.recentToasts.length > this.maxBufferSize) { + this.recentToasts.shift(); + } + this.subject.next(toastWithKey); + + return { close }; + } + + toast$(): Observable { + // Filter out any toasts that were closed to handle race conditions + const activeToasts = this.recentToasts.filter( + t => !this.closedKeys.has(t.key), + ); + return this.subject.asObservable(activeToasts); + } +} diff --git a/plugins/app/src/apis/index.ts b/plugins/app/src/apis/index.ts new file mode 100644 index 0000000000..dc8189b7ee --- /dev/null +++ b/plugins/app/src/apis/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export { ToastApiForwarder } from './ToastApiForwarder'; +export { toastApiForwarderRef } from './toastApiForwarderRef'; +export type { ToastApiForwarderApi } from './toastApiForwarderRef'; diff --git a/plugins/app/src/apis/toastApiForwarderRef.ts b/plugins/app/src/apis/toastApiForwarderRef.ts new file mode 100644 index 0000000000..2b2ddce0ab --- /dev/null +++ b/plugins/app/src/apis/toastApiForwarderRef.ts @@ -0,0 +1,61 @@ +/* + * 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 { + ToastApi, + ToastApiMessage, + createApiRef, +} from '@backstage/frontend-plugin-api'; +import { Observable } from '@backstage/types'; + +/** + * Internal enriched toast message emitted by the forwarder's observable. + * + * @internal + */ +export type ToastApiForwarderMessage = ToastApiMessage & { + /** Unique key for tracking this toast */ + key: string; + /** Dismiss this toast programmatically */ + close(): void; + /** + * Register a callback that fires when this toast is closed. + * Used by the toast display to sync with the rendering queue. + */ + onClose(callback: () => void): void; +}; + +/** + * Internal extension of the public ToastApi that adds an observable + * for the toast display to subscribe to. This interface is only used + * within the app plugin and can be freely evolved. + * + * @internal + */ +export type ToastApiForwarderApi = ToastApi & { + /** Observe toasts posted by other parts of the application. */ + toast$(): Observable; +}; + +/** + * Internal API ref for the toast forwarder. The public `toastApiRef` + * delegates to this, and the toast display subscribes to it directly. + * + * @internal + */ +export const toastApiForwarderRef = createApiRef({ + id: 'app.toast.internal-forwarder', +}); diff --git a/plugins/app/src/components/Toast/Toast.module.css b/plugins/app/src/components/Toast/Toast.module.css new file mode 100644 index 0000000000..4111ce9c23 --- /dev/null +++ b/plugins/app/src/components/Toast/Toast.module.css @@ -0,0 +1,227 @@ +/* + * 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. + */ + +/* Toast Container - Container for all toasts (bottom center) */ +.container { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 100050; + width: 300px; + outline: none; +} + +@media (min-width: 500px) { + .container { + width: 350px; + bottom: 2rem; + } +} + +/* Individual Toast */ +.toast { + /* CSS Variables */ + --toast-peek: 0.75rem; + --toast-scale: calc(max(0, 1 - (var(--toast-index) * 0.05))); + + /* Layout */ + position: absolute; + bottom: 0; + left: 0; + width: 100%; + margin: 0 auto; + box-sizing: border-box; + + outline: none; + cursor: default; + user-select: none; + /* All toasts need pointer-events to trigger hover expansion */ + pointer-events: auto; + + /* Stacking */ + z-index: calc(1000 - var(--toast-index)); + transform-origin: bottom center; +} + +.surface { + width: 100%; + box-sizing: border-box; + display: flex; + align-items: flex-start; + padding: var(--bui-space-4); + gap: var(--bui-space-3); + border-radius: var(--bui-radius-3); + font-size: var(--bui-font-size-3); + background-color: var(--bui-bg-app); + border: 1px solid var(--bui-border-1); + color: var(--bui-fg-primary); + box-shadow: var(--bui-shadow); +} + +.toast:focus-visible { + outline: 2px solid var(--bui-ring); + outline-offset: 2px; +} + +/* Extend hover area above each toast to fill gaps when expanded */ +/* This prevents the stack from collapsing when hovering between toasts */ +.toast::before { + content: ''; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + /* Height matches the expanded gap to ensure continuous hover */ + height: 12px; + pointer-events: auto; +} + +/* Status variants - color icon and title */ +.toast[data-status='neutral'] .title { + color: var(--bui-fg-primary); +} + +.toast[data-status='info'] .icon, +.toast[data-status='info'] .title { + color: var(--bui-fg-info); +} + +.toast[data-status='success'] .icon, +.toast[data-status='success'] .title { + color: var(--bui-fg-success); +} + +.toast[data-status='warning'] .icon, +.toast[data-status='warning'] .title { + color: var(--bui-fg-warning); +} + +.toast[data-status='danger'] .icon, +.toast[data-status='danger'] .title { + color: var(--bui-fg-danger); +} + +.wrapper { + display: flex; + align-items: flex-start; + gap: var(--bui-space-3); + flex: 1; + min-width: 0; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--bui-space-1); +} + +/* Icon */ +.icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + margin-top: var(--bui-space-0_5); +} + +.icon svg { + width: 1rem; + height: 1rem; +} + +/* Title */ +.title { + font-weight: var(--bui-font-weight-bold); + font-size: var(--bui-font-size-3); + word-wrap: break-word; + margin-top: var(--bui-space-0_5); +} + +/* Description */ +.description { + color: var(--bui-fg-secondary); + font-size: var(--bui-font-size-3); + opacity: 0.9; + word-wrap: break-word; +} + +/* Links */ +.links { + display: flex; + flex-wrap: wrap; + gap: var(--bui-space-3); + margin-top: var(--bui-space-1); +} + +.links a { + font-family: var(--bui-font-regular); + padding: 0; + margin: 0; + cursor: pointer; + display: inline-block; + + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-thickness: min(2px, max(1px, 0.05em)); + text-underline-offset: calc(0.025em + 2px); + text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); +} + +.links a:hover { + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-thickness: min(2px, max(1px, 0.05em)); + text-underline-offset: calc(0.025em + 2px); + text-decoration-color: color-mix(in srgb, currentColor 30%, transparent); +} + +/* Close Button */ +.closeButton { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin: -0.25rem -0.25rem 0 0; + padding: 0; + border: none; + border-radius: var(--bui-radius-2); + background: transparent; + color: inherit; + cursor: pointer; + transition: background-color 0.15s ease; + color: var(--bui-fg-primary); +} + +.closeButton svg { + width: 1rem; + height: 1rem; +} + +.closeButton:hover { + background-color: var(--bui-bg-neutral-1-hover); +} + +.closeButton:focus-visible { + outline: 2px solid var(--bui-ring); + outline-offset: 1px; +} + +.closeButton:active { + background-color: var(--bui-bg-neutral-1-pressed); +} diff --git a/plugins/app/src/components/Toast/Toast.stories.tsx b/plugins/app/src/components/Toast/Toast.stories.tsx new file mode 100644 index 0000000000..f008bc2550 --- /dev/null +++ b/plugins/app/src/components/Toast/Toast.stories.tsx @@ -0,0 +1,669 @@ +/* + * 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 { useState } from 'react'; +/* eslint-disable @backstage/no-relative-monorepo-imports */ +import preview from '../../../../../.storybook/preview'; +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 { ToastApiMessageContent } from './types'; +import { MemoryRouter } from 'react-router-dom'; + +const toastQueue = new ToastQueue({ + maxVisibleToasts: 4, +}); + +const meta = preview.meta({ + title: 'Plugins/App/Toast', + component: ToastContainer, + parameters: { + layout: 'centered', + }, +}); + +const randomToasts = [ + // Title only - short + { title: 'Saved', status: 'success' as const }, + { title: 'Error', status: 'danger' as const }, + { title: 'New notification', status: 'info' as const }, + { title: 'Warning', status: 'warning' as const }, + { title: 'Task completed', status: 'neutral' as const }, + // Title only - medium + { title: 'Changes saved successfully', status: 'success' as const }, + { title: 'Connection restored', status: 'info' as const }, + { title: 'Action could not be completed', status: 'danger' as const }, + { title: 'Background sync in progress', status: 'neutral' as const }, + // Title + short description + { + title: 'Files uploaded', + description: '3 files uploaded.', + status: 'success' as const, + }, + { + title: 'Update available', + description: 'Version 2.0 is ready.', + status: 'info' as const, + }, + { + title: 'Request failed', + description: 'Please try again.', + status: 'danger' as const, + }, + { + title: 'Storage warning', + description: '90% used.', + status: 'warning' as const, + }, + { + title: 'Clipboard updated', + description: 'Text copied.', + status: 'neutral' as const, + }, + // Title + medium description + { + title: 'Deployment complete', + description: + 'Your application has been deployed to production successfully.', + status: 'success' as const, + }, + { + title: 'Session expiring', + description: + 'Your session will expire in 5 minutes. Please save your work.', + status: 'warning' as const, + }, + { + title: 'Permission denied', + description: 'You do not have access to perform this action.', + status: 'danger' as const, + }, + { + title: 'Preferences updated', + description: 'Your display settings have been saved to your profile.', + status: 'neutral' as const, + }, + // Title + long description + { + title: 'Sync completed', + description: + 'All your files have been synchronized across devices. This includes 47 documents, 23 images, and 12 configuration files that were updated in the last hour.', + status: 'success' as const, + }, + { + title: 'Rate limit exceeded', + description: + 'You have exceeded the maximum number of API requests allowed. Please wait a few minutes before trying again or upgrade your plan for higher limits.', + status: 'warning' as const, + }, + { + title: 'Critical error', + description: + 'The server encountered an unexpected error while processing your request. Our team has been notified and is investigating the issue. Please try again later.', + status: 'danger' as const, + }, + // Long title only + { + title: 'Your subscription has been renewed successfully for another year', + status: 'success' as const, + }, + { + title: 'Multiple users are currently editing this document', + status: 'info' as const, + }, + { + title: 'Your workspace has been switched to the new project', + status: 'neutral' as const, + }, +]; + +export const Default = meta.story({ + render: () => ( + <> + + + + + + + ), +}); + +export const StatusVariants = meta.story({ + render: () => ( + <> + + + + + + + + + + ), +}); + +export const WithoutDescription = meta.story({ + render: () => ( + <> + + + + + + + ), +}); + +export const NeutralStatus = meta.story({ + render: () => ( + <> + + + + + + + ), +}); + +export const WithLinks = meta.story({ + render: () => ( + + + + + + + + + ), +}); + +export const AutoDismiss = meta.story({ + render: () => ( + <> + + + + + + + + ), +}); + +function ProgrammaticDismissStory() { + const [toastKey, setToastKey] = useState(null); + + return ( + <> + + + + ); +} + +export const ProgrammaticDismiss = meta.story({ + render: () => , +}); + +export const QueueManagement = meta.story({ + render: () => ( + <> + + + + + + + + ), +}); + +export const AlertApiIntegration = meta.story({ + name: 'AlertApi Integration', + render: () => { + // This story demonstrates how alerts posted via the AlertApi + // would be displayed using the ToastDisplay component. + // The ToastDisplay bridges AlertApi.post() calls to the toast queue. + + return ( + <> + + + + + AlertApi Severity Mapping: + + success → success (green) + info → info (blue) + warning → warning (orange) + error → danger (red) + + (ToastApi also supports neutral - no icon, primary color) + + + + + + + + + + + + ); + }, +}); + +/** + * This story tests the real AlertApi integration. + * It uses the alertApi from the TestApiProvider (set up in storybook preview) + * and shows how alerts posted via alertApi.post() appear. + * + * Note: The storybook preview.tsx renders AlertDisplay from core-components, + * which still uses Material UI. To test the new ToastDisplay, run the actual + * Backstage app where the app plugin's elements.tsx is used. + */ +function RealAlertApiStory() { + // eslint-disable-next-line @backstage/no-relative-monorepo-imports + const { useApi, alertApiRef } = require('@backstage/core-plugin-api'); + const alertApi = useApi(alertApiRef); + + return ( + + + + Real AlertApi Test + + + These buttons call alertApi.post() directly. Alerts appear in the OLD + AlertDisplay (top of screen) because Storybook uses core-components. + + + To test the NEW ToastDisplay, run: yarn start + + + + + + + + + + + + + + ); +} + +export const RealAlertApi = meta.story({ + name: 'Real AlertApi Test', + render: () => , +}); + +export default meta; diff --git a/plugins/app/src/components/Toast/Toast.tsx b/plugins/app/src/components/Toast/Toast.tsx new file mode 100644 index 0000000000..3f81629c38 --- /dev/null +++ b/plugins/app/src/components/Toast/Toast.tsx @@ -0,0 +1,270 @@ +/* + * 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 { forwardRef, Ref, useRef, useLayoutEffect, useState } from 'react'; +import { useToast } from '@react-aria/toast'; +import { useButton } from '@react-aria/button'; +import { motion } from 'motion/react'; +import { Box } from '@backstage/ui'; +import { + RiInformationLine, + RiCheckLine, + RiErrorWarningLine, + RiAlertLine, + RiCloseLine, +} from '@remixicon/react'; +import type { ToastApiMessageProps } from './types'; +import styles from './Toast.module.css'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { BgReset } from '../../../../../packages/ui/src/hooks/useBg'; + +// Track which toasts are being manually closed (vs auto-timeout) +// This allows different exit animations for each case +const manuallyClosingToasts = new Set(); + +/** + * A Toast displays a brief, temporary notification of actions, errors, or other events in an application. + * + * @remarks + * The Toast component is used internally by ToastContainer and managed by a ToastQueue. + * It supports multiple status variants (neutral, info, success, warning, danger) and can display + * a title and description. Toasts can be dismissed manually or automatically. + * + * @internal + */ +export const Toast = forwardRef( + (props: ToastApiMessageProps, ref: Ref) => { + const { + toast, + state, + index = 0, + isExpanded = false, + onClose, + status, + expandedY: expandedYProp = 0, + collapsedHeight, + naturalHeight, + onHeightChange, + } = props; + + // Use internal ref if none provided + const internalRef = useRef(null); + const toastRef = (ref as React.RefObject) || internalRef; + + // Get ARIA props from useToast hook + const { toastProps, titleProps, closeButtonProps } = useToast( + { toast }, + state, + toastRef, + ); + + // Close button ref for useButton hook + const closeButtonRef = useRef(null); + + // Extract only ARIA and accessibility props from toastProps to avoid + // conflicts with motion.div's event handler types (motion has its own drag API) + const ariaProps = { + role: toastProps.role, + tabIndex: toastProps.tabIndex, + 'aria-label': toastProps['aria-label'], + 'aria-labelledby': toastProps['aria-labelledby'], + 'aria-describedby': toastProps['aria-describedby'], + 'aria-posinset': toastProps['aria-posinset'], + 'aria-setsize': toastProps['aria-setsize'], + }; + + // Track whether we've measured this toast's natural height + const [hasMeasured, setHasMeasured] = useState(false); + // Store the measured natural height locally to avoid re-measurement issues + const naturalHeightRef = useRef(null); + + // Measure this toast's natural height on mount (before paint) + // Using useLayoutEffect ensures we measure before the browser paints + useLayoutEffect(() => { + if (!onHeightChange) return; + if (naturalHeightRef.current) return; // Already measured + + const element = toastRef.current; + if (!element) return; + + // Measure immediately - useLayoutEffect runs before paint + const height = element.getBoundingClientRect().height; + if (height > 0) { + naturalHeightRef.current = height; + onHeightChange(toast.key, height); + setHasMeasured(true); + } + }, [toast.key, onHeightChange, toastRef]); + + // Close button handler + const handleClose = () => { + // Mark this toast as manually closed for exit animation + manuallyClosingToasts.add(toast.key); + onClose?.(); + state.close(toast.key); + }; + + // Get button props from useButton hook + const { buttonProps } = useButton( + { + 'aria-label': closeButtonProps['aria-label'], + onPress: handleClose, + }, + closeButtonRef, + ); + + // Get content from toast + const content = toast.content; + const finalStatus = status || content.status || 'info'; + + // Determine which icon to render based on status + const getStatusIcon = () => { + switch (finalStatus) { + case 'neutral': + // Neutral status has no icon + return null; + case 'success': + return