diff --git a/pages/alert/runtime-content.page.tsx b/pages/alert/runtime-content.page.tsx
new file mode 100644
index 00000000000..6d7760dd288
--- /dev/null
+++ b/pages/alert/runtime-content.page.tsx
@@ -0,0 +1,76 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import React, { useState } from 'react';
+import { render, unmountComponentAtNode } from 'react-dom';
+
+import { Checkbox, Spinner } from '~components';
+import Alert, { AlertProps } from '~components/alert';
+import Button from '~components/button';
+import awsuiPlugins from '~components/internal/plugins';
+
+import createPermutations from '../utils/permutations';
+import PermutationsView from '../utils/permutations-view';
+import ScreenshotArea from '../utils/screenshot-area';
+
+awsuiPlugins.alertContent.registerContentReplacer({
+ id: 'awsui/alert-test-action',
+ runReplacer(context, registerReplacement) {
+ console.log('replace');
+ const appendedContent = document.createElement('div');
+ if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) {
+ render(, appendedContent);
+ context.contentRef.current.appendChild(appendedContent);
+ setTimeout(() => {
+ console.log('unmountManual');
+ unmountComponentAtNode(appendedContent);
+ appendedContent.parentNode?.removeChild(appendedContent);
+ render(
Access denied message!
, context.replacedContentRef.current);
+ registerReplacement({ hasHeader: false, hasContent: true }, () => {
+ console.log('unmount1');
+ unmountComponentAtNode(context.replacedContentRef.current!);
+ });
+ }, 2000);
+ }
+ registerReplacement({ hasHeader: null, hasContent: null }, () => {
+ console.log('unmount2');
+ unmountComponentAtNode(appendedContent);
+ appendedContent.parentNode?.removeChild(appendedContent);
+ });
+ },
+});
+
+/* eslint-disable react/jsx-key */
+const permutations = createPermutations([
+ {
+ header: [null, 'Alert'],
+ children: ['Content', 'There was an error: Access denied because of XYZ'],
+ type: ['success', 'error'],
+ action: [null, ],
+ },
+]);
+/* eslint-enable react/jsx-key */
+
+export default function () {
+ const [loading, setLoading] = useState(true);
+ return (
+ <>
+ Alert runtime actions
+ setLoading(e.detail.checked)} checked={loading}>
+ Loading
+
+
+ (
+ <>
+
+
+ {loading ? 'Loading' : permutation.children}
+
+ >
+ )}
+ />
+
+ >
+ );
+}
diff --git a/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap b/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap
index b82a00071b7..8a5ee11fa6a 100644
--- a/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap
+++ b/src/__tests__/__snapshots__/test-utils-selectors.test.tsx.snap
@@ -6,8 +6,10 @@ Object {
"awsui_action-button_mx3cw",
"awsui_action-slot_mx3cw",
"awsui_alert_mx3cw",
+ "awsui_content-replacement_mx3cw",
"awsui_content_mx3cw",
"awsui_dismiss-button_mx3cw",
+ "awsui_header-replacement_mx3cw",
"awsui_header_mx3cw",
"awsui_root_mx3cw",
],
@@ -257,7 +259,9 @@ Object {
"awsui_action-button_1q84n",
"awsui_action-slot_1q84n",
"awsui_dismiss-button_1q84n",
+ "awsui_flash-content-replacement_1q84n",
"awsui_flash-content_1q84n",
+ "awsui_flash-header-replacement_1q84n",
"awsui_flash-header_1q84n",
"awsui_flash-list-item_1q84n",
"awsui_flash-type-error_1q84n",
diff --git a/src/alert/__tests__/alert.test.tsx b/src/alert/__tests__/alert.test.tsx
index 15d0ab150f6..9b9fbae4b00 100644
--- a/src/alert/__tests__/alert.test.tsx
+++ b/src/alert/__tests__/alert.test.tsx
@@ -11,11 +11,25 @@ import createWrapper from '../../../lib/components/test-utils/dom';
import styles from '../../../lib/components/alert/styles.css.js';
+let useVisualRefresh = false;
+jest.mock('../../../lib/components/internal/hooks/use-visual-mode', () => {
+ const originalVisualModeModule = jest.requireActual('../../../lib/components/internal/hooks/use-visual-mode');
+ return {
+ __esModule: true,
+ ...originalVisualModeModule,
+ useVisualRefresh: (...args: any) => useVisualRefresh || originalVisualModeModule.useVisualRefresh(...args),
+ };
+});
+
function renderAlert(props: AlertProps = {}) {
const { container } = render();
return { wrapper: createWrapper(container).findAlert()!, container };
}
+beforeEach(() => {
+ useVisualRefresh = false;
+});
+
describe('Alert Component', () => {
describe('structure', () => {
it('has no header container when no header is set', () => {
@@ -153,4 +167,10 @@ describe('Alert Component', () => {
expect(wrapper.getElement()).toHaveAttribute(DATA_ATTR_ANALYTICS_ALERT, 'success');
});
});
+
+ test('visual refresh rendering for coverage', () => {
+ useVisualRefresh = true;
+ const { wrapper } = renderAlert({ header: 'Hello' });
+ expect(wrapper.findHeader()!.getElement()).toHaveTextContent('Hello');
+ });
});
diff --git a/src/alert/__tests__/runtime-content.test.tsx b/src/alert/__tests__/runtime-content.test.tsx
new file mode 100644
index 00000000000..29002b5c454
--- /dev/null
+++ b/src/alert/__tests__/runtime-content.test.tsx
@@ -0,0 +1,270 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+import { act, render } from '@testing-library/react';
+
+import Alert from '../../../lib/components/alert';
+import Button from '../../../lib/components/button';
+import awsuiPlugins from '../../../lib/components/internal/plugins';
+import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api';
+import { AlertFlashContentConfig } from '../../../lib/components/internal/plugins/controllers/alert-flash-content';
+import { AlertWrapper } from '../../../lib/components/test-utils/dom';
+
+import stylesCss from '../../../lib/components/alert/styles.css.js';
+
+const pause = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout));
+
+const expectAlertContent = (
+ wrapper: AlertWrapper,
+ {
+ header,
+ headerReplaced,
+ content,
+ contentReplaced,
+ }: {
+ header?: string | false;
+ headerReplaced?: boolean;
+ content?: string | false;
+ contentReplaced?: boolean;
+ }
+) => {
+ if (header) {
+ if (headerReplaced) {
+ expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementHeader()?.getElement().textContent).toBe(header);
+ } else {
+ expect(wrapper.findReplacementHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findHeader()?.getElement().textContent).toBe(header);
+ }
+ } else if (header === false) {
+ expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ }
+ if (content) {
+ if (contentReplaced) {
+ expect(wrapper.findContent().getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementContent().getElement().textContent).toBe(content);
+ } else {
+ expect(wrapper.findReplacementContent().getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findContent().getElement().textContent).toBe(content);
+ }
+ } else if (content === false) {
+ expect(wrapper.findContent().getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementContent().getElement()).toHaveClass(stylesCss.hidden);
+ }
+};
+
+const defaultContent: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer(context, registerReplacement) {
+ context.replacedHeaderRef.current!.append('New header');
+ context.replacedContentRef.current!.append('New content');
+ registerReplacement({ hasHeader: true, hasContent: true }, () => {});
+ },
+};
+
+function delay(advanceBy = 1) {
+ const promise = act(() => new Promise(resolve => setTimeout(resolve)));
+ jest.advanceTimersByTime(advanceBy);
+ return promise;
+}
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+afterEach(() => {
+ awsuiPluginsInternal.alertContent.clearRegisteredReplacer();
+ jest.useRealTimers();
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+});
+
+test('renders runtime content initially', async () => {
+ awsuiPlugins.alertContent.registerContentReplacer(defaultContent);
+ const { container } = render(Alert content);
+ const alertWrapper = new AlertWrapper(container);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ content: 'New content',
+ contentReplaced: true,
+ });
+});
+
+test('renders runtime content when asynchronously registered', async () => {
+ const { container } = render(Alert content);
+ const alertWrapper = new AlertWrapper(container);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ content: 'Alert content',
+ contentReplaced: false,
+ });
+ awsuiPlugins.alertContent.registerContentReplacer(defaultContent);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ content: 'New content',
+ contentReplaced: true,
+ });
+});
+
+describe.each([true, false])('existing header:%p', existingHeader => {
+ test('renders runtime header initially', async () => {
+ awsuiPlugins.alertContent.registerContentReplacer(defaultContent);
+ const { container } = render(Alert content);
+ const alertWrapper = new AlertWrapper(container);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ header: 'New header',
+ headerReplaced: true,
+ });
+ });
+
+ test('renders runtime header when asynchronously registered', async () => {
+ const { container } = render(Alert content);
+ const alertWrapper = new AlertWrapper(container);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ header: existingHeader ? 'Header content' : undefined,
+ headerReplaced: false,
+ });
+ awsuiPlugins.alertContent.registerContentReplacer(defaultContent);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ header: 'New header',
+ headerReplaced: true,
+ });
+ });
+});
+
+test('removes header styling if runtime header is explicitly empty', async () => {
+ const plugin: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer(context, registerReplacement) {
+ registerReplacement({ hasHeader: false, hasContent: true }, () => {});
+ },
+ };
+ awsuiPlugins.alertContent.registerContentReplacer(plugin);
+ const { container } = render();
+ const alertWrapper = new AlertWrapper(container);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ header: false,
+ headerReplaced: true,
+ });
+});
+
+describe('runReplacer arguments', () => {
+ const runReplacer = jest.fn();
+ beforeEach(() => {
+ const plugin: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer,
+ };
+ awsuiPlugins.alertContent.registerContentReplacer(plugin);
+ });
+ test('refs', async () => {
+ render(
+ Action button}>
+ Alert content
+
+ );
+ await delay();
+ expect(runReplacer.mock.lastCall[0].headerRef.current).toHaveTextContent('Alert header');
+ expect(runReplacer.mock.lastCall[0].replacedHeaderRef.current).toBeEmptyDOMElement();
+ expect(runReplacer.mock.lastCall[0].contentRef.current).toHaveTextContent('Alert content');
+ expect(runReplacer.mock.lastCall[0].replacedContentRef.current).toBeEmptyDOMElement();
+ expect(runReplacer.mock.lastCall[0].actionsRef.current).toHaveTextContent('Action button');
+ });
+ test('type - default', async () => {
+ render();
+ await delay();
+ expect(runReplacer.mock.lastCall[0].type).toBe('info');
+ });
+ test('type - custom', async () => {
+ render();
+ await delay();
+ expect(runReplacer.mock.lastCall[0].type).toBe('error');
+ });
+});
+
+test('calls unmount callback', async () => {
+ const unmountCallback = jest.fn();
+ const plugin: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer(context, registerReplacement) {
+ registerReplacement({ hasHeader: true, hasContent: true }, () => unmountCallback());
+ },
+ };
+ awsuiPlugins.alertContent.registerContentReplacer(plugin);
+ const { unmount } = render(Alert content);
+ await delay();
+ expect(unmountCallback).not.toBeCalled();
+ unmount();
+ expect(unmountCallback).toBeCalled();
+});
+
+describe('asynchronous rendering', () => {
+ test('allows asynchronous rendering of content', async () => {
+ const asyncContent: AlertFlashContentConfig = {
+ id: 'test-content-async',
+ async runReplacer(context, registerReplacement) {
+ await pause(1000);
+ const content = document.createElement('div');
+ content.append('New content');
+ content.dataset.testid = 'test-content-async';
+ context.replacedContentRef.current!.appendChild(content);
+ registerReplacement({ hasHeader: null, hasContent: true }, () => {});
+ },
+ };
+ awsuiPlugins.alertContent.registerContentReplacer(asyncContent);
+ const { container } = render(Alert content);
+ const alertWrapper = new AlertWrapper(container);
+ await delay();
+ expectAlertContent(alertWrapper, {
+ content: 'Alert content',
+ contentReplaced: false,
+ });
+ await delay(1000);
+ expectAlertContent(alertWrapper, {
+ content: 'New content',
+ contentReplaced: true,
+ });
+ });
+
+ test('cancels asynchronous rendering when unmounting', async () => {
+ let rendered = false;
+ const asyncContent: AlertFlashContentConfig = {
+ id: 'test-content-async',
+ async runReplacer(context) {
+ await pause(1000);
+ if (!context.signal.aborted) {
+ rendered = true;
+ }
+ },
+ };
+ awsuiPlugins.alertContent.registerContentReplacer(asyncContent);
+ const { unmount } = render(Alert content);
+ await delay(500);
+ unmount();
+ await delay(1000);
+ expect(rendered).toBeFalsy();
+ });
+
+ test('warns if registerReplacement called after unmounting', async () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const asyncContent: AlertFlashContentConfig = {
+ id: 'test-content-async',
+ async runReplacer(context, registerReplacement) {
+ await pause(1000);
+ registerReplacement({ hasHeader: true, hasContent: true }, () => {});
+ },
+ };
+ awsuiPlugins.alertContent.registerContentReplacer(asyncContent);
+ const { unmount } = render(Alert content);
+ await delay(500);
+ unmount();
+ await delay(1000);
+ expect(consoleWarnSpy).toBeCalledWith(
+ '[AwsUi] [Runtime alert/flash content] `registerReplacement` called after component unmounted'
+ );
+ });
+});
diff --git a/src/alert/actions-wrapper/index.tsx b/src/alert/actions-wrapper/index.tsx
index 942bbcbcfc2..b69ea9606d4 100644
--- a/src/alert/actions-wrapper/index.tsx
+++ b/src/alert/actions-wrapper/index.tsx
@@ -32,23 +32,21 @@ interface ActionsWrapperProps {
onButtonClick: InternalButtonProps['onClick'];
}
-export function ActionsWrapper({
- className,
- testUtilClasses,
- action,
- discoveredActions,
- buttonText,
- onButtonClick,
-}: ActionsWrapperProps) {
- const actionButton = createActionButton(testUtilClasses, action, buttonText, onButtonClick);
- if (!actionButton && discoveredActions.length === 0) {
- return null;
- }
+export const ActionsWrapper = React.forwardRef(
+ (
+ { className, testUtilClasses, action, discoveredActions, buttonText, onButtonClick }: ActionsWrapperProps,
+ ref: React.Ref
+ ) => {
+ const actionButton = createActionButton(testUtilClasses, action, buttonText, onButtonClick);
+ if (!actionButton && discoveredActions.length === 0) {
+ return null;
+ }
- return (
-
- {actionButton}
- {discoveredActions}
-
- );
-}
+ return (
+
+ {actionButton}
+ {discoveredActions}
+
+ );
+ }
+);
diff --git a/src/alert/internal.tsx b/src/alert/internal.tsx
index 6478dbc2ce8..618177fe3fa 100644
--- a/src/alert/internal.tsx
+++ b/src/alert/internal.tsx
@@ -18,7 +18,7 @@ import { InternalBaseComponentProps } from '../internal/hooks/use-base-component
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
import { useVisualRefresh } from '../internal/hooks/use-visual-mode';
import { awsuiPluginsInternal } from '../internal/plugins/api';
-import { createUseDiscoveredAction } from '../internal/plugins/helpers';
+import { createUseDiscoveredAction, createUseDiscoveredContent } from '../internal/plugins/helpers';
import { SomeRequired } from '../internal/types';
import { ActionsWrapper } from './actions-wrapper';
import { AlertProps } from './interfaces';
@@ -35,6 +35,7 @@ const typeToIcon: Record = {
type InternalAlertProps = SomeRequired & InternalBaseComponentProps;
const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.alert.onActionRegistered);
+const useDiscoveredContent = createUseDiscoveredContent(awsuiPluginsInternal.alertContent.onContentRegistered);
const InternalAlert = React.forwardRef(
(
@@ -64,10 +65,26 @@ const InternalAlert = React.forwardRef(
const [breakpoint, breakpointRef] = useContainerBreakpoints(['xs']);
const mergedRef = useMergeRefs(breakpointRef, __internalRootRef);
- const isRefresh = useVisualRefresh();
- const size = isRefresh ? 'normal' : header && children ? 'big' : 'normal';
+ const { discoveredActions, headerRef: headerRefAction, contentRef: contentRefAction } = useDiscoveredAction(type);
+ const {
+ hasReplacementHeader,
+ hasReplacementContent,
+ headerRef: headerRefContent,
+ contentRef: contentRefContent,
+ replacedHeaderRef,
+ replacedContentRef,
+ actionsRef,
+ } = useDiscoveredContent({ type, header, children });
+
+ const headerRef = useMergeRefs(headerRefAction, headerRefContent);
+ const contentRef = useMergeRefs(contentRefAction, contentRefContent);
- const { discoveredActions, headerRef, contentRef } = useDiscoveredAction(type);
+ const isRefresh = useVisualRefresh();
+ const size = isRefresh
+ ? 'normal'
+ : (hasReplacementHeader ?? header) && (hasReplacementContent ?? children)
+ ? 'big'
+ : 'normal';
const hasAction = Boolean(action || buttonText || discoveredActions.length);
@@ -100,17 +117,27 @@ const InternalAlert = React.forwardRef(
- {header && (
-
- {header}
-
- )}
-
+
+ {header}
+
+
+
{children}
+
new Promise(resolve => setTimeout(resolve, timeout));
+
+const expectFlashContent = (
+ wrapper: FlashWrapper,
+ {
+ header,
+ headerReplaced,
+ content,
+ contentReplaced,
+ }: {
+ header?: string | false;
+ headerReplaced?: boolean;
+ content?: string | false;
+ contentReplaced?: boolean;
+ }
+) => {
+ if (header) {
+ if (headerReplaced) {
+ expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementHeader()?.getElement().textContent).toBe(header);
+ } else {
+ expect(wrapper.findReplacementHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findHeader()?.getElement().textContent).toBe(header);
+ }
+ } else if (header === false) {
+ expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementHeader()?.getElement()).toHaveClass(stylesCss.hidden);
+ }
+ if (content) {
+ if (contentReplaced) {
+ expect(wrapper.findContent()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementContent()?.getElement().textContent).toBe(content);
+ } else {
+ expect(wrapper.findReplacementContent()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findContent()?.getElement().textContent).toBe(content);
+ }
+ } else if (content === false) {
+ expect(wrapper.findContent()?.getElement()).toHaveClass(stylesCss.hidden);
+ expect(wrapper.findReplacementContent()?.getElement()).toHaveClass(stylesCss.hidden);
+ }
+};
+
+const defaultContent: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer(context, registerReplacement) {
+ context.replacedHeaderRef.current!.append('New header');
+ context.replacedContentRef.current!.append('New content');
+ registerReplacement({ hasHeader: true, hasContent: true }, () => {});
+ },
+};
+
+function delay(advanceBy = 1) {
+ const promise = act(() => new Promise(resolve => setTimeout(resolve)));
+ jest.advanceTimersByTime(advanceBy);
+ return promise;
+}
+
+beforeEach(() => {
+ jest.useFakeTimers();
+});
+afterEach(() => {
+ awsuiPluginsInternal.flashContent.clearRegisteredReplacer();
+ jest.useRealTimers();
+ jest.resetAllMocks();
+ jest.restoreAllMocks();
+});
+
+test('renders runtime content initially', async () => {
+ awsuiPlugins.flashContent.registerContentReplacer(defaultContent);
+ const { container } = render();
+ const flashbarWrapper = new FlashbarWrapper(container);
+ await delay();
+ expectFlashContent(flashbarWrapper.findItems()[0], {
+ content: 'New content',
+ contentReplaced: true,
+ });
+});
+
+test('renders runtime content when asynchronously registered', async () => {
+ const { container } = render();
+ const flashbarWrapper = new FlashbarWrapper(container);
+ await delay();
+ expectFlashContent(flashbarWrapper.findItems()[0], {
+ content: 'Flash content',
+ contentReplaced: false,
+ });
+ awsuiPlugins.flashContent.registerContentReplacer(defaultContent);
+ await delay();
+ expectFlashContent(flashbarWrapper.findItems()[0], {
+ content: 'New content',
+ contentReplaced: true,
+ });
+});
+
+describe.each([true, false])('existing header:%p', existingHeader => {
+ test('renders runtime header initially', async () => {
+ awsuiPlugins.flashContent.registerContentReplacer(defaultContent);
+ const { container } = render(
+
+ );
+ const flashbarWrapper = new FlashbarWrapper(container);
+ await delay();
+ expectFlashContent(flashbarWrapper.findItems()[0], {
+ header: 'New header',
+ headerReplaced: true,
+ });
+ });
+
+ test('renders runtime header when asynchronously registered', async () => {
+ const { container } = render(
+
+ );
+ const flashbarWrapper = new FlashbarWrapper(container);
+ await delay();
+ expectFlashContent(flashbarWrapper.findItems()[0], {
+ header: existingHeader ? 'Flash header' : undefined,
+ headerReplaced: false,
+ });
+ awsuiPlugins.flashContent.registerContentReplacer(defaultContent);
+ await delay();
+ expectFlashContent(flashbarWrapper.findItems()[0], {
+ header: 'New header',
+ headerReplaced: true,
+ });
+ });
+});
+
+describe('runReplacer arguments', () => {
+ const runReplacer = jest.fn();
+ beforeEach(() => {
+ const plugin: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer,
+ };
+ awsuiPlugins.flashContent.registerContentReplacer(plugin);
+ });
+ test('refs', async () => {
+ render(
+ Action button,
+ content: 'Flash content',
+ },
+ ]}
+ />
+ );
+ await delay();
+ expect(runReplacer.mock.lastCall[0].headerRef.current).toHaveTextContent('Flash header');
+ expect(runReplacer.mock.lastCall[0].replacedHeaderRef.current).toBeEmptyDOMElement();
+ expect(runReplacer.mock.lastCall[0].contentRef.current).toHaveTextContent('Flash content');
+ expect(runReplacer.mock.lastCall[0].replacedContentRef.current).toBeEmptyDOMElement();
+ expect(runReplacer.mock.lastCall[0].actionsRef.current).toHaveTextContent('Action button');
+ });
+ test('type - default', async () => {
+ render();
+ await delay();
+ expect(runReplacer.mock.lastCall[0].type).toBe('info');
+ });
+ test('type - custom', async () => {
+ render();
+ await delay();
+ expect(runReplacer.mock.lastCall[0].type).toBe('error');
+ });
+});
+
+test('calls unmount callback', async () => {
+ const unmountCallback = jest.fn();
+ const plugin: AlertFlashContentConfig = {
+ id: 'test-content',
+ runReplacer(context, registerReplacement) {
+ registerReplacement({ hasHeader: true, hasContent: true }, () => unmountCallback());
+ },
+ };
+ awsuiPlugins.flashContent.registerContentReplacer(plugin);
+ const { unmount } = render();
+ await delay();
+ expect(unmountCallback).not.toBeCalled();
+ unmount();
+ expect(unmountCallback).toBeCalled();
+});
+
+describe('asynchronous rendering', () => {
+ test('allows asynchronous rendering of content', async () => {
+ const asyncContent: AlertFlashContentConfig = {
+ id: 'test-content-async',
+ async runReplacer(context, registerReplacement) {
+ await pause(1000);
+ const content = document.createElement('div');
+ content.append('New content');
+ content.dataset.testid = 'test-content-async';
+ context.replacedContentRef.current!.appendChild(content);
+ registerReplacement({ hasHeader: null, hasContent: true }, () => {});
+ },
+ };
+ awsuiPlugins.flashContent.registerContentReplacer(asyncContent);
+ const { container } = render();
+ const flashWrapper = new FlashbarWrapper(container).findItems()[0];
+ await delay();
+ expectFlashContent(flashWrapper, {
+ content: 'Flash content',
+ contentReplaced: false,
+ });
+ await delay(1000);
+ expectFlashContent(flashWrapper, {
+ content: 'New content',
+ contentReplaced: true,
+ });
+ });
+
+ test('cancels asynchronous rendering when unmounting', async () => {
+ let rendered = false;
+ const asyncContent: AlertFlashContentConfig = {
+ id: 'test-content-async',
+ async runReplacer(context) {
+ await pause(1000);
+ if (!context.signal.aborted) {
+ rendered = true;
+ }
+ },
+ };
+ awsuiPlugins.flashContent.registerContentReplacer(asyncContent);
+ const { unmount } = render();
+ await delay(500);
+ unmount();
+ await delay(1000);
+ expect(rendered).toBeFalsy();
+ });
+
+ test('warns if registerReplacement called after unmounting', async () => {
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
+ const asyncContent: AlertFlashContentConfig = {
+ id: 'test-content-async',
+ async runReplacer(context, registerReplacement) {
+ await pause(1000);
+ registerReplacement({ hasHeader: true, hasContent: true }, () => {});
+ },
+ };
+ awsuiPlugins.flashContent.registerContentReplacer(asyncContent);
+ const { unmount } = render();
+ await delay(500);
+ unmount();
+ await delay(1000);
+ expect(consoleWarnSpy).toBeCalledWith(
+ '[AwsUi] [Runtime alert/flash content] `registerReplacement` called after component unmounted'
+ );
+ });
+});
diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx
index f0a5e6b8188..1181724a332 100644
--- a/src/flashbar/flash.tsx
+++ b/src/flashbar/flash.tsx
@@ -17,7 +17,7 @@ import { PACKAGE_VERSION } from '../internal/environment';
import { useMergeRefs } from '../internal/hooks/use-merge-refs';
import { isDevelopment } from '../internal/is-development';
import { awsuiPluginsInternal } from '../internal/plugins/api';
-import { createUseDiscoveredAction } from '../internal/plugins/helpers';
+import { createUseDiscoveredAction, createUseDiscoveredContent } from '../internal/plugins/helpers';
import { throttle } from '../internal/utils/throttle';
import InternalSpinner from '../spinner/internal';
import { FlashbarProps } from './interfaces';
@@ -35,6 +35,7 @@ const ICON_TYPES = {
} as const;
const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.flashbar.onActionRegistered);
+const useDiscoveredContent = createUseDiscoveredContent(awsuiPluginsInternal.flashContent.onContentRegistered);
function dismissButton(
dismissLabel: FlashbarProps.MessageDefinition['dismissLabel'],
@@ -110,7 +111,20 @@ export const Flash = React.forwardRef(
const analyticsMetadata = getAnalyticsMetadataProps(props as BasePropsWithAnalyticsMetadata);
const elementRef = useComponentMetadata('Flash', PACKAGE_VERSION, { ...analyticsMetadata });
const mergedRef = useMergeRefs(ref, elementRef);
- const { discoveredActions, headerRef, contentRef } = useDiscoveredAction(type);
+
+ const { discoveredActions, headerRef: headerRefAction, contentRef: contentRefAction } = useDiscoveredAction(type);
+ const {
+ hasReplacementHeader,
+ hasReplacementContent,
+ headerRef: headerRefContent,
+ contentRef: contentRefContent,
+ replacedHeaderRef,
+ replacedContentRef,
+ actionsRef,
+ } = useDiscoveredContent({ type, header, children: content });
+
+ const headerRef = useMergeRefs(headerRefAction, headerRefContent);
+ const contentRef = useMergeRefs(contentRefAction, contentRefContent);
const iconType = ICON_TYPES[type];
@@ -165,15 +179,30 @@ export const Flash = React.forwardRef(
{icon}
{dismissible && dismissButton(dismissLabel, handleDismiss)}
- {ariaRole === 'status' && }
+ {ariaRole === 'status' && }
);
}
diff --git a/src/flashbar/styles.scss b/src/flashbar/styles.scss
index 79513f53745..45e304d856d 100644
--- a/src/flashbar/styles.scss
+++ b/src/flashbar/styles.scss
@@ -102,11 +102,17 @@
@include styles.text-flex-wrapping;
}
-.flash-header {
+.hidden {
+ display: none;
+}
+
+.flash-header,
+.flash-header-replacement {
font-weight: styles.$font-weight-bold;
}
-.flash-content {
+.flash-content,
+.flash-content-replacement {
/* Only used as a selector for test-utils */
}
diff --git a/src/internal/plugins/api.ts b/src/internal/plugins/api.ts
index 0a865d8b8be..718cb4abe53 100644
--- a/src/internal/plugins/api.ts
+++ b/src/internal/plugins/api.ts
@@ -2,6 +2,11 @@
// SPDX-License-Identifier: Apache-2.0
import { BreadcrumbGroupProps } from '../../breadcrumb-group/interfaces';
import { ActionButtonsController, ActionsApiInternal, ActionsApiPublic } from './controllers/action-buttons';
+import {
+ AlertFlashContentApiInternal,
+ AlertFlashContentApiPublic,
+ AlertFlashContentController,
+} from './controllers/alert-flash-content';
import { AppLayoutWidgetApiInternal, AppLayoutWidgetController } from './controllers/app-layout-widget';
import { BreadcrumbsApiInternal, BreadcrumbsController } from './controllers/breadcrumbs';
import { DrawersApiInternal, DrawersApiPublic, DrawersController } from './controllers/drawers';
@@ -12,13 +17,17 @@ interface AwsuiApi {
awsuiPlugins: {
appLayout: DrawersApiPublic;
alert: ActionsApiPublic;
+ alertContent: AlertFlashContentApiPublic;
flashbar: ActionsApiPublic;
+ flashContent: AlertFlashContentApiPublic;
};
awsuiPluginsInternal: {
appLayout: DrawersApiInternal;
appLayoutWidget: AppLayoutWidgetApiInternal;
alert: ActionsApiInternal;
+ alertContent: AlertFlashContentApiInternal;
flashbar: ActionsApiInternal;
+ flashContent: AlertFlashContentApiInternal;
breadcrumbs: BreadcrumbsApiInternal;
};
}
@@ -76,6 +85,14 @@ function installApi(api: DeepPartial): AwsuiApi {
api.awsuiPlugins.alert = alertActions.installPublic(api.awsuiPlugins.alert);
api.awsuiPluginsInternal.alert = alertActions.installInternal(api.awsuiPluginsInternal.alert);
+ const alertContent = new AlertFlashContentController();
+ api.awsuiPlugins.alertContent = alertContent.installPublic(api.awsuiPlugins.alertContent);
+ api.awsuiPluginsInternal.alertContent = alertContent.installInternal(api.awsuiPluginsInternal.alertContent);
+
+ const flashContent = new AlertFlashContentController();
+ api.awsuiPlugins.flashContent = flashContent.installPublic(api.awsuiPlugins.flashContent);
+ api.awsuiPluginsInternal.flashContent = flashContent.installInternal(api.awsuiPluginsInternal.flashContent);
+
const flashbarActions = new ActionButtonsController();
api.awsuiPlugins.flashbar = flashbarActions.installPublic(api.awsuiPlugins.flashbar);
api.awsuiPluginsInternal.flashbar = flashbarActions.installInternal(api.awsuiPluginsInternal.flashbar);
diff --git a/src/internal/plugins/controllers/alert-flash-content.ts b/src/internal/plugins/controllers/alert-flash-content.ts
new file mode 100644
index 00000000000..381f161cb8f
--- /dev/null
+++ b/src/internal/plugins/controllers/alert-flash-content.ts
@@ -0,0 +1,90 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import debounce from '../../debounce';
+
+// this code should not depend on React typings, because it is portable between major versions
+interface RefShim {
+ current: T | null;
+}
+
+export interface AlertFlashContentContext {
+ type: string;
+ headerRef: RefShim;
+ replacedHeaderRef: RefShim;
+ contentRef: RefShim;
+ replacedContentRef: RefShim;
+ actionsRef: RefShim;
+ signal: AbortSignal;
+}
+
+type RegisterReplacement = (
+ results: {
+ hasHeader: boolean | null;
+ hasContent: boolean | null;
+ },
+ unmountCallback: () => void
+) => void;
+
+export interface AlertFlashContentConfig {
+ id: string;
+ runReplacer: (context: AlertFlashContentContext, registerReplacement: RegisterReplacement) => void;
+}
+
+export interface AlertFlashContentRegistrationListener {
+ (provider?: AlertFlashContentConfig): void | (() => void);
+ cleanup?: void | (() => void);
+}
+
+export interface AlertFlashContentApiPublic {
+ registerContentReplacer(config: AlertFlashContentConfig): void;
+}
+
+export interface AlertFlashContentApiInternal {
+ clearRegisteredReplacer(): void;
+ onContentRegistered(listener: AlertFlashContentRegistrationListener): () => void;
+}
+
+export class AlertFlashContentController {
+ private listeners: Array = [];
+ private provider?: AlertFlashContentConfig;
+
+ private scheduleUpdate = debounce(() => {
+ this.listeners.forEach(listener => {
+ listener.cleanup = listener(this.provider);
+ });
+ }, 0);
+
+ registerContentReplacer = (content: AlertFlashContentConfig) => {
+ if (this.provider) {
+ console.warn(
+ `Cannot call \`registerContentReplacer\` with new provider: provider with id \`${this.provider.id}\` already registered.`
+ );
+ }
+ this.provider = content;
+ this.scheduleUpdate();
+ };
+
+ clearRegisteredReplacer = () => {
+ this.provider = undefined;
+ };
+
+ onContentRegistered = (listener: AlertFlashContentRegistrationListener) => {
+ this.listeners.push(listener);
+ this.scheduleUpdate();
+ return () => {
+ listener.cleanup?.();
+ this.listeners = this.listeners.filter(item => item !== listener);
+ };
+ };
+
+ installPublic(api: Partial = {}): AlertFlashContentApiPublic {
+ api.registerContentReplacer ??= this.registerContentReplacer;
+ return api as AlertFlashContentApiPublic;
+ }
+
+ installInternal(internalApi: Partial = {}): AlertFlashContentApiInternal {
+ internalApi.clearRegisteredReplacer ??= this.clearRegisteredReplacer;
+ internalApi.onContentRegistered ??= this.onContentRegistered;
+ return internalApi as AlertFlashContentApiInternal;
+ }
+}
diff --git a/src/internal/plugins/helpers/index.ts b/src/internal/plugins/helpers/index.ts
index 435d0288dc3..ef2bbee9f86 100644
--- a/src/internal/plugins/helpers/index.ts
+++ b/src/internal/plugins/helpers/index.ts
@@ -2,3 +2,4 @@
// SPDX-License-Identifier: Apache-2.0
export { RuntimeContentWrapper } from './runtime-content-wrapper';
export { createUseDiscoveredAction } from './use-discovered-action';
+export { createUseDiscoveredContent } from './use-discovered-content';
diff --git a/src/internal/plugins/helpers/use-discovered-content.tsx b/src/internal/plugins/helpers/use-discovered-content.tsx
new file mode 100644
index 00000000000..0b5ea0ed6f3
--- /dev/null
+++ b/src/internal/plugins/helpers/use-discovered-content.tsx
@@ -0,0 +1,70 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import { ReactNode, useEffect, useRef, useState } from 'react';
+
+import { AlertFlashContentController } from '../controllers/alert-flash-content';
+
+export function createUseDiscoveredContent(onContentRegistered: AlertFlashContentController['onContentRegistered']) {
+ return function useDiscoveredContent({
+ type,
+ header,
+ children,
+ }: {
+ type: string;
+ header: ReactNode;
+ children: ReactNode;
+ }) {
+ const headerRef = useRef(null);
+ const contentRef = useRef(null);
+ const replacedHeaderRef = useRef(null);
+ const replacedContentRef = useRef(null);
+ const actionsRef = useRef(null);
+ const unmountCallback = useRef<(() => void) | null>(null);
+ const [foundHeaderReplacement, setFoundHeaderReplacement] = useState(null);
+ const [foundContentReplacement, setFoundContentReplacement] = useState(null);
+
+ useEffect(() => {
+ return onContentRegistered(provider => {
+ const controller = new AbortController();
+
+ provider?.runReplacer(
+ {
+ type,
+ headerRef,
+ replacedHeaderRef,
+ contentRef,
+ replacedContentRef,
+ actionsRef,
+ signal: controller.signal,
+ },
+ (results, newUnmountCallback) => {
+ if (controller.signal.aborted) {
+ console.warn(
+ '[AwsUi] [Runtime alert/flash content] `registerReplacement` called after component unmounted'
+ );
+ }
+ setFoundHeaderReplacement(results.hasHeader);
+ setFoundContentReplacement(results.hasContent);
+ unmountCallback.current = newUnmountCallback;
+ }
+ );
+
+ return () => {
+ controller.abort();
+ headerRef.current && unmountCallback.current?.();
+ };
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [type, header, children]);
+
+ return {
+ hasReplacementHeader: foundHeaderReplacement,
+ hasReplacementContent: foundContentReplacement,
+ headerRef,
+ replacedHeaderRef,
+ contentRef,
+ replacedContentRef,
+ actionsRef,
+ };
+ };
+}
diff --git a/src/test-utils/dom/alert/index.ts b/src/test-utils/dom/alert/index.ts
index 18d6c177914..8bbe40f7476 100644
--- a/src/test-utils/dom/alert/index.ts
+++ b/src/test-utils/dom/alert/index.ts
@@ -38,10 +38,18 @@ export default class AlertWrapper extends ComponentWrapper {
return this.findByClassName(styles.header);
}
+ findReplacementHeader(): ElementWrapper | null {
+ return this.findByClassName(styles['header-replacement']);
+ }
+
findContent(): ElementWrapper {
return this.findByClassName(styles.content)!;
}
+ findReplacementContent(): ElementWrapper {
+ return this.findByClassName(styles['content-replacement'])!;
+ }
+
findActionSlot(): ElementWrapper | null {
return this.findByClassName(styles['action-slot']);
}
diff --git a/src/test-utils/dom/flashbar/flash.ts b/src/test-utils/dom/flashbar/flash.ts
index 983680a57c6..29d64c1d3f4 100644
--- a/src/test-utils/dom/flashbar/flash.ts
+++ b/src/test-utils/dom/flashbar/flash.ts
@@ -38,7 +38,15 @@ export default class FlashWrapper extends ComponentWrapper {
return this.findByClassName(styles['flash-header']);
}
+ findReplacementHeader(): ElementWrapper | null {
+ return this.findByClassName(styles['flash-header-replacement']);
+ }
+
findContent(): ElementWrapper | null {
return this.findByClassName(styles['flash-content']);
}
+
+ findReplacementContent(): ElementWrapper | null {
+ return this.findByClassName(styles['flash-content-replacement']);
+ }
}