diff --git a/.changeset/chilled-ladybugs-cough.md b/.changeset/chilled-ladybugs-cough.md
new file mode 100644
index 0000000000..95a86e97c6
--- /dev/null
+++ b/.changeset/chilled-ladybugs-cough.md
@@ -0,0 +1,20 @@
+---
+'@backstage/core-plugin-api': minor
+---
+
+Added a new `display` property to the `AlertMessage` which can accept the values `permanent` or `transient`.
+
+Here's a rough example of how to trigger an alert using the new `display` property:
+
+```ts
+import { alertApiRef, useApi } from '@backstage/core-plugin-api';
+
+const ExampleTransient = () => {
+ const alertApi = useApi(alertApiRef);
+ alertApi.post({
+ message: 'Example of Transient Alert',
+ severity: 'success',
+ display: 'transient',
+ });
+};
+```
diff --git a/.changeset/strong-rice-warn.md b/.changeset/strong-rice-warn.md
new file mode 100644
index 0000000000..da157ddaaf
--- /dev/null
+++ b/.changeset/strong-rice-warn.md
@@ -0,0 +1,19 @@
+---
+'@backstage/core-components': patch
+---
+
+Added an option to allow the `AlertMessage` to be self-closing. This is done with a new `display` property that is set to `transient` on the `AlertMessage` when triggering a message to the `AlertApi`. The length of time that these transient messages stay open for can be set using the `transientTimeoutMs` prop on the `AlertDisplay` in the `App.tsx`. Here is an example:
+
+```diff
+ const App = () => (
+
++
+
+
+ {routes}
+
+
+ );
+```
+
+The above example will set the transient timeout to 2500ms from the default of 5000ms
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index fd73e99bcb..05ef61c11a 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -280,7 +280,7 @@ const routes = (
const App = () => (
-
+
{routes}
diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md
index 9ed964db44..d8ea9d2bbe 100644
--- a/packages/core-components/api-report.md
+++ b/packages/core-components/api-report.md
@@ -55,12 +55,13 @@ import { WithStyles } from '@material-ui/core/styles';
// @public
export function AlertDisplay(props: AlertDisplayProps): JSX.Element | null;
-// @public (undocumented)
+// @public
export type AlertDisplayProps = {
anchorOrigin?: {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right';
};
+ transientTimeoutMs?: number;
};
// @public
diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx
index c366082b46..b75f773f90 100644
--- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx
+++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.test.tsx
@@ -14,13 +14,15 @@
* limitations under the License.
*/
-import React from 'react';
-import { screen } from '@testing-library/react';
-import { AlertDisplay } from './AlertDisplay';
-import { alertApiRef } from '@backstage/core-plugin-api';
+import { AlertMessage, alertApiRef } from '@backstage/core-plugin-api';
+import { TestApiProvider, renderInTestApp } from '@backstage/test-utils';
+import { fireEvent, screen } from '@testing-library/react';
+
import { AlertApiForwarder } from '@backstage/core-app-api';
+import { AlertDisplay } from './AlertDisplay';
import Observable from 'zen-observable';
-import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
+import React from 'react';
+import { act } from '@testing-library/react';
const TEST_MESSAGE = 'TEST_MESSAGE';
@@ -93,4 +95,263 @@ describe('', () => {
expect(screen.getByText('(2 older messages)')).toBeInTheDocument();
});
});
+
+ describe('with transient message', () => {
+ const apis = [
+ [
+ alertApiRef,
+ {
+ post() {},
+ alert$() {
+ return Observable.of({
+ message: 'transient message one',
+ display: 'transient',
+ });
+ },
+ },
+ ] as const,
+ ] as const;
+
+ it('renders message and then removes it', async () => {
+ jest.useFakeTimers();
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(5005);
+ });
+ expect(queryByText('transient message one')).not.toBeInTheDocument();
+ jest.useRealTimers();
+ });
+
+ it('respects transientTimeoutMs prop', async () => {
+ jest.useFakeTimers();
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(2505);
+ });
+ expect(queryByText('transient message one')).not.toBeInTheDocument();
+ jest.useRealTimers();
+ });
+ });
+
+ describe('with multiple transient messages', () => {
+ const alert$ = () =>
+ new Observable(subscriber => {
+ subscriber.next({
+ message: 'transient message one',
+ display: 'transient',
+ });
+ setTimeout(() => {
+ subscriber.next({
+ message: 'transient message two',
+ display: 'transient',
+ });
+ }, 1000);
+ setTimeout(() => {
+ subscriber.next({
+ message: 'transient message three',
+ display: 'transient',
+ });
+ }, 2000);
+ });
+
+ const apis = [
+ [
+ alertApiRef,
+ {
+ post() {},
+ alert$,
+ },
+ ] as const,
+ ] as const;
+
+ it('renders message and then removes it', async () => {
+ jest.useFakeTimers();
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+ // Validate adding messages
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ expect(queryByText('(2 older messages)')).toBeInTheDocument();
+
+ // Validate removing messages
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(queryByText('transient message two')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(queryByText('transient message three')).toBeInTheDocument();
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(queryByText('transient message')).not.toBeInTheDocument();
+ jest.useRealTimers();
+ });
+
+ it('renders 3 different messages with overlapping timeout', async () => {
+ jest.useFakeTimers();
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ // 3 messages stacked with 1.5s each, display times: 0-1.5, 1.5-3, 3-4.5
+
+ // 0s in, only message 1
+ expect(queryByText('transient message one')).toBeInTheDocument();
+
+ // 1s in, message 1 still shown, message 2 added in background
+ act(() => jest.advanceTimersByTime(1000));
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+
+ // 2s in, message 2 now shown, message 3 added
+ act(() => jest.advanceTimersByTime(1000));
+ expect(queryByText('transient message two')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+
+ // 3.5s in, message 3 now shown
+ act(() => jest.advanceTimersByTime(1500));
+ expect(queryByText('transient message three')).toBeInTheDocument();
+
+ // 5s in, all messages gone
+ act(() => jest.advanceTimersByTime(1500));
+ expect(queryByText('transient message three')).not.toBeInTheDocument();
+ jest.useRealTimers();
+ });
+
+ it('renders 3 different messages with overlapping timeout and manual removal', async () => {
+ jest.useFakeTimers();
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+
+ // 3 messages stacked with 1.5s each, display times: 0-1.5, 1.5-3, 3-4.5
+
+ // 0s in, only message 1
+ expect(queryByText('transient message one')).toBeInTheDocument();
+
+ // 1s in, message 1 still shown, message 2 added in background
+ act(() => jest.advanceTimersByTime(1000));
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+
+ // manually remove message 1
+ fireEvent.click(screen.getByTestId('error-button-close'));
+ expect(screen.getByText('transient message two')).toBeInTheDocument();
+
+ // 2s in, message 2 now shown, message 3 added
+ act(() => jest.advanceTimersByTime(1000));
+ expect(queryByText('transient message two')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+
+ // 3s in, message 3 now shown
+ act(() => jest.advanceTimersByTime(1500));
+ expect(queryByText('transient message three')).toBeInTheDocument();
+
+ // 4s in, all messages gone
+ act(() => jest.advanceTimersByTime(1500));
+ expect(queryByText('transient message three')).not.toBeInTheDocument();
+ jest.useRealTimers();
+ });
+ });
+
+ describe('with multiple messages of mixed display', () => {
+ const alert$ = () =>
+ new Observable(subscriber => {
+ subscriber.next({
+ message: 'transient message one',
+ display: 'transient',
+ });
+ setTimeout(() => {
+ subscriber.next({
+ message: 'permanent message',
+ display: 'permanent',
+ });
+ }, 1000);
+ setTimeout(() => {
+ subscriber.next({
+ message: 'transient message three',
+ display: 'transient',
+ });
+ }, 2000);
+ });
+
+ const apis = [
+ [
+ alertApiRef,
+ {
+ post() {},
+ alert$,
+ },
+ ] as const,
+ ] as const;
+
+ it('renders message and then removes it', async () => {
+ jest.useFakeTimers();
+ const { queryByText } = await renderInTestApp(
+
+
+ ,
+ );
+ // Validate adding messages
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+ expect(queryByText('transient message one')).toBeInTheDocument();
+ expect(queryByText('(2 older messages)')).toBeInTheDocument();
+
+ // Validate removing messages
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(queryByText('permanent message')).toBeInTheDocument();
+ expect(queryByText('(1 older message)')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId('error-button-close'));
+ expect(queryByText('transient message three')).toBeInTheDocument();
+
+ act(() => {
+ jest.advanceTimersByTime(5000);
+ });
+ expect(queryByText('transient message')).not.toBeInTheDocument();
+ jest.useRealTimers();
+ });
+ });
});
diff --git a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx
index 83c11647bb..b1c8273749 100644
--- a/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx
+++ b/packages/core-components/src/components/AlertDisplay/AlertDisplay.tsx
@@ -22,14 +22,17 @@ import { Alert } from '@material-ui/lab';
import pluralize from 'pluralize';
import React, { useEffect, useState } from 'react';
-// TODO: improve on this and promote to a shared component for use by all apps.
-
-/** @public */
+/**
+ * Properties for {@link AlertDisplay}
+ *
+ * @public
+ */
export type AlertDisplayProps = {
anchorOrigin?: {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'center' | 'right';
};
+ transientTimeoutMs?: number;
};
/**
@@ -39,12 +42,33 @@ export type AlertDisplayProps = {
* @remarks
*
* Shown as SnackBar at the center top of the page by default. Configurable with props.
+ *
+ * @param anchorOrigin - The `vertical` property will set the vertical orientation of where the AlertDisplay will be located
+ * and the `horizontal` property will set the horizontal orientation of where the AlertDisplay will be located
+ * @param transientTimeoutMs - Number of milliseconds a transient alert will stay open for. Default value is 5000
+ *
+ * @example
+ * Here's some examples:
+ * ```
+ * // This example shows the default usage, the SnackBar will show up at the top in the center and any transient messages will stay open for 5000ms
+ *
+ *
+ * // With this example the SnackBar will show up in the bottom right hand corner and any transient messages will stay open for 2500ms
+ *
+ *
+ * // If you want to just set the time a transientTimeoutMs, you can do that like this:
+ *
+ * ```
*/
export function AlertDisplay(props: AlertDisplayProps) {
const [messages, setMessages] = useState>([]);
const alertApi = useApi(alertApiRef);
- const { anchorOrigin = { vertical: 'top', horizontal: 'center' } } = props;
+ const {
+ anchorOrigin = { vertical: 'top', horizontal: 'center' },
+ transientTimeoutMs,
+ } = props;
+ const timeoutMs = transientTimeoutMs ?? 5000;
useEffect(() => {
const subscription = alertApi
@@ -56,12 +80,25 @@ export function AlertDisplay(props: AlertDisplayProps) {
};
}, [alertApi]);
+ const [firstMessage] = messages;
+
+ useEffect(() => {
+ if (firstMessage && firstMessage.display === 'transient') {
+ const timeout = setTimeout(() => {
+ setMessages(msgs => {
+ const newMsgs = msgs.filter(msg => msg !== firstMessage);
+ return newMsgs.length === msgs.length ? msgs : newMsgs;
+ });
+ }, timeoutMs);
+ return () => clearTimeout(timeout);
+ }
+ return undefined;
+ }, [firstMessage, timeoutMs]);
+
if (messages.length === 0) {
return null;
}
- const [firstMessage] = messages;
-
const handleClose = () => {
setMessages(msgs => msgs.filter(msg => msg !== firstMessage));
};
diff --git a/packages/core-plugin-api/api-report.md b/packages/core-plugin-api/api-report.md
index cebf09e908..dff4f7e5a6 100644
--- a/packages/core-plugin-api/api-report.md
+++ b/packages/core-plugin-api/api-report.md
@@ -29,6 +29,7 @@ export const alertApiRef: ApiRef;
export type AlertMessage = {
message: string;
severity?: 'success' | 'info' | 'warning' | 'error';
+ display?: 'permanent' | 'transient';
};
// @public
diff --git a/packages/core-plugin-api/src/apis/definitions/AlertApi.ts b/packages/core-plugin-api/src/apis/definitions/AlertApi.ts
index 0c84f7c975..afdcb6a617 100644
--- a/packages/core-plugin-api/src/apis/definitions/AlertApi.ts
+++ b/packages/core-plugin-api/src/apis/definitions/AlertApi.ts
@@ -26,6 +26,7 @@ export type AlertMessage = {
message: string;
// Severity will default to success since that is what material ui defaults the value to.
severity?: 'success' | 'info' | 'warning' | 'error';
+ display?: 'permanent' | 'transient';
};
/**