Merge pull request #13513 from awanlin/topic/transient-alert

Added transient AlertMessage
This commit is contained in:
Patrik Oldsberg
2022-12-06 14:05:49 +01:00
committed by GitHub
8 changed files with 353 additions and 13 deletions
+20
View File
@@ -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',
});
};
```
+19
View File
@@ -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 = () => (
<AppProvider>
+ <AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
</AppRouter>
</AppProvider>
);
```
The above example will set the transient timeout to 2500ms from the default of 5000ms
+1 -1
View File
@@ -280,7 +280,7 @@ const routes = (
const App = () => (
<AppProvider>
<AlertDisplay />
<AlertDisplay transientTimeoutMs={2500} />
<OAuthRequestDialog />
<AppRouter>
<Root>{routes}</Root>
+2 -1
View File
@@ -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
@@ -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('<AlertDisplay />', () => {
expect(screen.getByText('(2 older messages)')).toBeInTheDocument();
});
});
describe('with transient message', () => {
const apis = [
[
alertApiRef,
{
post() {},
alert$() {
return Observable.of<AlertMessage>({
message: 'transient message one',
display: 'transient',
});
},
},
] as const,
] as const;
it('renders message and then removes it', async () => {
jest.useFakeTimers();
const { queryByText } = await renderInTestApp(
<TestApiProvider apis={apis}>
<AlertDisplay />
</TestApiProvider>,
);
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(
<TestApiProvider apis={apis}>
<AlertDisplay transientTimeoutMs={2500} />
</TestApiProvider>,
);
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<AlertMessage>(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(
<TestApiProvider apis={apis}>
<AlertDisplay />
</TestApiProvider>,
);
// 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(
<TestApiProvider apis={apis}>
<AlertDisplay transientTimeoutMs={1500} />
</TestApiProvider>,
);
// 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(
<TestApiProvider apis={apis}>
<AlertDisplay transientTimeoutMs={1500} />
</TestApiProvider>,
);
// 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<AlertMessage>(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(
<TestApiProvider apis={apis}>
<AlertDisplay />
</TestApiProvider>,
);
// 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();
});
});
});
@@ -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
* <AlertDisplay />
*
* // With this example the SnackBar will show up in the bottom right hand corner and any transient messages will stay open for 2500ms
* <AlertDisplay transientTimeoutMs={2500} anchorOrigin={{vertical: 'bottom', horizontal: 'right'}}/>
*
* // If you want to just set the time a transientTimeoutMs, you can do that like this:
* <AlertDisplay transientTimeoutMs={10000} />
* ```
*/
export function AlertDisplay(props: AlertDisplayProps) {
const [messages, setMessages] = useState<Array<AlertMessage>>([]);
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));
};
+1
View File
@@ -29,6 +29,7 @@ export const alertApiRef: ApiRef<AlertApi>;
export type AlertMessage = {
message: string;
severity?: 'success' | 'info' | 'warning' | 'error';
display?: 'permanent' | 'transient';
};
// @public
@@ -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';
};
/**