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'; }; /**