From 648a197d84d90e0000d5a330c05891441b266cca Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Tue, 13 Aug 2024 16:00:50 +0200 Subject: [PATCH] feat: Add alert content replacement runtime API --- pages/alert/runtime-content.page.tsx | 76 +++++ .../test-utils-selectors.test.tsx.snap | 4 + src/alert/__tests__/alert.test.tsx | 20 ++ src/alert/__tests__/runtime-content.test.tsx | 270 +++++++++++++++++ src/alert/actions-wrapper/index.tsx | 36 ++- src/alert/internal.tsx | 47 ++- src/alert/styles.scss | 10 +- .../__tests__/runtime-content.test.tsx | 278 ++++++++++++++++++ src/flashbar/flash.tsx | 39 ++- src/flashbar/styles.scss | 10 +- src/internal/plugins/api.ts | 17 ++ .../controllers/alert-flash-content.ts | 90 ++++++ src/internal/plugins/helpers/index.ts | 1 + .../helpers/use-discovered-content.tsx | 70 +++++ src/test-utils/dom/alert/index.ts | 8 + src/test-utils/dom/flashbar/flash.ts | 8 + 16 files changed, 946 insertions(+), 38 deletions(-) create mode 100644 pages/alert/runtime-content.page.tsx create mode 100644 src/alert/__tests__/runtime-content.test.tsx create mode 100644 src/flashbar/__tests__/runtime-content.test.tsx create mode 100644 src/internal/plugins/controllers/alert-flash-content.ts create mode 100644 src/internal/plugins/helpers/use-discovered-content.tsx 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}
-
+
{header}
-
+
+
{content}
+
{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']); + } }