From a0393984d3c15cc90362604aa26eff5f56166074 Mon Sep 17 00:00:00 2001 From: Gethin Webster Date: Fri, 13 Sep 2024 14:23:42 +0200 Subject: [PATCH] chore: Add alert/flash content replacement runtime API (#2647) Co-authored-by: Andrei Zhaleznichenka --- pages/alert/runtime-content.page.tsx | 134 +++++++ pages/flashbar/runtime-content.page.tsx | 130 +++++++ src/alert/__tests__/alert.test.tsx | 34 ++ src/alert/__tests__/runtime-content-utils.tsx | 62 +++ src/alert/__tests__/runtime-content.test.tsx | 367 ++++++++++++++++++ src/alert/actions-wrapper/index.tsx | 6 +- src/alert/internal.tsx | 57 ++- src/alert/styles.scss | 10 +- .../__tests__/runtime-content.test.tsx | 309 +++++++++++++++ src/flashbar/flash.tsx | 47 ++- src/flashbar/styles.scss | 10 +- src/internal/plugins/api.ts | 17 + .../controllers/alert-flash-content.ts | 107 +++++ src/internal/plugins/helpers/index.ts | 1 + .../plugins/helpers/use-discovered-action.tsx | 6 +- .../helpers/use-discovered-content.tsx | 106 +++++ 16 files changed, 1379 insertions(+), 24 deletions(-) create mode 100644 pages/alert/runtime-content.page.tsx create mode 100644 pages/flashbar/runtime-content.page.tsx create mode 100644 src/alert/__tests__/runtime-content-utils.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 0000000000..589d2b552b --- /dev/null +++ b/pages/alert/runtime-content.page.tsx @@ -0,0 +1,134 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import React, { useContext, useMemo, useState } from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; + +import { + Alert, + AlertProps, + Box, + Button, + Checkbox, + ExpandableSection, + FormField, + Select, + SpaceBetween, +} from '~components'; +import awsuiPlugins from '~components/internal/plugins'; + +import AppContext, { AppContextType } from '../app/app-context'; +import ScreenshotArea from '../utils/screenshot-area'; + +type PageContext = React.Context>; + +awsuiPlugins.alertContent.registerContentReplacer({ + id: 'awsui/alert-test-action', + runReplacer(context, replacer) { + console.log('mount'); + + const doReplace = () => { + replacer.restoreHeader(); + replacer.restoreContent(); + if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) { + replacer.hideHeader(); + replacer.replaceContent(container => { + console.log('render replacement content'); + render( + + ---REPLACEMENT--- Access denied message! ---REPLACEMENT--- + + {context.contentRef.current?.textContent} + + , + container + ); + }); + } + }; + + doReplace(); + + return { + update() { + console.log('update'); + doReplace(); + }, + unmount({ replacementContentContainer }) { + console.log('unmount'); + unmountComponentAtNode(replacementContentContainer); + }, + }; + }, +}); + +const alertTypeOptions = ['error', 'warning', 'info', 'success'].map(type => ({ value: type })); + +export default function () { + const { + urlParams: { loading = false, hidden = false, type = 'error' }, + setUrlParams, + } = useContext(AppContext as PageContext); + const [unrelatedState, setUnrelatedState] = useState(false); + const [contentSwapped, setContentSwapped] = useState(false); + + const content1 = useMemo(() => (loading ? Loading... : Content), [loading]); + const content2 = loading ? Loading... : There was an error: Access denied because of XYZ; + + return ( + +

Alert runtime actions

+ + + setUrlParams({ loading: e.detail.checked })} checked={loading}> + Content loading + + setUrlParams({ hidden: e.detail.checked })} checked={hidden}> + Unmount all + + setUnrelatedState(e.detail.checked)} checked={unrelatedState}> + Unrelated state + + setContentSwapped(e.detail.checked)} checked={contentSwapped}> + Swap content + + + option.value === type) ?? messageTypeOptions[0]} + onChange={e => setUrlParams({ type: e.detail.selectedOption.value as FlashbarProps.Type })} + /> + + + +
+ + + {hidden ? null : ( + Action, + }, + { + type, + statusIconAriaLabel: type, + header: 'Header', + content: loading ? 'Loading...' : 'There was an error: Access denied because of XYZ', + action: , + }, + ]} + /> + )} + +
+
+ ); +} diff --git a/src/alert/__tests__/alert.test.tsx b/src/alert/__tests__/alert.test.tsx index 15d0ab150f..1db855989f 100644 --- a/src/alert/__tests__/alert.test.tsx +++ b/src/alert/__tests__/alert.test.tsx @@ -7,15 +7,25 @@ import '../../__a11y__/to-validate-a11y'; import Alert, { AlertProps } from '../../../lib/components/alert'; import Button from '../../../lib/components/button'; import { DATA_ATTR_ANALYTICS_ALERT } from '../../../lib/components/internal/analytics/selectors'; +import { useVisualRefresh } from '../../../lib/components/internal/hooks/use-visual-mode'; import createWrapper from '../../../lib/components/test-utils/dom'; import styles from '../../../lib/components/alert/styles.css.js'; +jest.mock('../../../lib/components/internal/hooks/use-visual-mode', () => ({ + ...jest.requireActual('../../../lib/components/internal/hooks/use-visual-mode'), + useVisualRefresh: jest.fn().mockReturnValue(false), +})); + function renderAlert(props: AlertProps = {}) { const { container } = render(); return { wrapper: createWrapper(container).findAlert()!, container }; } +beforeEach(() => { + jest.mocked(useVisualRefresh).mockReset(); +}); + describe('Alert Component', () => { describe('structure', () => { it('has no header container when no header is set', () => { @@ -153,4 +163,28 @@ describe('Alert Component', () => { expect(wrapper.getElement()).toHaveAttribute(DATA_ATTR_ANALYTICS_ALERT, 'success'); }); }); + + describe('icon size', () => { + test('classic - big if has header and content', () => { + const { wrapper } = renderAlert({ header: 'Header', children: ['Content'] }); + expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeFalsy(); + expect(wrapper.findByClassName(styles['icon-size-big'])).toBeTruthy(); + }); + test('classic - normal if only header', () => { + const { wrapper } = renderAlert({ header: 'Header' }); + expect(wrapper.findByClassName(styles['icon-size-big'])).toBeFalsy(); + expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeTruthy(); + }); + test('classic - normal if only content', () => { + const { wrapper } = renderAlert({ children: ['Content'] }); + expect(wrapper.findByClassName(styles['icon-size-big'])).toBeFalsy(); + expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeTruthy(); + }); + test('visual refresh - always normal', () => { + jest.mocked(useVisualRefresh).mockReturnValue(true); + const { wrapper } = renderAlert({ header: 'Header', children: ['Content'] }); + expect(wrapper.findByClassName(styles['icon-size-big'])).toBeFalsy(); + expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeTruthy(); + }); + }); }); diff --git a/src/alert/__tests__/runtime-content-utils.tsx b/src/alert/__tests__/runtime-content-utils.tsx new file mode 100644 index 0000000000..f1309ad87e --- /dev/null +++ b/src/alert/__tests__/runtime-content-utils.tsx @@ -0,0 +1,62 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { AlertWrapper } from '../../../lib/components/test-utils/dom'; +import FlashWrapper from '../../../lib/components/test-utils/dom/flashbar/flash.js'; + +import alertStyles from '../../../lib/components/alert/styles.selectors.js'; +import flashbarStyles from '../../../lib/components/flashbar/styles.selectors.js'; + +export const expectContent = ( + wrapper: AlertWrapper | FlashWrapper, + stylesCss: Record, + { + header, + headerReplaced, + content, + contentReplaced, + }: { + header?: string | false; + headerReplaced?: boolean; + content?: string | false; + contentReplaced?: boolean; + } +) => { + if (header) { + if (headerReplaced) { + if (wrapper.findHeader()) { + expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden); + } + expect(findReplacementHeader(wrapper)?.getElement().textContent).toBe(header); + } else { + expect(findReplacementHeader(wrapper)?.getElement()).toHaveClass(stylesCss.hidden); + expect(wrapper.findHeader()?.getElement().textContent).toBe(header); + } + } else if (header === false) { + if (wrapper.findHeader()) { + expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden); + } + expect(findReplacementHeader(wrapper)?.getElement()).toHaveClass(stylesCss.hidden); + } + if (content) { + if (contentReplaced) { + expect(wrapper.findContent()?.getElement()).toHaveClass(stylesCss.hidden); + expect(findReplacementContent(wrapper)?.getElement().textContent).toBe(content); + } else { + expect(findReplacementContent(wrapper)?.getElement()).toHaveClass(stylesCss.hidden); + expect(wrapper.findContent()?.getElement().textContent).toBe(content); + } + } else if (content === false) { + expect(wrapper.findContent()?.getElement()).toHaveClass(stylesCss.hidden); + expect(findReplacementContent(wrapper)?.getElement()).toHaveClass(stylesCss.hidden); + } +}; + +function findReplacementHeader(wrapper: AlertWrapper | FlashWrapper) { + const styles = wrapper instanceof AlertWrapper ? alertStyles : flashbarStyles; + return wrapper.findByClassName(styles['header-replacement']); +} + +function findReplacementContent(wrapper: AlertWrapper | FlashWrapper) { + const styles = wrapper instanceof AlertWrapper ? alertStyles : flashbarStyles; + return wrapper.findByClassName(styles['content-replacement'])!; +} diff --git a/src/alert/__tests__/runtime-content.test.tsx b/src/alert/__tests__/runtime-content.test.tsx new file mode 100644 index 0000000000..abbcd30c3a --- /dev/null +++ b/src/alert/__tests__/runtime-content.test.tsx @@ -0,0 +1,367 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { render, waitFor } 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 createWrapper from '../../../lib/components/test-utils/dom'; +import { expectContent } from './runtime-content-utils'; + +import stylesCss from '../../../lib/components/alert/styles.css.js'; + +const pause = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout)); + +const defaultContent: AlertFlashContentConfig = { + id: 'test-content', + runReplacer(context, replacer) { + replacer.replaceHeader(container => container.append('New header')); + replacer.replaceContent(container => container.append('New content')); + return { + update: () => {}, + unmount: () => {}, + }; + }, +}; + +beforeEach(() => { + jest.spyOn(console, 'warn').mockImplementation(); +}); + +afterEach(() => { + awsuiPluginsInternal.alertContent.clearRegisteredReplacer(); + jest.resetAllMocks(); + jest.restoreAllMocks(); +}); + +test('renders replacement content initially', () => { + awsuiPlugins.alertContent.registerContentReplacer(defaultContent); + render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + content: 'New content', + contentReplaced: true, + }); +}); + +test('renders replacement content when asynchronously registered', async () => { + render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + content: 'Alert content', + contentReplaced: false, + }); + + awsuiPlugins.alertContent.registerContentReplacer(defaultContent); + await waitFor(() => { + expectContent(alertWrapper, stylesCss, { + content: 'New content', + contentReplaced: true, + }); + }); +}); + +describe.each([true, false])('existing header:%p', existingHeader => { + test('renders replacement header initially', () => { + awsuiPlugins.alertContent.registerContentReplacer(defaultContent); + render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + header: 'New header', + headerReplaced: true, + }); + }); + + test('renders replacement header when asynchronously registered', async () => { + render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + header: existingHeader ? 'Header content' : undefined, + headerReplaced: false, + }); + + awsuiPlugins.alertContent.registerContentReplacer(defaultContent); + await waitFor(() => { + expectContent(alertWrapper, stylesCss, { + header: 'New header', + headerReplaced: true, + }); + }); + }); +}); + +test('restores content and header', async () => { + const { rerender } = render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + header: 'Alert header', + headerReplaced: false, + content: 'Alert content', + contentReplaced: false, + }); + + awsuiPlugins.alertContent.registerContentReplacer({ + id: 'test-content', + runReplacer(context, replacer) { + const runUpdate = () => { + if (context.headerRef.current?.textContent?.includes('Alert')) { + replacer.replaceHeader(container => container.append('New header')); + replacer.replaceContent(container => container.append('New content')); + } else { + replacer.restoreHeader(); + replacer.restoreContent(); + } + }; + runUpdate(); + return { + update: runUpdate, + unmount: () => {}, + }; + }, + }); + await waitFor(() => { + expectContent(alertWrapper, stylesCss, { + header: 'New header', + headerReplaced: true, + content: 'New content', + contentReplaced: true, + }); + }); + + rerender(Alert content); + await waitFor(() => { + expectContent(alertWrapper, stylesCss, { + header: 'Updated header', + headerReplaced: false, + content: 'Alert content', + contentReplaced: false, + }); + }); +}); + +test('removes styling if replacement is explicitly empty', () => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer(context, replacer) { + replacer.hideHeader(); + replacer.hideContent(); + return { + update: () => {}, + unmount: () => {}, + }; + }, + }; + awsuiPlugins.alertContent.registerContentReplacer(plugin); + render(Initial content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + header: false, + headerReplaced: true, + content: false, + contentReplaced: true, + }); +}); + +describe('runReplacer arguments', () => { + const runReplacer = jest.fn(); + beforeEach(() => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer, + }; + awsuiPlugins.alertContent.registerContentReplacer(plugin); + }); + test('refs', () => { + render( + Action button}> + Alert content + + ); + expect(runReplacer.mock.lastCall[0].headerRef.current).toHaveTextContent('Alert header'); + expect(runReplacer.mock.lastCall[0].contentRef.current).toHaveTextContent('Alert content'); + }); + test('type - default', () => { + render(); + expect(runReplacer.mock.lastCall[0].type).toBe('info'); + }); + test('type - custom', () => { + render(); + expect(runReplacer.mock.lastCall[0].type).toBe('error'); + }); +}); + +test('calls unmount callback', () => { + const unmountCallback = jest.fn(); + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer(context, replacer) { + replacer.replaceContent(container => container.append('New content')); + return { + update: () => {}, + unmount: unmountCallback, + }; + }, + }; + awsuiPlugins.alertContent.registerContentReplacer(plugin); + const { unmount } = render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { content: 'New content', contentReplaced: true }); + expect(unmountCallback).not.toBeCalled(); + + unmount(); + expect(unmountCallback).toBeCalled(); +}); + +test('calls update callback', () => { + const update = jest.fn(); + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer: jest.fn(() => ({ update, unmount: () => {} })), + }; + awsuiPlugins.alertContent.registerContentReplacer(plugin); + const { rerender } = render(Alert content); + expect(update).toBeCalledTimes(1); + expect(plugin.runReplacer).toBeCalledTimes(1); + + rerender(Alert new content); + expect(update).toBeCalledTimes(2); + expect(plugin.runReplacer).toBeCalledTimes(1); +}); + +describe('asynchronous rendering', () => { + test('allows asynchronous rendering of content', async () => { + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + runReplacer(context, replacer) { + (async () => { + await pause(500); + const content = document.createElement('div'); + content.append('New content'); + replacer.replaceContent(container => container.appendChild(content)); + })(); + return { + update: () => {}, + unmount: () => {}, + }; + }, + }; + awsuiPlugins.alertContent.registerContentReplacer(asyncContent); + render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + content: 'Alert content', + contentReplaced: false, + }); + + await waitFor(() => { + expectContent(alertWrapper, stylesCss, { + content: 'New content', + contentReplaced: true, + }); + }); + }); + + test('warns if registerReplacement called after unmounting', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const headerFn = jest.fn(); + const contentFn = jest.fn(); + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + runReplacer(context, replacer) { + (async () => { + await pause(500); + replacer.hideHeader(); + replacer.restoreHeader(); + replacer.replaceHeader(headerFn); + replacer.hideContent(); + replacer.restoreContent(); + replacer.replaceContent(contentFn); + })(); + return { + update: () => {}, + unmount: () => {}, + }; + }, + }; + awsuiPlugins.alertContent.registerContentReplacer(asyncContent); + const { unmount } = render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + content: 'Alert content', + contentReplaced: false, + }); + + unmount(); + + await waitFor(() => { + const message = (method: string) => + `[AwsUi] [Runtime alert content] \`${method}\` called after component unmounted`; + expect(consoleWarnSpy).toBeCalledWith(message('hideHeader')); + expect(consoleWarnSpy).toBeCalledWith(message('restoreHeader')); + expect(consoleWarnSpy).toBeCalledWith(message('replaceHeader')); + expect(consoleWarnSpy).toBeCalledWith(message('hideContent')); + expect(consoleWarnSpy).toBeCalledWith(message('restoreContent')); + expect(consoleWarnSpy).toBeCalledWith(message('replaceContent')); + expect(headerFn).not.toBeCalled(); + expect(contentFn).not.toBeCalled(); + }); + }); +}); + +test('calls replacer when alert type changes', () => { + const plugin: AlertFlashContentConfig = { + id: 'plugin', + runReplacer: (context, replacer) => { + if (context.type === 'error') { + replacer.replaceContent(container => (container.textContent = 'New error')); + } else if (context.type === 'warning') { + replacer.replaceContent(container => (container.textContent = 'New warning')); + } + return { update: () => {}, unmount: () => {} }; + }, + }; + awsuiPlugins.alertContent.registerContentReplacer(plugin); + + const { rerender } = render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { content: 'New error', contentReplaced: true }); + + rerender(Alert content); + expectContent(alertWrapper, stylesCss, { content: 'New warning', contentReplaced: true }); +}); + +test('can only register a single provider', () => { + const plugin1: AlertFlashContentConfig = { + id: 'plugin-1', + runReplacer: (context, replacer) => { + replacer.replaceContent(container => container.append('Replacement 1')); + return { update: () => {}, unmount: () => {} }; + }, + }; + const plugin2: AlertFlashContentConfig = { + id: 'plugin-2', + runReplacer: (context, replacer) => { + replacer.replaceContent(container => container.append('Replacement 2')); + return { update: () => {}, unmount: () => {} }; + }, + }; + + awsuiPlugins.alertContent.registerContentReplacer(plugin1); + awsuiPlugins.alertContent.registerContentReplacer(plugin2); + + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot call `registerContentReplacer` with new provider: provider with id "plugin-1" already registered.' + ) + ); + + render(Alert content); + const alertWrapper = createWrapper().findAlert()!; + expectContent(alertWrapper, stylesCss, { + content: 'Replacement 1', + contentReplaced: true, + }); +}); diff --git a/src/alert/actions-wrapper/index.tsx b/src/alert/actions-wrapper/index.tsx index ef0314720c..b4d536038c 100644 --- a/src/alert/actions-wrapper/index.tsx +++ b/src/alert/actions-wrapper/index.tsx @@ -41,14 +41,14 @@ interface ActionsWrapperProps { onButtonClick: InternalButtonProps['onClick']; } -export function ActionsWrapper({ +export const ActionsWrapper = ({ className, testUtilClasses, action, discoveredActions, buttonText, onButtonClick, -}: ActionsWrapperProps) { +}: ActionsWrapperProps) => { const actionButton = createActionButton(testUtilClasses, action, buttonText, onButtonClick); if (!actionButton && discoveredActions.length === 0) { return null; @@ -60,4 +60,4 @@ export function ActionsWrapper({ {discoveredActions} ); -} +}; diff --git a/src/alert/internal.tsx b/src/alert/internal.tsx index aac94d0788..5e84397d83 100644 --- a/src/alert/internal.tsx +++ b/src/alert/internal.tsx @@ -20,7 +20,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 { GeneratedAnalyticsMetadataAlertDismiss } from './analytics-metadata/interfaces'; @@ -39,6 +39,7 @@ const typeToIcon: Record = { type InternalAlertProps = SomeRequired & InternalBaseComponentProps; const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.alert.onActionRegistered); +const useDiscoveredContent = createUseDiscoveredContent('alert', awsuiPluginsInternal.alertContent.onContentRegistered); const InternalAlert = React.forwardRef( ( @@ -68,10 +69,25 @@ 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 { + headerReplacementType, + contentReplacementType, + headerRef: headerRefContent, + contentRef: contentRefContent, + replacementHeaderRef, + replacementContentRef, + } = 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' + : headerReplacementType !== 'remove' && header && contentReplacementType !== 'remove' && children + ? 'big' + : 'normal'; const hasAction = Boolean(action || buttonText || discoveredActions.length); @@ -104,14 +120,35 @@ const InternalAlert = React.forwardRef(
- {header && ( -
- {header} -
- )} -
+
+ {header} +
+
+
{children}
+
new Promise(resolve => setTimeout(resolve, timeout)); + +const defaultContent: AlertFlashContentConfig = { + id: 'test-content', + runReplacer(context, replacer) { + replacer.replaceHeader(container => container.append('New header')); + replacer.replaceContent(container => container.append('New content')); + return { + update: () => {}, + unmount: () => {}, + }; + }, +}; + +afterEach(() => { + awsuiPluginsInternal.flashContent.clearRegisteredReplacer(); + jest.resetAllMocks(); + jest.restoreAllMocks(); +}); + +test('renders runtime content initially', () => { + awsuiPlugins.flashContent.registerContentReplacer(defaultContent); + render(); + const flashbarWrapper = createWrapper().findFlashbar()!; + expectContent(flashbarWrapper.findItems()[0], stylesCss, { + content: 'New content', + contentReplaced: true, + }); +}); + +test('renders runtime content when asynchronously registered', async () => { + render(); + const flashbarWrapper = createWrapper().findFlashbar()!; + expectContent(flashbarWrapper.findItems()[0], stylesCss, { + content: 'Flash content', + contentReplaced: false, + }); + + awsuiPlugins.flashContent.registerContentReplacer(defaultContent); + await waitFor(() => { + expectContent(flashbarWrapper.findItems()[0], stylesCss, { + content: 'New content', + contentReplaced: true, + }); + }); +}); + +describe.each([true, false])('existing header:%p', existingHeader => { + test('renders runtime header initially', () => { + awsuiPlugins.flashContent.registerContentReplacer(defaultContent); + render( + + ); + const flashbarWrapper = createWrapper().findFlashbar()!; + expectContent(flashbarWrapper.findItems()[0], stylesCss, { + header: 'New header', + headerReplaced: true, + }); + }); + + test('renders runtime header when asynchronously registered', async () => { + render( + + ); + const flashWrapper = createWrapper().findFlashbar()!.findItems()[0]; + expectContent(flashWrapper, stylesCss, { + header: existingHeader ? 'Flash header' : undefined, + headerReplaced: false, + }); + + awsuiPlugins.flashContent.registerContentReplacer(defaultContent); + await waitFor(() => { + expectContent(flashWrapper, stylesCss, { + header: 'New header', + headerReplaced: true, + }); + }); + }); +}); + +test('restores content and header', async () => { + const { rerender } = render(); + const flashWrapper = createWrapper().findFlashbar()!.findItems()[0]; + expectContent(flashWrapper, stylesCss, { + header: 'Flash header', + headerReplaced: false, + content: 'Flash content', + contentReplaced: false, + }); + + awsuiPlugins.flashContent.registerContentReplacer({ + id: 'test-content', + runReplacer(context, replacer) { + const runUpdate = () => { + if (context.headerRef.current?.textContent?.includes('Flash')) { + replacer.replaceHeader(container => container.append('New header')); + replacer.replaceContent(container => container.append('New content')); + } else { + replacer.restoreHeader(); + replacer.restoreContent(); + } + }; + runUpdate(); + return { + update: runUpdate, + unmount: () => {}, + }; + }, + }); + await waitFor(() => { + expectContent(flashWrapper, stylesCss, { + header: 'New header', + headerReplaced: true, + content: 'New content', + contentReplaced: true, + }); + }); + + rerender(); + await waitFor(() => { + expectContent(flashWrapper, stylesCss, { + header: 'Updated header', + headerReplaced: false, + content: 'Flash content', + contentReplaced: false, + }); + }); +}); + +describe('runReplacer arguments', () => { + const runReplacer = jest.fn(); + beforeEach(() => { + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer, + }; + awsuiPlugins.flashContent.registerContentReplacer(plugin); + }); + test('refs', () => { + render( + Action button, + content: 'Flash content', + }, + ]} + /> + ); + expect(runReplacer.mock.lastCall[0].headerRef.current).toHaveTextContent('Flash header'); + expect(runReplacer.mock.lastCall[0].contentRef.current).toHaveTextContent('Flash content'); + }); + test('type - default', () => { + render(); + expect(runReplacer.mock.lastCall[0].type).toBe('info'); + }); + test('type - custom', () => { + render(); + expect(runReplacer.mock.lastCall[0].type).toBe('error'); + }); +}); + +test('calls unmount callback', () => { + const unmountCallback = jest.fn(); + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer(context, replacer) { + replacer.replaceContent(container => container.append('New content')); + return { + update: () => {}, + unmount: unmountCallback, + }; + }, + }; + awsuiPlugins.flashContent.registerContentReplacer(plugin); + const { unmount } = render(); + const flashbarWrapper = createWrapper().findFlashbar()!; + expectContent(flashbarWrapper.findItems()[0], stylesCss, { content: 'New content', contentReplaced: true }); + expect(unmountCallback).not.toBeCalled(); + + unmount(); + expect(unmountCallback).toBeCalled(); +}); + +test('calls update callback', () => { + const update = jest.fn(); + const plugin: AlertFlashContentConfig = { + id: 'test-content', + runReplacer: jest.fn(() => ({ update, unmount: () => {} })), + }; + awsuiPlugins.flashContent.registerContentReplacer(plugin); + const { rerender } = render(); + expect(update).toBeCalledTimes(1); + expect(plugin.runReplacer).toBeCalledTimes(1); + + rerender(); + expect(update).toBeCalledTimes(2); + expect(plugin.runReplacer).toBeCalledTimes(1); +}); + +describe('asynchronous rendering', () => { + test('allows asynchronous rendering of content', async () => { + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + runReplacer(context, replacer) { + (async () => { + await pause(500); + const content = document.createElement('div'); + content.append('New content'); + replacer.replaceContent(container => container.appendChild(content)); + })(); + return { + update: () => {}, + unmount: () => {}, + }; + }, + }; + awsuiPlugins.flashContent.registerContentReplacer(asyncContent); + render(); + const flashWrapper = createWrapper().findFlashbar()!.findItems()[0]; + expectContent(flashWrapper, stylesCss, { + content: 'Flash content', + contentReplaced: false, + }); + + await waitFor(() => { + expectContent(flashWrapper, stylesCss, { + content: 'New content', + contentReplaced: true, + }); + }); + }); + + test('warns if registerReplacement called after unmounting', async () => { + const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const headerFn = jest.fn(); + const contentFn = jest.fn(); + const asyncContent: AlertFlashContentConfig = { + id: 'test-content-async', + runReplacer(context, replacer) { + (async () => { + await pause(500); + replacer.hideHeader(); + replacer.restoreHeader(); + replacer.replaceHeader(headerFn); + replacer.hideContent(); + replacer.restoreContent(); + replacer.replaceContent(contentFn); + })(); + return { + update: () => {}, + unmount: () => {}, + }; + }, + }; + awsuiPlugins.flashContent.registerContentReplacer(asyncContent); + const { unmount } = render(); + const flashWrapper = createWrapper().findFlashbar()!.findItems()[0]; + expectContent(flashWrapper, stylesCss, { + content: 'Flash content', + contentReplaced: false, + }); + + unmount(); + + await waitFor(() => { + const message = (method: string) => + `[AwsUi] [Runtime flash content] \`${method}\` called after component unmounted`; + expect(consoleWarnSpy).toBeCalledWith(message('hideHeader')); + expect(consoleWarnSpy).toBeCalledWith(message('restoreHeader')); + expect(consoleWarnSpy).toBeCalledWith(message('replaceHeader')); + expect(consoleWarnSpy).toBeCalledWith(message('hideContent')); + expect(consoleWarnSpy).toBeCalledWith(message('restoreContent')); + expect(consoleWarnSpy).toBeCalledWith(message('replaceContent')); + expect(headerFn).not.toBeCalled(); + expect(contentFn).not.toBeCalled(); + }); + }); +}); diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index cd83f8ed5c..ecd0cca1d5 100644 --- a/src/flashbar/flash.tsx +++ b/src/flashbar/flash.tsx @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React from 'react'; +import React, { useRef } from 'react'; import clsx from 'clsx'; import { useComponentMetadata, warnOnce } from '@cloudscape-design/component-toolkit/internal'; @@ -18,7 +18,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 { GeneratedAnalyticsMetadataFlashbarDismiss } from './analytics-metadata/interfaces'; @@ -38,6 +38,7 @@ const ICON_TYPES = { } as const; const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.flashbar.onActionRegistered); +const useDiscoveredContent = createUseDiscoveredContent('flash', awsuiPluginsInternal.flashContent.onContentRegistered); function dismissButton( dismissLabel: FlashbarProps.MessageDefinition['dismissLabel'], @@ -118,7 +119,21 @@ 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 headerRefObject = useRef(null); + const contentRefObject = useRef(null); + const { discoveredActions, headerRef: headerRefAction, contentRef: contentRefAction } = useDiscoveredAction(type); + const { + headerReplacementType, + contentReplacementType, + headerRef: headerRefContent, + contentRef: contentRefContent, + replacementHeaderRef, + replacementContentRef, + } = useDiscoveredContent({ type, header, children: content }); + + const headerRef = useMergeRefs(headerRefAction, headerRefContent, headerRefObject); + const contentRef = useMergeRefs(contentRefAction, contentRefContent, contentRefObject); const iconType = ICON_TYPES[type]; @@ -173,12 +188,32 @@ 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 79513f5374..638419326d 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, +.header-replacement { font-weight: styles.$font-weight-bold; } -.flash-content { +.flash-content, +.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 0a865d8b8b..718cb4abe5 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 0000000000..ea9722ece1 --- /dev/null +++ b/src/internal/plugins/controllers/alert-flash-content.ts @@ -0,0 +1,107 @@ +// 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; + contentRef: RefShim; +} + +export type ReplacementType = 'original' | 'remove' | 'replaced'; + +export interface ReplacementApi { + hideHeader(): void; + restoreHeader(): void; + replaceHeader(replacer: (container: HTMLElement) => void): void; + hideContent(): void; + restoreContent(): void; + replaceContent(replacer: (container: HTMLElement) => void): void; +} + +export interface AlertFlashContentResult { + update: () => void; + unmount: (containers: { replacementHeaderContainer: HTMLElement; replacementContentContainer: HTMLElement }) => void; +} + +export interface AlertFlashContentConfig { + id: string; + runReplacer: (context: AlertFlashContentContext, replacementApi: ReplacementApi) => AlertFlashContentResult; +} + +export type AlertFlashContentRegistrationListener = (provider: AlertFlashContentConfig) => () => void; + +export interface AlertFlashContentApiPublic { + registerContentReplacer(config: AlertFlashContentConfig): void; +} + +export interface AlertFlashContentApiInternal { + clearRegisteredReplacer(): void; + onContentRegistered(listener: AlertFlashContentRegistrationListener): () => void; +} + +export class AlertFlashContentController { + #listeners: Array = []; + #cleanups = new Map void>(); + #provider?: AlertFlashContentConfig; + + #scheduleUpdate = debounce( + () => + this.#listeners.forEach(listener => { + if (this.#provider) { + const cleanup = listener(this.#provider); + this.#cleanups.set(listener, cleanup); + } + }), + 0 + ); + + registerContentReplacer = (content: AlertFlashContentConfig) => { + if (this.#provider) { + console.warn( + `Cannot call \`registerContentReplacer\` with new provider: provider with id "${this.#provider.id}" already registered.` + ); + return; + } + this.#provider = content; + + // Notify existing components if registration happens after the components are rendered. + this.#scheduleUpdate(); + }; + + clearRegisteredReplacer = () => { + this.#provider = undefined; + }; + + onContentRegistered = (listener: AlertFlashContentRegistrationListener) => { + if (this.#provider) { + const cleanup = listener(this.#provider); + this.#listeners.push(listener); + this.#cleanups.set(listener, cleanup); + } else { + this.#listeners.push(listener); + } + return () => { + this.#cleanups.get(listener)?.(); + this.#listeners = this.#listeners.filter(item => item !== listener); + this.#cleanups.delete(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 435d0288dc..ef2bbee9f8 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-action.tsx b/src/internal/plugins/helpers/use-discovered-action.tsx index c8f509e0b1..498784e313 100644 --- a/src/internal/plugins/helpers/use-discovered-action.tsx +++ b/src/internal/plugins/helpers/use-discovered-action.tsx @@ -19,7 +19,11 @@ function convertRuntimeAction(action: ActionConfig | null, context: ActionContex } export function createUseDiscoveredAction(onActionRegistered: ActionButtonsController['onActionRegistered']) { - return function useDiscoveredAction(type: string) { + return function useDiscoveredAction(type: string): { + discoveredActions: React.ReactNode[]; + headerRef: React.Ref; + contentRef: React.Ref; + } { const [discoveredActions, setDiscoveredActions] = useState>([]); const headerRef = useRef(null); const contentRef = useRef(null); 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 0000000000..3b46c4d7af --- /dev/null +++ b/src/internal/plugins/helpers/use-discovered-content.tsx @@ -0,0 +1,106 @@ +// 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, + AlertFlashContentResult, + ReplacementType, +} from '../controllers/alert-flash-content'; + +export function createUseDiscoveredContent( + componentName: string, + onContentRegistered: AlertFlashContentController['onContentRegistered'] +) { + return function useDiscoveredContent({ + type, + header, + children, + }: { + type: string; + header: ReactNode; + children: ReactNode; + }) { + const headerRef = useRef(null); + const contentRef = useRef(null); + const replacementHeaderRef = useRef(null); + const replacementContentRef = useRef(null); + const [headerReplacementType, setFoundHeaderReplacement] = useState('original'); + const [contentReplacementType, setFoundContentReplacement] = useState('original'); + const mountedProvider = useRef(); + + useEffect(() => { + const context = { type, headerRef, contentRef }; + + return onContentRegistered(provider => { + let mounted = true; + + function checkMounted(methodName: string) { + if (!mounted) { + console.warn( + `[AwsUi] [Runtime ${componentName} content] \`${methodName}\` called after component unmounted` + ); + return false; + } + return true; + } + + mountedProvider.current = provider.runReplacer(context, { + hideHeader() { + if (checkMounted('hideHeader')) { + setFoundHeaderReplacement('remove'); + } + }, + restoreHeader() { + if (checkMounted('restoreHeader')) { + setFoundHeaderReplacement('original'); + } + }, + replaceHeader(replacer: (container: HTMLElement) => void) { + if (checkMounted('replaceHeader')) { + replacer(replacementHeaderRef.current!); + setFoundHeaderReplacement('replaced'); + } + }, + hideContent() { + if (checkMounted('hideContent')) { + setFoundContentReplacement('remove'); + } + }, + restoreContent() { + if (checkMounted('restoreContent')) { + setFoundContentReplacement('original'); + } + }, + replaceContent(replacer: (container: HTMLElement) => void) { + if (checkMounted('replaceContent')) { + replacer(replacementContentRef.current!); + setFoundContentReplacement('replaced'); + } + }, + }); + + return () => { + mountedProvider.current?.unmount({ + replacementHeaderContainer: replacementHeaderRef.current!, + replacementContentContainer: replacementContentRef.current!, + }); + mounted = false; + }; + }); + }, [type]); + + useEffect(() => { + mountedProvider.current?.update(); + }, [type, header, children]); + + return { + headerReplacementType, + contentReplacementType, + headerRef: headerRef as React.Ref, + replacementHeaderRef: replacementHeaderRef as React.Ref, + contentRef: contentRef as React.Ref, + replacementContentRef: replacementContentRef as React.Ref, + }; + }; +}