Merge pull request #13513 from awanlin/topic/transient-alert
Added transient AlertMessage
This commit is contained in:
@@ -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',
|
||||
});
|
||||
};
|
||||
```
|
||||
@@ -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
|
||||
@@ -280,7 +280,7 @@ const routes = (
|
||||
|
||||
const App = () => (
|
||||
<AppProvider>
|
||||
<AlertDisplay />
|
||||
<AlertDisplay transientTimeoutMs={2500} />
|
||||
<OAuthRequestDialog />
|
||||
<AppRouter>
|
||||
<Root>{routes}</Root>
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user