From aa6ae9ea1c107b782eaaa812a92534f79b046654 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 | 77 +++++ src/alert/__tests__/alert.test.tsx | 20 ++ src/alert/__tests__/runtime-content.test.tsx | 314 +++++++++++++++++ src/alert/actions-wrapper/index.tsx | 36 +- src/alert/internal.tsx | 32 +- .../__tests__/runtime-content.test.tsx | 323 ++++++++++++++++++ src/flashbar/flash.tsx | 17 +- src/internal/plugins/api.ts | 17 + .../controllers/alert-flash-content.ts | 90 +++++ src/internal/plugins/helpers/index.ts | 1 + .../helpers/use-discovered-content.tsx | 98 ++++++ 11 files changed, 994 insertions(+), 31 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..ea20854654e --- /dev/null +++ b/pages/alert/runtime-content.page.tsx @@ -0,0 +1,77 @@ +// 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, TextContent } 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.registerContent({ + id: 'awsui/alert-test-action', + mountContent: (container, context) => { + if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) { + render( +
+ Access was denied + + Some more details + +
, + container + ); + return true; + } + return null; + }, + mountHeader: (container, context) => { + if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) { + container.replaceChildren(); + return false; + } + return null; + }, + unmountContent: container => unmountComponentAtNode(container), + unmountHeader: () => {}, +}); + +/* 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/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..5768a4070d8 --- /dev/null +++ b/src/alert/__tests__/runtime-content.test.tsx @@ -0,0 +1,314 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { act, render, screen } 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'; + +const pause = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout)); + +const defaultContent: AlertFlashContentConfig = { + id: 'test-content', + mountContent: container => { + const content = document.createElement('div'); + content.append('New content'); + content.dataset.testid = 'test-content'; + container.replaceChildren(content); + return true; + }, + mountHeader: container => { + const content = document.createElement('div'); + content.append('New header'); + content.dataset.testid = 'test-header'; + container.replaceChildren(content); + return true; + }, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), +}; + +function delay(advanceBy = 1) { + const promise = act(() => new Promise(resolve => setTimeout(resolve))); + jest.advanceTimersByTime(advanceBy); + return promise; +} + +beforeEach(() => { + jest.useFakeTimers(); +}); +afterEach(() => { + awsuiPluginsInternal.alertContent.clearRegisteredContent(); + jest.useRealTimers(); + jest.resetAllMocks(); + jest.restoreAllMocks(); +}); + +test('renders runtime content initially', async () => { + awsuiPlugins.alertContent.registerContent(defaultContent); + const { container } = render(Alert content); + const alertWrapper = new AlertWrapper(container); + await delay(); + expect(screen.queryByTestId('test-content')).toBeTruthy(); + expect(alertWrapper.findContent().getElement().textContent).toBe('New content'); +}); + +test('renders runtime content asynchronously', async () => { + render(); + await delay(); + expect(screen.queryByTestId('test-content')).toBeFalsy(); + awsuiPlugins.alertContent.registerContent(defaultContent); + await delay(); + expect(screen.queryByTestId('test-content')).toBeTruthy(); +}); + +describe.each([true, false])('existing header:%p', existingHeader => { + test('renders runtime header initially', async () => { + awsuiPlugins.alertContent.registerContent(defaultContent); + const { container } = render(Alert content); + const alertWrapper = new AlertWrapper(container); + await delay(); + expect(screen.queryByTestId('test-header')).toBeTruthy(); + expect(alertWrapper.findHeader()!.getElement().textContent).toBe('New header'); + }); + + test('renders runtime header asynchronously', async () => { + const { container } = render(Alert content); + const alertWrapper = new AlertWrapper(container); + await delay(); + expect(screen.queryByTestId('test-header')).toBeFalsy(); + awsuiPlugins.alertContent.registerContent(defaultContent); + await delay(); + expect(screen.queryByTestId('test-header')).toBeTruthy(); + expect(alertWrapper.findHeader()!.getElement().textContent).toBe('New header'); + }); +}); + +test('removes header styling if runtime header is explicitly empty', async () => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountHeader(container) { + container.replaceChildren(); + return false; + }, + }; + awsuiPlugins.alertContent.registerContent(plugin); + const { container } = render(); + const alertWrapper = new AlertWrapper(container); + expect(alertWrapper.findHeader()).toBeTruthy(); + await delay(); + expect(alertWrapper.findHeader()).toBeFalsy(); +}); + +describe('mountContent arguments', () => { + const mountContent = jest.fn(); + beforeEach(() => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountContent, + }; + awsuiPlugins.alertContent.registerContent(plugin); + }); + test('refs', async () => { + render( + Action button}> + Alert content + + ); + await delay(); + expect(mountContent.mock.lastCall[1].headerRef.current).toHaveTextContent('Alert header'); + expect(mountContent.mock.lastCall[1].contentRef.current).toHaveTextContent('Alert content'); + expect(mountContent.mock.lastCall[1].actionsRef.current).toHaveTextContent('Action button'); + }); + test('type - default', async () => { + render(); + await delay(); + expect(mountContent.mock.lastCall[1].type).toBe('info'); + }); + test('type - custom', async () => { + render(); + await delay(); + expect(mountContent.mock.lastCall[1].type).toBe('error'); + }); +}); + +describe('unmounting', () => { + test('unmounts content and header', async () => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountContent: () => true, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), + }; + awsuiPlugins.alertContent.registerContent(plugin); + const { unmount } = render(Alert content); + await delay(); + expect(plugin.unmountContent).toBeCalledTimes(0); + expect(plugin.unmountHeader).toBeCalledTimes(0); + unmount(); + await delay(); + expect(plugin.unmountContent).toBeCalledTimes(1); + expect(plugin.unmountHeader).toBeCalledTimes(1); + }); + + test('does not unmount if not rendered', async () => { + const noRender: AlertFlashContentConfig = { + id: 'test-content', + mountContent: () => null, + mountHeader: () => null, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), + }; + awsuiPlugins.alertContent.registerContent(noRender); + const { unmount } = render(Alert content); + await delay(); + unmount(); + await delay(); + expect(noRender.unmountContent).toBeCalledTimes(0); + expect(noRender.unmountHeader).toBeCalledTimes(0); + }); +}); + +describe('asynchronous rendering', () => { + test('allows asynchronous rendering of content', async () => { + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + mountContent: async container => { + await pause(1000); + const content = document.createElement('div'); + content.append('New content'); + content.dataset.testid = 'test-content-async'; + container.replaceChildren(content); + return true; + }, + }; + awsuiPlugins.alertContent.registerContent(asyncContent); + const { container } = render(Alert content); + const alertWrapper = new AlertWrapper(container); + await delay(); + expect(screen.queryByTestId('test-content-async')).toBeFalsy(); + expect(alertWrapper.findContent().getElement().textContent).toBe('Alert content'); + await delay(1000); + expect(screen.queryByTestId('test-content-async')).toBeTruthy(); + expect(alertWrapper.findContent().getElement().textContent).toBe('New content'); + }); + + test('cancels asynchronous rendering when unmounting', async () => { + let rendered = false; + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + mountContent: async (container, { signal }) => { + await pause(1000); + if (!signal.aborted) { + rendered = true; + return true; + } + return null; + }, + }; + awsuiPlugins.alertContent.registerContent(asyncContent); + const { unmount } = render(Alert content); + await delay(500); + unmount(); + await delay(1000); + expect(rendered).toBeFalsy(); + }); + + test('warns if promise returns after unmounting', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + mountContent: async () => { + await pause(1000); + return true; + }, + }; + awsuiPlugins.alertContent.registerContent(asyncContent); + const { unmount } = render(Alert content); + await delay(500); + unmount(); + await delay(1000); + expect(consoleWarnSpy).toBeCalledWith( + '[AwsUi] [Runtime alert/flash content] Async content returned after component unmounted' + ); + }); + + test('warns if promise returns after unmounting (header)', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + mountHeader: async () => { + await pause(1000); + return true; + }, + }; + awsuiPlugins.alertContent.registerContent(asyncContent); + const { unmount } = render(Alert content); + await delay(500); + unmount(); + await delay(1000); + expect(consoleWarnSpy).toBeCalledWith( + '[AwsUi] [Runtime alert/flash content] Async header returned after component unmounted' + ); + }); + + test('waits for first async plugin before trying next', async () => { + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async-1', + mountContent: jest.fn(async () => { + await pause(1000); + return null; + }), + }; + const asyncContent2: AlertFlashContentConfig = { + id: 'test-content-async-2', + mountContent: jest.fn(async () => { + await pause(1000); + return true; + }), + mountHeader: jest.fn(), + }; + const asyncContent3: AlertFlashContentConfig = { + id: 'test-content-async-3', + mountContent: jest.fn(), + }; + awsuiPlugins.alertContent.registerContent(asyncContent); + awsuiPlugins.alertContent.registerContent(asyncContent2); + render(Alert content); + await delay(); + expect(asyncContent.mountContent).toBeCalled(); + expect(asyncContent2.mountContent).not.toBeCalled(); + expect(asyncContent2.mountHeader).not.toBeCalled(); + awsuiPlugins.alertContent.registerContent(asyncContent3); + expect(asyncContent3.mountContent).not.toBeCalled(); + await delay(1000); + expect(asyncContent2.mountContent).toBeCalled(); + expect(asyncContent2.mountHeader).toBeCalled(); + expect(asyncContent3.mountContent).not.toBeCalled(); + await delay(1000); + expect(asyncContent3.mountContent).not.toBeCalled(); + }); +}); + +test('only uses first plugin to return a result', async () => { + const first: AlertFlashContentConfig = { + id: 'test-content-1', + mountContent: jest.fn(() => false), + }; + const second: AlertFlashContentConfig = { + id: 'test-content-2', + mountContent: jest.fn(), + mountHeader: jest.fn(), + }; + awsuiPlugins.alertContent.registerContent(first); + awsuiPlugins.alertContent.registerContent(second); + render(); + await delay(); + expect(first.mountContent).toBeCalled(); + expect(second.mountContent).not.toBeCalled(); + expect(second.mountHeader).not.toBeCalled(); +}); 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..d6629dd748a 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,24 @@ 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 { + hasDiscoveredHeader, + hasDiscoveredContent, + headerRef: headerRefContent, + contentRef: contentRefContent, + 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' + : (hasDiscoveredHeader ?? header) && (hasDiscoveredContent ?? children) + ? 'big' + : 'normal'; const hasAction = Boolean(action || buttonText || discoveredActions.length); @@ -100,17 +115,16 @@ const InternalAlert = React.forwardRef(
- {header && ( -
- {header} -
- )} +
+ {header} +
{children}
new Promise(resolve => setTimeout(resolve, timeout)); + +const defaultContent: AlertFlashContentConfig = { + id: 'test-content', + mountContent: container => { + const content = document.createElement('div'); + content.append('New content'); + content.dataset.testid = 'test-content'; + container.replaceChildren(content); + return true; + }, + mountHeader: container => { + const content = document.createElement('div'); + content.append('New header'); + content.dataset.testid = 'test-header'; + container.replaceChildren(content); + return true; + }, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), +}; + +function delay(advanceBy = 1) { + const promise = act(() => new Promise(resolve => setTimeout(resolve))); + jest.advanceTimersByTime(advanceBy); + return promise; +} + +beforeEach(() => { + jest.useFakeTimers(); +}); +afterEach(() => { + awsuiPluginsInternal.flashContent.clearRegisteredContent(); + jest.useRealTimers(); + jest.resetAllMocks(); +}); + +test('renders runtime content initially', async () => { + awsuiPlugins.flashContent.registerContent(defaultContent); + const { container } = render( + + ); + const wrapper = new FlashbarWrapper(container); + await delay(); + expect(screen.queryByTestId('test-content')).toBeTruthy(); + expect(wrapper.findItems()[0]!.findContent()!.getElement().textContent).toBe('New content'); +}); + +test('renders runtime content asynchronously', async () => { + render( + + ); + await delay(); + expect(screen.queryByTestId('test-content')).toBeFalsy(); + awsuiPlugins.flashContent.registerContent(defaultContent); + await delay(); + expect(screen.queryByTestId('test-content')).toBeTruthy(); +}); + +describe.each([true, false])('existing header:%p', existingHeader => { + test('renders runtime header initially', async () => { + awsuiPlugins.flashContent.registerContent(defaultContent); + const { container } = render( + + ); + const wrapper = new FlashbarWrapper(container); + await delay(); + expect(screen.queryByTestId('test-header')).toBeTruthy(); + expect(wrapper.findItems()[0].findHeader()!.getElement().textContent).toBe('New header'); + }); + + test('renders runtime header asynchronously', async () => { + const { container } = render( + + ); + const wrapper = new FlashbarWrapper(container); + await delay(); + expect(screen.queryByTestId('test-header')).toBeFalsy(); + awsuiPlugins.flashContent.registerContent(defaultContent); + await delay(); + expect(screen.queryByTestId('test-header')).toBeTruthy(); + expect(wrapper.findItems()[0].findHeader()!.getElement().textContent).toBe('New header'); + }); +}); + +describe('mountContent arguments', () => { + const mountContent = jest.fn(); + beforeEach(() => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountContent, + }; + awsuiPlugins.flashContent.registerContent(plugin); + }); + test('refs', async () => { + render( + Action button, + content: 'Flash content', + }, + ]} + /> + ); + await delay(); + expect(mountContent.mock.lastCall[1].headerRef.current).toHaveTextContent('Flash header'); + expect(mountContent.mock.lastCall[1].contentRef.current).toHaveTextContent('Flash content'); + expect(mountContent.mock.lastCall[1].actionsRef.current).toHaveTextContent('Action button'); + }); + test('type - default', async () => { + render(); + await delay(); + expect(mountContent.mock.lastCall[1].type).toBe('info'); + }); + test('type - custom', async () => { + render( + + ); + await delay(); + expect(mountContent.mock.lastCall[1].type).toBe('error'); + }); +}); + +test('multiple flashes', async () => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountContent: (container, context) => { + if (context.type === 'error') { + container.innerHTML = 'Replaced content'; + return true; + } + return false; + }, + }; + awsuiPlugins.flashContent.registerContent(plugin); + const { container } = render( + + ); + await delay(); + const wrapper = new FlashbarWrapper(container); + expect(wrapper.findItems()[0].findContent()?.getElement()).toHaveTextContent('Replaced content'); + expect(wrapper.findItems()[1].findContent()?.getElement()).toHaveTextContent('Flash content'); + expect(wrapper.findItems()[2].findContent()?.getElement()).toHaveTextContent('Replaced content'); +}); + +describe('unmounting', () => { + test('unmounts content and header', async () => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountContent: () => true, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), + }; + awsuiPlugins.flashContent.registerContent(plugin); + const { unmount } = render(); + await delay(); + expect(plugin.unmountContent).toBeCalledTimes(0); + expect(plugin.unmountHeader).toBeCalledTimes(0); + unmount(); + await delay(); + expect(plugin.unmountContent).toBeCalledTimes(1); + expect(plugin.unmountHeader).toBeCalledTimes(1); + }); + test('unmounts content and header (individual flash)', async () => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + mountContent: () => true, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), + }; + awsuiPlugins.flashContent.registerContent(plugin); + const { rerender } = render(); + await delay(); + expect(plugin.unmountContent).toBeCalledTimes(0); + expect(plugin.unmountHeader).toBeCalledTimes(0); + rerender(); + await delay(); + expect(plugin.unmountContent).toBeCalledTimes(1); + expect(plugin.unmountHeader).toBeCalledTimes(1); + }); + + test('does not unmount if not rendered', async () => { + const noRender: AlertFlashContentConfig = { + id: 'test-content', + mountContent: () => null, + mountHeader: () => null, + unmountContent: jest.fn(), + unmountHeader: jest.fn(), + }; + awsuiPlugins.flashContent.registerContent(noRender); + const { unmount } = render(); + await delay(); + unmount(); + await delay(); + expect(noRender.unmountContent).toBeCalledTimes(0); + expect(noRender.unmountHeader).toBeCalledTimes(0); + }); +}); + +describe('asynchronous rendering', () => { + test('allows asynchronous rendering of content', async () => { + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + mountContent: async container => { + await pause(1000); + const content = document.createElement('div'); + content.append('New content'); + content.dataset.testid = 'test-content-async'; + container.replaceChildren(content); + return true; + }, + }; + awsuiPlugins.flashContent.registerContent(asyncContent); + const { container } = render( + + ); + const wrapper = new FlashbarWrapper(container); + await delay(); + expect(screen.queryByTestId('test-content-async')).toBeFalsy(); + expect(wrapper.findItems()[0].findContent()!.getElement().textContent).toBe('Flash content'); + await delay(1000); + expect(screen.queryByTestId('test-content-async')).toBeTruthy(); + expect(wrapper.findItems()[0].findContent()!.getElement().textContent).toBe('New content'); + }); + + test('cancels asynchronous rendering when unmounting', async () => { + let rendered = false; + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + mountContent: async (container, { signal }) => { + await pause(1000); + if (!signal.aborted) { + rendered = true; + return true; + } + return null; + }, + }; + awsuiPlugins.flashContent.registerContent(asyncContent); + const { unmount } = render(); + await delay(500); + unmount(); + await delay(1000); + expect(rendered).toBeFalsy(); + }); +}); + +test('only uses first plugin to return a result', async () => { + const first: AlertFlashContentConfig = { + id: 'test-content-1', + mountContent: jest.fn(() => false), + }; + const second: AlertFlashContentConfig = { + id: 'test-content-2', + mountContent: jest.fn(), + mountHeader: jest.fn(), + }; + awsuiPlugins.flashContent.registerContent(first); + awsuiPlugins.flashContent.registerContent(second); + render(); + await delay(); + expect(first.mountContent).toBeCalled(); + expect(second.mountContent).not.toBeCalled(); + expect(second.mountHeader).not.toBeCalled(); +}); diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index f0a5e6b8188..95114068f16 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,16 @@ 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 { + headerRef: headerRefContent, + contentRef: contentRefContent, + actionsRef, + } = useDiscoveredContent({ type, header, children: content }); + + const headerRef = useMergeRefs(headerRefAction, headerRefContent); + const contentRef = useMergeRefs(contentRefAction, contentRefContent); const iconType = ICON_TYPES[type]; @@ -174,6 +184,7 @@ export const Flash = React.forwardRef( {dismissible && dismissButton(dismissLabel, handleDismiss)} - {ariaRole === 'status' && } + {ariaRole === 'status' && } ); } 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..e126dbc5e0a --- /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'; +import { sortByPriority } from '../helpers/utils'; + +// 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; + contentRef: RefShim; + actionsRef: RefShim; + signal: AbortSignal; +} + +export interface AlertFlashContentConfig { + id: string; + orderPriority?: number; + /** + * Return values for mountHeader and mountContent: + * - true: content was replaced + * - false: content was removed + * - null: nothing was done + */ + mountHeader?: (container: HTMLElement, context: AlertFlashContentContext) => boolean | null | Promise; + mountContent?: ( + container: HTMLElement, + context: AlertFlashContentContext + ) => boolean | null | Promise; + unmountHeader?: (container: HTMLElement) => void; + unmountContent?: (container: HTMLElement) => void; +} + +export interface AlertFlashContentRegistrationListener { + (providers: Array): void | (() => void); + cleanup?: void | (() => void); +} + +export interface AlertFlashContentApiPublic { + registerContent(config: AlertFlashContentConfig): void; +} + +export interface AlertFlashContentApiInternal { + clearRegisteredContent(): void; + onContentRegistered(listener: AlertFlashContentRegistrationListener): () => void; +} + +export class AlertFlashContentController { + private listeners: Array = []; + private providers: Array = []; + + private scheduleUpdate = debounce(() => { + this.listeners.forEach(listener => { + listener.cleanup = listener(this.providers); + }); + }, 0); + + registerContent = (content: AlertFlashContentConfig) => { + this.providers.push(content); + this.providers = sortByPriority(this.providers); + this.scheduleUpdate(); + }; + + clearRegisteredContent = () => { + this.providers = []; + }; + + 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.registerContent ??= this.registerContent; + return api as AlertFlashContentApiPublic; + } + + installInternal(internalApi: Partial = {}): AlertFlashContentApiInternal { + internalApi.clearRegisteredContent ??= this.clearRegisteredContent; + 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..b78feccfb7f --- /dev/null +++ b/src/internal/plugins/helpers/use-discovered-content.tsx @@ -0,0 +1,98 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { ReactNode, useEffect, useRef, useState } from 'react'; + +import { AlertFlashContentConfig, 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 actionsRef = useRef(null); + const foundProvider = useRef(null); + const [foundHeaderReplacement, setFoundHeaderReplacement] = useState(null); + const [foundContentReplacement, setFoundContentReplacement] = useState(null); + + useEffect(() => { + return onContentRegistered(providers => { + const controller = new AbortController(); + const runHeader = async (provider: AlertFlashContentConfig) => { + if ( + headerRef.current && + (!foundProvider.current || foundProvider.current === provider) && + provider.mountHeader + ) { + const result = await provider.mountHeader(headerRef.current, { + type, + headerRef, + contentRef, + actionsRef, + signal: controller.signal, + }); + if (result !== null) { + if (controller.signal.aborted) { + console.warn('[AwsUi] [Runtime alert/flash content] Async header returned after component unmounted'); + return; + } + foundProvider.current = provider; + setFoundHeaderReplacement(result); + } + } + }; + const runContent = async (provider: AlertFlashContentConfig) => { + if ( + contentRef.current && + (!foundProvider.current || foundProvider.current === provider) && + provider.mountContent + ) { + const result = await provider.mountContent(contentRef.current, { + type, + headerRef, + contentRef, + actionsRef, + signal: controller.signal, + }); + if (result !== null) { + if (controller.signal.aborted) { + console.warn('[AwsUi] [Runtime alert/flash content] Async content returned after component unmounted'); + return; + } + foundProvider.current = provider; + setFoundContentReplacement(result); + } + } + }; + (async () => { + for (const provider of providers) { + await Promise.all([runHeader(provider), runContent(provider)]); + if (controller.signal.aborted) { + break; + } + } + })(); + return () => { + controller.abort(); + headerRef.current && foundProvider.current?.unmountHeader?.(headerRef.current); + contentRef.current && foundProvider.current?.unmountContent?.(contentRef.current); + }; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, header, children]); + + return { + hasDiscoveredHeader: foundHeaderReplacement, + hasDiscoveredContent: foundContentReplacement, + headerRef, + contentRef, + actionsRef, + }; + }; +}