diff --git a/src/alert/styles.scss b/src/alert/styles.scss
index fd74a600f8..5c2c2f5661 100644
--- a/src/alert/styles.scss
+++ b/src/alert/styles.scss
@@ -46,8 +46,7 @@
}
.action {
- white-space: nowrap;
- margin-left: awsui.$space-alert-action-left;
+ /* used in test-utils */
}
.action-button {
@@ -124,6 +123,10 @@
/* used in test-utils */
}
+.action-slot {
+ /* used in test-utils */
+}
+
.icon {
flex: 0 0 auto;
}
diff --git a/src/app-layout/runtime-api.tsx b/src/app-layout/runtime-api.tsx
index 43761459f5..756ed88bdf 100644
--- a/src/app-layout/runtime-api.tsx
+++ b/src/app-layout/runtime-api.tsx
@@ -1,27 +1,10 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
-import React, { useEffect, useRef } from 'react';
-import { DrawerConfig as RuntimeDrawerConfig } from '../internal/plugins/drawers-controller';
+import React from 'react';
+import { DrawerConfig as RuntimeDrawerConfig } from '../internal/plugins/controllers/drawers';
+import { RuntimeContentWrapper } from '../internal/plugins/helpers';
import { DrawerItem } from './drawer/interfaces';
-interface RuntimeContentWrapperProps {
- mountContent: RuntimeDrawerConfig['mountContent'];
- unmountContent: RuntimeDrawerConfig['unmountContent'];
-}
-
-function RuntimeContentWrapper({ mountContent, unmountContent }: RuntimeContentWrapperProps) {
- const ref = useRef
(null);
-
- useEffect(() => {
- const container = ref.current!;
- mountContent(container);
- return () => unmountContent(container);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return ;
-}
-
export interface DrawersLayout {
before: Array;
after: Array;
diff --git a/src/flashbar/__tests__/runtime-action.test.tsx b/src/flashbar/__tests__/runtime-action.test.tsx
new file mode 100644
index 0000000000..62209933da
--- /dev/null
+++ b/src/flashbar/__tests__/runtime-action.test.tsx
@@ -0,0 +1,151 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+import * as React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import Flashbar, { FlashbarProps } from '../../../lib/components/flashbar';
+import awsuiPlugins from '../../../lib/components/internal/plugins';
+import { ActionConfig } from '../../../lib/components/internal/plugins/controllers/action-buttons';
+import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api';
+
+const defaultAction: ActionConfig = {
+ id: 'test-action',
+ mountContent: container => {
+ const button = document.createElement('button');
+ button.dataset.testid = 'test-action';
+ container.appendChild(button);
+ },
+ unmountContent: container => (container.innerHTML = ''),
+};
+
+const defaultItem: FlashbarProps.MessageDefinition = {};
+
+function delay() {
+ return act(() => new Promise(resolve => setTimeout(resolve)));
+}
+
+afterEach(() => {
+ awsuiPluginsInternal.flashbar.clearAction();
+});
+
+test('renders runtime action button initially', async () => {
+ awsuiPlugins.flashbar.registerAction(defaultAction);
+ render();
+ await delay();
+ expect(screen.queryByTestId('test-action')).toBeTruthy();
+});
+
+test('renders runtime action button asynchronously', async () => {
+ render();
+ await delay();
+ expect(screen.queryByTestId('test-action')).toBeFalsy();
+ awsuiPlugins.flashbar.registerAction(defaultAction);
+ await delay();
+ expect(screen.queryByTestId('test-action')).toBeTruthy();
+});
+
+test('renders runtime action along with the props one', async () => {
+ awsuiPlugins.flashbar.registerAction(defaultAction);
+ render(test }]} />);
+ await delay();
+ expect(screen.queryByTestId('own-button')).toBeTruthy();
+ expect(screen.queryByTestId('test-action')).toBeTruthy();
+});
+
+test('renders runtime action button on multiple instances', async () => {
+ awsuiPlugins.flashbar.registerAction(defaultAction);
+ render(
+
+ );
+ await delay();
+ expect(screen.queryAllByTestId('test-action')).toHaveLength(2);
+});
+
+test('allows skipping actions for some items', async () => {
+ const testAction: ActionConfig = {
+ ...defaultAction,
+ mountContent: (container, context) => {
+ if (context.type !== 'error') {
+ return;
+ }
+ defaultAction.mountContent(container, context);
+ },
+ };
+ awsuiPlugins.flashbar.registerAction(testAction);
+ render(
+
+ );
+ await delay();
+ expect(screen.queryAllByTestId('test-action')).toHaveLength(1);
+});
+
+test('maintains action button state when dismissing a message', async () => {
+ const testAction: ActionConfig = {
+ ...defaultAction,
+ mountContent: (container, context) => {
+ if (context.type !== 'error') {
+ return;
+ }
+ defaultAction.mountContent(container, context);
+ },
+ };
+ awsuiPlugins.flashbar.registerAction(testAction);
+ const items: Array = [
+ { ...defaultItem, id: '1', type: 'info' },
+ { ...defaultItem, id: '2', type: 'error' },
+ ];
+ const { rerender } = render();
+ await delay();
+ expect(screen.queryAllByTestId('test-action')).toHaveLength(1);
+
+ rerender();
+ await delay();
+ expect(screen.queryAllByTestId('test-action')).toHaveLength(1);
+});
+
+test('propagates flash message context into callback', async () => {
+ const onClick = jest.fn();
+ const testAction: ActionConfig = {
+ ...defaultAction,
+ mountContent: (container, context) => {
+ const button = document.createElement('button');
+ button.dataset.testid = 'test-action';
+ button.onclick = () => onClick(context);
+ container.appendChild(button);
+ },
+ };
+ awsuiPlugins.flashbar.registerAction(testAction);
+ render();
+ await delay();
+ fireEvent.click(screen.getByTestId('test-action'));
+ expect(onClick).toHaveBeenCalledWith({
+ type: 'info',
+ headerRef: { current: expect.any(HTMLElement) },
+ contentRef: { current: expect.any(HTMLElement) },
+ });
+});
+
+test('cleans up on unmount', async () => {
+ const testAction: ActionConfig = {
+ ...defaultAction,
+ mountContent: jest.fn(),
+ unmountContent: jest.fn(),
+ };
+ awsuiPlugins.flashbar.registerAction(testAction);
+ const { rerender } = render();
+ await delay();
+ expect(testAction.mountContent).toHaveBeenCalledTimes(1);
+ expect(testAction.unmountContent).toHaveBeenCalledTimes(0);
+ rerender(<>>);
+ expect(testAction.mountContent).toHaveBeenCalledTimes(1);
+ expect(testAction.unmountContent).toHaveBeenCalledTimes(1);
+});
diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx
index 086328fa51..2edb2adc1b 100644
--- a/src/flashbar/flash.tsx
+++ b/src/flashbar/flash.tsx
@@ -18,6 +18,9 @@ import { sendDismissMetric } from './internal/analytics';
import { FOCUS_THROTTLE_DELAY } from './utils';
import { DATA_ATTR_ANALYTICS_FLASHBAR } from '../internal/analytics/selectors';
+import { createUseDiscoveredAction } from '../internal/plugins/helpers';
+import { awsuiPluginsInternal } from '../internal/plugins/api';
+import { ActionsWrapper } from '../alert/actions-wrapper';
const ICON_TYPES = {
success: 'status-positive',
@@ -27,16 +30,7 @@ const ICON_TYPES = {
'in-progress': 'status-in-progress',
} as const;
-function actionButton(
- buttonText: FlashbarProps.MessageDefinition['buttonText'],
- onButtonClick: FlashbarProps.MessageDefinition['onButtonClick']
-) {
- return (
-
- {buttonText}
-
- );
-}
+const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.flashbar.onActionRegistered);
function dismissButton(
dismissLabel: FlashbarProps.MessageDefinition['dismissLabel'],
@@ -107,7 +101,7 @@ export const Flash = React.forwardRef(
}
}
- const button = action || (buttonText && actionButton(buttonText, onButtonClick));
+ const { discoveredAction, headerRef, contentRef } = useDiscoveredAction(type);
const iconType = ICON_TYPES[type];
@@ -158,11 +152,24 @@ export const Flash = React.forwardRef(
{icon}