From e2002cdad43085504645a627c73435674e75855d Mon Sep 17 00:00:00 2001 From: Boris Serdiuk Date: Fri, 11 Aug 2023 15:36:58 +0200 Subject: [PATCH] chore: Add runtime actions discovery to alert and flashbar (#1430) --- pages/alert/runtime-action.page.tsx | 74 +++++++ pages/flashbar/runtime-action.page.tsx | 74 +++++++ src/alert/__tests__/runtime-action.test.tsx | 119 +++++++++++ src/alert/actions-wrapper/index.tsx | 52 +++++ src/alert/actions-wrapper/styles.scss | 16 ++ src/alert/internal.tsx | 38 ++-- src/alert/styles.scss | 1 + .../__tests__/runtime-drawers.test.tsx | 2 +- src/app-layout/runtime-api.tsx | 35 +--- .../__tests__/runtime-action.test.tsx | 194 ++++++++++++++++++ src/flashbar/flash.tsx | 36 ++-- src/flashbar/styles.scss | 3 +- src/internal/plugins/api.ts | 41 +++- .../__tests__/action-buttons.test.ts | 92 +++++++++ .../__tests__/drawers.test.ts} | 2 +- .../plugins/controllers/action-buttons.ts | 51 +++++ .../drawers.ts} | 17 +- src/internal/plugins/helpers/index.ts | 4 + .../helpers/runtime-content-wrapper.tsx | 21 ++ .../plugins/helpers/use-discovered-action.tsx | 34 +++ src/internal/plugins/helpers/utils.ts | 10 + src/test-utils/dom/alert/index.ts | 2 +- src/test-utils/dom/flashbar/flash.ts | 2 +- 23 files changed, 844 insertions(+), 76 deletions(-) create mode 100644 pages/alert/runtime-action.page.tsx create mode 100644 pages/flashbar/runtime-action.page.tsx create mode 100644 src/alert/__tests__/runtime-action.test.tsx create mode 100644 src/alert/actions-wrapper/index.tsx create mode 100644 src/alert/actions-wrapper/styles.scss create mode 100644 src/flashbar/__tests__/runtime-action.test.tsx create mode 100644 src/internal/plugins/controllers/__tests__/action-buttons.test.ts rename src/internal/plugins/{__tests__/drawers-controller.test.ts => controllers/__tests__/drawers.test.ts} (95%) create mode 100644 src/internal/plugins/controllers/action-buttons.ts rename src/internal/plugins/{drawers-controller.ts => controllers/drawers.ts} (72%) create mode 100644 src/internal/plugins/helpers/index.ts create mode 100644 src/internal/plugins/helpers/runtime-content-wrapper.tsx create mode 100644 src/internal/plugins/helpers/use-discovered-action.tsx create mode 100644 src/internal/plugins/helpers/utils.ts diff --git a/pages/alert/runtime-action.page.tsx b/pages/alert/runtime-action.page.tsx new file mode 100644 index 0000000000..f568fa51bd --- /dev/null +++ b/pages/alert/runtime-action.page.tsx @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import Alert, { AlertProps } from '~components/alert'; +import Button from '~components/button'; +import SpaceBetween from '~components/space-between'; +import awsuiPlugins from '~components/internal/plugins'; +import ScreenshotArea from '../utils/screenshot-area'; +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; + +awsuiPlugins.alert.registerAction({ + id: 'awsui/alert-test-action', + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + render( + , + container + ); + }, + unmountContent: container => unmountComponentAtNode(container), +}); + +/* eslint-disable react/jsx-key */ +const permutations = createPermutations([ + { + dismissible: [true, false], + header: ['Alert'], + children: ['Content'], + type: ['success', 'error'], + action: [ + null, + , + + + + , + ], + }, +]); +/* eslint-enable react/jsx-key */ + +export default function () { + return ( + <> +

Alert runtime actions

+ + ( + + )} + /> + + + ); +} diff --git a/pages/flashbar/runtime-action.page.tsx b/pages/flashbar/runtime-action.page.tsx new file mode 100644 index 0000000000..c93f9120ca --- /dev/null +++ b/pages/flashbar/runtime-action.page.tsx @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import Flashbar, { FlashbarProps } from '~components/flashbar'; +import Button from '~components/button'; +import SpaceBetween from '~components/space-between'; +import awsuiPlugins from '~components/internal/plugins'; +import ScreenshotArea from '../utils/screenshot-area'; +import createPermutations from '../utils/permutations'; +import PermutationsView from '../utils/permutations-view'; + +awsuiPlugins.flashbar.registerAction({ + id: 'awsui/flashbar-test-action', + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + render( + , + container + ); + }, + unmountContent: container => unmountComponentAtNode(container), +}); + +/* eslint-disable react/jsx-key */ +const permutations = createPermutations([ + { + dismissible: [true, false], + header: ['Flash message'], + content: ['Content'], + type: ['success', 'error'], + action: [ + null, + , + + + + , + ], + }, +]); +/* eslint-enable react/jsx-key */ + +export default function () { + return ( + <> +

Flashbar runtime actions

+ + ( + + )} + /> + + + ); +} diff --git a/src/alert/__tests__/runtime-action.test.tsx b/src/alert/__tests__/runtime-action.test.tsx new file mode 100644 index 0000000000..3f06a4ecf3 --- /dev/null +++ b/src/alert/__tests__/runtime-action.test.tsx @@ -0,0 +1,119 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Alert from '../../../lib/components/alert'; +import awsuiPlugins from '../../../lib/components/internal/plugins'; +import { ActionConfig } from '../../../lib/components/internal/plugins/controllers/action-buttons'; +import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api'; + +const defaultAction: ActionConfig = { + id: 'test-action', + mountContent: container => { + const button = document.createElement('button'); + button.dataset.testid = 'test-action'; + container.appendChild(button); + }, + unmountContent: container => (container.innerHTML = ''), +}; + +function delay() { + return act(() => new Promise(resolve => setTimeout(resolve))); +} + +afterEach(() => { + awsuiPluginsInternal.alert.clearRegisteredActions(); +}); + +test('renders runtime action button initially', async () => { + awsuiPlugins.alert.registerAction(defaultAction); + render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action button asynchronously', async () => { + render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeFalsy(); + awsuiPlugins.alert.registerAction(defaultAction); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action along with the props one', async () => { + awsuiPlugins.alert.registerAction(defaultAction); + render(test} />); + await delay(); + expect(screen.queryByTestId('own-button')).toBeTruthy(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action button on multiple instances', async () => { + awsuiPlugins.alert.registerAction(defaultAction); + render( + <> + + + + ); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(2); +}); + +test('propagates alert context into callback', async () => { + const onClick = jest.fn(); + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + const button = document.createElement('button'); + button.dataset.testid = 'test-action'; + button.onclick = () => onClick(context); + container.appendChild(button); + }, + }; + awsuiPlugins.alert.registerAction(testAction); + render(Test content); + await delay(); + fireEvent.click(screen.getByTestId('test-action')); + expect(onClick).toHaveBeenCalledWith({ + type: 'info', + headerRef: { current: expect.any(HTMLElement) }, + contentRef: { current: expect.any(HTMLElement) }, + }); +}); + +test('allows skipping rendering actions', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + defaultAction.mountContent(container, context); + }, + }; + awsuiPlugins.alert.registerAction(testAction); + const { rerender } = render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeFalsy(); + rerender(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('cleans up on unmount', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: jest.fn(), + unmountContent: jest.fn(), + }; + awsuiPlugins.alert.registerAction(testAction); + const { rerender } = render(); + await delay(); + expect(testAction.mountContent).toHaveBeenCalledTimes(1); + expect(testAction.unmountContent).toHaveBeenCalledTimes(0); + rerender(<>); + expect(testAction.mountContent).toHaveBeenCalledTimes(1); + expect(testAction.unmountContent).toHaveBeenCalledTimes(1); +}); diff --git a/src/alert/actions-wrapper/index.tsx b/src/alert/actions-wrapper/index.tsx new file mode 100644 index 0000000000..642e35f178 --- /dev/null +++ b/src/alert/actions-wrapper/index.tsx @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import clsx from 'clsx'; +import React from 'react'; +import styles from './styles.css.js'; +import InternalButton, { InternalButtonProps } from '../../button/internal'; + +function createActionButton( + testUtilClasses: ActionsWrapperProps['testUtilClasses'], + action: React.ReactNode, + buttonText: React.ReactNode, + onButtonClick: InternalButtonProps['onClick'] +) { + if (!action && buttonText) { + action = ( + + {buttonText} + + ); + } + return action ?
{action}
: null; +} + +interface ActionsWrapperProps { + className: string; + testUtilClasses: { actionSlot: string; actionButton: string }; + action: React.ReactNode; + discoveredActions: Array; + buttonText: React.ReactNode; + 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; + } + + return ( +
+ {actionButton} + {discoveredActions} +
+ ); +} diff --git a/src/alert/actions-wrapper/styles.scss b/src/alert/actions-wrapper/styles.scss new file mode 100644 index 0000000000..53d3649ca3 --- /dev/null +++ b/src/alert/actions-wrapper/styles.scss @@ -0,0 +1,16 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../../internal/styles/tokens' as awsui; + +.root { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: awsui.$space-xs; + & > :empty { + display: none; + } +} diff --git a/src/alert/internal.tsx b/src/alert/internal.tsx index 1226e2375b..9ef4b128d1 100644 --- a/src/alert/internal.tsx +++ b/src/alert/internal.tsx @@ -19,6 +19,9 @@ import { SomeRequired } from '../internal/types'; import { useInternalI18n } from '../i18n/context'; import { DATA_ATTR_ANALYTICS_ALERT } from '../internal/analytics/selectors'; import { LinkDefaultVariantContext } from '../internal/context/link-default-variant-context'; +import { createUseDiscoveredAction } from '../internal/plugins/helpers'; +import { awsuiPluginsInternal } from '../internal/plugins/api'; +import { ActionsWrapper } from './actions-wrapper'; const typeToIcon: Record = { error: 'status-negative', @@ -29,6 +32,8 @@ const typeToIcon: Record = { type InternalAlertProps = SomeRequired & InternalBaseComponentProps; +const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.alert.onActionRegistered); + const InternalAlert = React.forwardRef( ( { @@ -60,17 +65,8 @@ const InternalAlert = React.forwardRef( const isRefresh = useVisualRefresh(); const size = isRefresh ? 'normal' : header && children ? 'big' : 'normal'; - const actionButton = action || ( - fireNonCancelableEvent(onButtonClick)} - formAction="none" - > - {buttonText} - - ); + const { discoveredActions, headerRef, contentRef } = useDiscoveredAction(type); - const hasAction = Boolean(action || buttonText); const analyticsAttributes = { [DATA_ATTR_ANALYTICS_ALERT]: type, }; @@ -98,12 +94,28 @@ const InternalAlert = React.forwardRef(
- {header &&
{header}
} -
{children}
+ {header && ( +
+ {header} +
+ )} +
+ {children} +
- {hasAction &&
{actionButton}
} + fireNonCancelableEvent(onButtonClick)} + /> {dismissible && (
diff --git a/src/alert/styles.scss b/src/alert/styles.scss index fd74a600f8..1130cbef0f 100644 --- a/src/alert/styles.scss +++ b/src/alert/styles.scss @@ -50,6 +50,7 @@ margin-left: awsui.$space-alert-action-left; } +.action-slot, .action-button { /* used in test-utils */ } diff --git a/src/app-layout/__tests__/runtime-drawers.test.tsx b/src/app-layout/__tests__/runtime-drawers.test.tsx index 86c9b77b75..6a00214796 100644 --- a/src/app-layout/__tests__/runtime-drawers.test.tsx +++ b/src/app-layout/__tests__/runtime-drawers.test.tsx @@ -5,7 +5,7 @@ import { act, render } from '@testing-library/react'; import AppLayout from '../../../lib/components/app-layout'; import { InternalDrawerProps } from '../../../lib/components/app-layout/drawer/interfaces'; import { awsuiPlugins, awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api'; -import { DrawerConfig } from '../../../lib/components/internal/plugins/drawers-controller'; +import { DrawerConfig } from '../../../lib/components/internal/plugins/controllers/drawers'; import createWrapper from '../../../lib/components/test-utils/dom'; beforeEach(() => { diff --git a/src/app-layout/runtime-api.tsx b/src/app-layout/runtime-api.tsx index 43761459f5..18a5107884 100644 --- a/src/app-layout/runtime-api.tsx +++ b/src/app-layout/runtime-api.tsx @@ -1,26 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef } from 'react'; -import { DrawerConfig as RuntimeDrawerConfig } from '../internal/plugins/drawers-controller'; +import React from 'react'; +import { DrawerConfig as RuntimeDrawerConfig } from '../internal/plugins/controllers/drawers'; +import { RuntimeContentWrapper } from '../internal/plugins/helpers'; import { DrawerItem } from './drawer/interfaces'; - -interface RuntimeContentWrapperProps { - mountContent: RuntimeDrawerConfig['mountContent']; - unmountContent: RuntimeDrawerConfig['unmountContent']; -} - -function RuntimeContentWrapper({ mountContent, unmountContent }: RuntimeContentWrapperProps) { - const ref = useRef(null); - - useEffect(() => { - const container = ref.current!; - mountContent(container); - return () => unmountContent(container); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return
; -} +import { sortByPriority } from '../internal/plugins/helpers/utils'; export interface DrawersLayout { before: Array; @@ -40,14 +24,9 @@ export function convertRuntimeDrawers(drawers: Array): Draw ), })); - converted.sort((a, b) => { - if (b.orderPriority !== a.orderPriority) { - return Math.sign((b.orderPriority ?? 0) - (a.orderPriority ?? 0)); - } - return b.id < a.id ? 1 : -1; - }); + const sorted = sortByPriority(converted); return { - before: converted.filter(item => (item.orderPriority ?? 0) > 0), - after: converted.filter(item => (item.orderPriority ?? 0) <= 0), + before: sorted.filter(item => (item.orderPriority ?? 0) > 0), + after: sorted.filter(item => (item.orderPriority ?? 0) <= 0), }; } diff --git a/src/flashbar/__tests__/runtime-action.test.tsx b/src/flashbar/__tests__/runtime-action.test.tsx new file mode 100644 index 0000000000..201ded1046 --- /dev/null +++ b/src/flashbar/__tests__/runtime-action.test.tsx @@ -0,0 +1,194 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Flashbar, { FlashbarProps } from '../../../lib/components/flashbar'; +import awsuiPlugins from '../../../lib/components/internal/plugins'; +import { ActionConfig } from '../../../lib/components/internal/plugins/controllers/action-buttons'; +import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api'; +import createWrapper, { ElementWrapper } from '../../../lib/components/test-utils/dom'; + +const defaultAction: ActionConfig = { + id: 'test-action', + mountContent: container => { + const button = document.createElement('button'); + button.dataset.testid = 'test-action'; + container.appendChild(button); + }, + unmountContent: container => (container.innerHTML = ''), +}; + +const defaultItem: FlashbarProps.MessageDefinition = {}; + +function delay() { + return act(() => new Promise(resolve => setTimeout(resolve))); +} + +afterEach(() => { + awsuiPluginsInternal.flashbar.clearRegisteredActions(); +}); + +test('renders runtime action button initially', async () => { + awsuiPlugins.flashbar.registerAction(defaultAction); + render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action button asynchronously', async () => { + render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeFalsy(); + awsuiPlugins.flashbar.registerAction(defaultAction); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action along with the props one', async () => { + awsuiPlugins.flashbar.registerAction(defaultAction); + render(test }]} />); + await delay(); + expect(screen.queryByTestId('own-button')).toBeTruthy(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action button on multiple instances', async () => { + awsuiPlugins.flashbar.registerAction(defaultAction); + render( + + ); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(2); +}); + +test('renders multiple actions on multiple instances', async () => { + awsuiPlugins.flashbar.registerAction({ + ...defaultAction, + id: 'first', + mountContent: (container, context) => { + const button = document.createElement('button'); + button.dataset.testid = 'runtime-action'; + button.textContent = context.type + '-first'; + container.appendChild(button); + }, + }); + awsuiPlugins.flashbar.registerAction({ + ...defaultAction, + id: 'second', + mountContent: (container, context) => { + const button = document.createElement('button'); + button.dataset.testid = 'runtime-action'; + button.textContent = context.type + '-second'; + container.appendChild(button); + }, + }); + render( + + ); + await delay(); + const items = createWrapper().findFlashbar()!.findItems(); + const getTextContent = (wrapper: ElementWrapper) => wrapper.getElement().textContent; + expect(items[0].findAll('button[data-testid="runtime-action"]').map(getTextContent)).toEqual([ + 'success-first', + 'success-second', + ]); + expect(items[1].findAll('button[data-testid="runtime-action"]').map(getTextContent)).toEqual([ + 'error-first', + 'error-second', + ]); +}); + +test('allows skipping actions for some items', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + defaultAction.mountContent(container, context); + }, + }; + awsuiPlugins.flashbar.registerAction(testAction); + render( + + ); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(1); +}); + +test('maintains action button state when dismissing a message', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + defaultAction.mountContent(container, context); + }, + }; + awsuiPlugins.flashbar.registerAction(testAction); + const items: Array = [ + { ...defaultItem, id: '1', type: 'info' }, + { ...defaultItem, id: '2', type: 'error' }, + ]; + const { rerender } = render(); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(1); + + rerender(); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(1); +}); + +test('propagates flash message context into callback', async () => { + const onClick = jest.fn(); + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + const button = document.createElement('button'); + button.dataset.testid = 'test-action'; + button.onclick = () => onClick(context); + container.appendChild(button); + }, + }; + awsuiPlugins.flashbar.registerAction(testAction); + render(); + await delay(); + fireEvent.click(screen.getByTestId('test-action')); + expect(onClick).toHaveBeenCalledWith({ + type: 'info', + headerRef: { current: expect.any(HTMLElement) }, + contentRef: { current: expect.any(HTMLElement) }, + }); +}); + +test('cleans up on unmount', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: jest.fn(), + unmountContent: jest.fn(), + }; + awsuiPlugins.flashbar.registerAction(testAction); + const { rerender } = render(); + await delay(); + expect(testAction.mountContent).toHaveBeenCalledTimes(1); + expect(testAction.unmountContent).toHaveBeenCalledTimes(0); + rerender(<>); + expect(testAction.mountContent).toHaveBeenCalledTimes(1); + expect(testAction.unmountContent).toHaveBeenCalledTimes(1); +}); diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index 9807326718..228f729e96 100644 --- a/src/flashbar/flash.tsx +++ b/src/flashbar/flash.tsx @@ -18,6 +18,9 @@ import { sendDismissMetric } from './internal/analytics'; import { FOCUS_THROTTLE_DELAY } from './utils'; import { DATA_ATTR_ANALYTICS_FLASHBAR } from '../internal/analytics/selectors'; +import { createUseDiscoveredAction } from '../internal/plugins/helpers'; +import { awsuiPluginsInternal } from '../internal/plugins/api'; +import { ActionsWrapper } from '../alert/actions-wrapper'; const ICON_TYPES = { success: 'status-positive', @@ -27,16 +30,7 @@ const ICON_TYPES = { 'in-progress': 'status-in-progress', } as const; -function actionButton( - buttonText: FlashbarProps.MessageDefinition['buttonText'], - onButtonClick: FlashbarProps.MessageDefinition['onButtonClick'] -) { - return ( - - {buttonText} - - ); -} +const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.flashbar.onActionRegistered); function dismissButton( dismissLabel: FlashbarProps.MessageDefinition['dismissLabel'], @@ -109,7 +103,7 @@ export const Flash = React.forwardRef( } } - const button = action || (buttonText && actionButton(buttonText, onButtonClick)); + const { discoveredActions, headerRef, contentRef } = useDiscoveredAction(type); const iconType = ICON_TYPES[type]; @@ -163,11 +157,25 @@ export const Flash = React.forwardRef( {icon}
-
{header}
-
{content}
+
+ {header} +
+
+ {content} +
- {button &&
{button}
} + {dismissible && dismissButton(dismissLabel, handleDismiss)} {ariaRole === 'status' && ( diff --git a/src/flashbar/styles.scss b/src/flashbar/styles.scss index ae43c9c3a4..137703f6cf 100644 --- a/src/flashbar/styles.scss +++ b/src/flashbar/styles.scss @@ -124,7 +124,8 @@ } } -.action-button { +.action-button, +.action-slot { /* Only used as a selector for test-utils */ } diff --git a/src/internal/plugins/api.ts b/src/internal/plugins/api.ts index 47c4907d42..3bd22911c6 100644 --- a/src/internal/plugins/api.ts +++ b/src/internal/plugins/api.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { DrawerConfig, DrawersController, DrawersRegistrationListener } from './drawers-controller'; +import { DrawerConfig, DrawersController, DrawersRegistrationListener } from './controllers/drawers'; +import { ActionButtonsController, ActionConfig, ActionRegistrationListener } from './controllers/action-buttons'; const storageKey = Symbol.for('awsui-plugin-api'); @@ -8,12 +9,26 @@ interface AwsuiPluginApiPublic { appLayout: { registerDrawer(config: DrawerConfig): void; }; + alert: { + registerAction(config: ActionConfig): void; + }; + flashbar: { + registerAction(config: ActionConfig): void; + }; } interface AwsuiPluginApiInternal { appLayout: { clearRegisteredDrawers(): void; onDrawersRegistered(listener: DrawersRegistrationListener): () => void; }; + alert: { + clearRegisteredActions: () => void; + onActionRegistered(listener: ActionRegistrationListener): () => void; + }; + flashbar: { + clearRegisteredActions: () => void; + onActionRegistered(listener: ActionRegistrationListener): () => void; + }; } interface AwsuiApi { @@ -59,18 +74,34 @@ function loadApi() { export const { awsuiPlugins, awsuiPluginsInternal } = loadApi(); function createApi(): AwsuiApi { - const drawers = new DrawersController(); + const appLayoutDrawers = new DrawersController(); + const alertActions = new ActionButtonsController(); + const flashbarActions = new ActionButtonsController(); return { awsuiPlugins: { appLayout: { - registerDrawer: drawers.registerDrawer, + registerDrawer: appLayoutDrawers.registerDrawer, + }, + alert: { + registerAction: alertActions.registerAction, + }, + flashbar: { + registerAction: flashbarActions.registerAction, }, }, awsuiPluginsInternal: { appLayout: { - clearRegisteredDrawers: drawers.clearRegisteredDrawers, - onDrawersRegistered: drawers.onDrawersRegistered, + clearRegisteredDrawers: appLayoutDrawers.clearRegisteredDrawers, + onDrawersRegistered: appLayoutDrawers.onDrawersRegistered, + }, + alert: { + clearRegisteredActions: alertActions.clearRegisteredActions, + onActionRegistered: alertActions.onActionRegistered, + }, + flashbar: { + clearRegisteredActions: flashbarActions.clearRegisteredActions, + onActionRegistered: flashbarActions.onActionRegistered, }, }, }; diff --git a/src/internal/plugins/controllers/__tests__/action-buttons.test.ts b/src/internal/plugins/controllers/__tests__/action-buttons.test.ts new file mode 100644 index 0000000000..0b079c0130 --- /dev/null +++ b/src/internal/plugins/controllers/__tests__/action-buttons.test.ts @@ -0,0 +1,92 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { + ActionButtonsController, + ActionConfig, +} from '../../../../../lib/components/internal/plugins/controllers/action-buttons'; + +const testAction: ActionConfig = { id: 'test-action', mountContent: () => {}, unmountContent: () => {} }; + +function delay() { + return new Promise(resolve => setTimeout(resolve)); +} + +test('notifies about registered action', async () => { + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.onActionRegistered(onAction); + controller.registerAction(testAction); + expect(onAction).not.toHaveBeenCalled(); + await delay(); + expect(onAction).toHaveBeenCalledWith([testAction]); +}); + +test('returns empty array when there is no registered action', async () => { + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.onActionRegistered(onAction); + await delay(); + expect(onAction).toHaveBeenCalledWith([]); +}); + +test('notifies about delayed registered action', async () => { + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.onActionRegistered(onAction); + await delay(); + onAction.mockReset(); + controller.registerAction(testAction); + await delay(); + expect(onAction).toHaveBeenCalledWith([testAction]); +}); + +test('change listener is not called after cleanup', async () => { + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + const cleanup = controller.onActionRegistered(onAction); + await delay(); + onAction.mockReset(); + cleanup(); + controller.registerAction(testAction); + await delay(); + expect(onAction).not.toHaveBeenCalled(); +}); + +test('supports multiple consumers', async () => { + const onActionFirst = jest.fn(); + const onActionSecond = jest.fn(); + const controller = new ActionButtonsController(); + controller.onActionRegistered(onActionFirst); + controller.onActionRegistered(onActionSecond); + controller.registerAction(testAction); + await delay(); + expect(onActionFirst).toHaveBeenCalledWith([testAction]); + expect(onActionSecond).toHaveBeenCalledWith([testAction]); +}); + +test('supports multiple registered actions', async () => { + const firstAction = { id: 'first action' } as ActionConfig; + const secondAction = { id: 'second action' } as ActionConfig; + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.registerAction(firstAction); + controller.registerAction(secondAction); + controller.onActionRegistered(onAction); + await delay(); + expect(onAction).toHaveBeenCalledWith([firstAction, secondAction]); +}); + +test('registers multiple actions in sorted order', async () => { + const firstAction = { id: 'first action', orderPriority: 10 } as ActionConfig; + const secondAction = { id: 'second action' } as ActionConfig; + const thirdAction = { id: 'third action', orderPriority: 1 } as ActionConfig; + + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.registerAction(firstAction); + controller.registerAction(secondAction); + controller.registerAction(thirdAction); + controller.onActionRegistered(onAction); + await delay(); + expect(onAction).toHaveBeenCalledWith([firstAction, thirdAction, secondAction]); +}); diff --git a/src/internal/plugins/__tests__/drawers-controller.test.ts b/src/internal/plugins/controllers/__tests__/drawers.test.ts similarity index 95% rename from src/internal/plugins/__tests__/drawers-controller.test.ts rename to src/internal/plugins/controllers/__tests__/drawers.test.ts index 90d61019d3..dac5fbf0a3 100644 --- a/src/internal/plugins/__tests__/drawers-controller.test.ts +++ b/src/internal/plugins/controllers/__tests__/drawers.test.ts @@ -1,6 +1,6 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { DrawerConfig, DrawersController } from '../../../../lib/components/internal/plugins/drawers-controller'; +import { DrawerConfig, DrawersController } from '../../../../../lib/components/internal/plugins/controllers/drawers'; const drawerA = { id: 'drawerA' } as DrawerConfig; const drawerB = { id: 'drawerB' } as DrawerConfig; diff --git a/src/internal/plugins/controllers/action-buttons.ts b/src/internal/plugins/controllers/action-buttons.ts new file mode 100644 index 0000000000..3028b8c43d --- /dev/null +++ b/src/internal/plugins/controllers/action-buttons.ts @@ -0,0 +1,51 @@ +// 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 ActionContext { + type: string; + headerRef: RefShim; + contentRef: RefShim; +} + +export interface ActionConfig { + id: string; + orderPriority?: number; + mountContent: (container: HTMLElement, context: ActionContext) => void; + unmountContent: (container: HTMLElement) => void; +} + +export type ActionRegistrationListener = (action: Array) => void; + +export class ActionButtonsController { + private listeners: Array = []; + private actions: Array = []; + + private scheduleUpdate = debounce(() => { + this.listeners.forEach(listener => listener(this.actions)); + }, 0); + + registerAction = (action: ActionConfig) => { + this.actions.push(action); + this.actions = sortByPriority(this.actions); + this.scheduleUpdate(); + }; + + clearRegisteredActions = () => { + this.actions = []; + }; + + onActionRegistered = (listener: ActionRegistrationListener) => { + this.listeners.push(listener); + this.scheduleUpdate(); + return () => { + this.listeners = this.listeners.filter(item => item !== listener); + }; + }; +} diff --git a/src/internal/plugins/drawers-controller.ts b/src/internal/plugins/controllers/drawers.ts similarity index 72% rename from src/internal/plugins/drawers-controller.ts rename to src/internal/plugins/controllers/drawers.ts index fcf3fd97db..696b59e07d 100644 --- a/src/internal/plugins/drawers-controller.ts +++ b/src/internal/plugins/controllers/drawers.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { DrawerItem } from '../../app-layout/drawer/interfaces'; +import { DrawerItem } from '../../../app-layout/drawer/interfaces'; +import debounce from '../../debounce'; export type DrawerConfig = Omit & { orderPriority?: number; @@ -15,16 +16,10 @@ export type DrawersRegistrationListener = (drawers: Array) => void export class DrawersController { private drawers: Array = []; private drawersRegistrationListener: DrawersRegistrationListener | null = null; - private updateTimeout: ReturnType | null = null; - private scheduleUpdate() { - if (this.updateTimeout) { - clearTimeout(this.updateTimeout); - } - this.updateTimeout = setTimeout(() => { - this.drawersRegistrationListener?.(this.drawers); - }); - } + scheduleUpdate = debounce(() => { + this.drawersRegistrationListener?.(this.drawers); + }, 0); registerDrawer = (config: DrawerConfig) => { this.drawers = this.drawers.concat(config); @@ -33,7 +28,7 @@ export class DrawersController { onDrawersRegistered = (listener: DrawersRegistrationListener) => { if (this.drawersRegistrationListener !== null) { - console.warn('[AwsUi] [runtime plugins] multiple app layout instances detected'); + console.warn('[AwsUi] [runtime drawers] multiple app layout instances detected'); } this.drawersRegistrationListener = listener; this.scheduleUpdate(); diff --git a/src/internal/plugins/helpers/index.ts b/src/internal/plugins/helpers/index.ts new file mode 100644 index 0000000000..435d0288dc --- /dev/null +++ b/src/internal/plugins/helpers/index.ts @@ -0,0 +1,4 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export { RuntimeContentWrapper } from './runtime-content-wrapper'; +export { createUseDiscoveredAction } from './use-discovered-action'; diff --git a/src/internal/plugins/helpers/runtime-content-wrapper.tsx b/src/internal/plugins/helpers/runtime-content-wrapper.tsx new file mode 100644 index 0000000000..df18772e8b --- /dev/null +++ b/src/internal/plugins/helpers/runtime-content-wrapper.tsx @@ -0,0 +1,21 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef } from 'react'; + +interface RuntimeContentWrapperProps { + mountContent: (container: HTMLElement) => void; + unmountContent: (container: HTMLElement) => void; +} + +export function RuntimeContentWrapper({ mountContent, unmountContent }: RuntimeContentWrapperProps) { + const ref = useRef(null); + + useEffect(() => { + const container = ref.current!; + mountContent(container); + return () => unmountContent(container); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
; +} diff --git a/src/internal/plugins/helpers/use-discovered-action.tsx b/src/internal/plugins/helpers/use-discovered-action.tsx new file mode 100644 index 0000000000..ae39569048 --- /dev/null +++ b/src/internal/plugins/helpers/use-discovered-action.tsx @@ -0,0 +1,34 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useEffect, useRef, useState } from 'react'; +import { ActionButtonsController, ActionConfig, ActionContext } from '../controllers/action-buttons'; +import { RuntimeContentWrapper } from './runtime-content-wrapper'; + +function convertRuntimeAction(action: ActionConfig | null, context: ActionContext) { + if (!action) { + return null; + } + return ( + action.mountContent(container, context)} + unmountContent={container => action.unmountContent(container)} + /> + ); +} + +export function createUseDiscoveredAction(onActionRegistered: ActionButtonsController['onActionRegistered']) { + return function useDiscoveredAction(type: string) { + const [discoveredActions, setDiscoveredActions] = useState>([]); + const headerRef = useRef(null); + const contentRef = useRef(null); + + useEffect(() => { + return onActionRegistered(actions => { + setDiscoveredActions(actions.map(action => convertRuntimeAction(action, { type, headerRef, contentRef }))); + }); + }, [type]); + + return { discoveredActions, headerRef, contentRef }; + }; +} diff --git a/src/internal/plugins/helpers/utils.ts b/src/internal/plugins/helpers/utils.ts new file mode 100644 index 0000000000..4351f512e2 --- /dev/null +++ b/src/internal/plugins/helpers/utils.ts @@ -0,0 +1,10 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +export function sortByPriority(items: Array) { + return items.slice().sort((a, b) => { + if (b.orderPriority !== a.orderPriority) { + return Math.sign((b.orderPriority ?? 0) - (a.orderPriority ?? 0)); + } + return b.id < a.id ? 1 : -1; + }); +} diff --git a/src/test-utils/dom/alert/index.ts b/src/test-utils/dom/alert/index.ts index 7c4afcdc94..247e04f851 100644 --- a/src/test-utils/dom/alert/index.ts +++ b/src/test-utils/dom/alert/index.ts @@ -41,6 +41,6 @@ export default class AlertWrapper extends ComponentWrapper { } findActionSlot(): ElementWrapper | null { - return this.findByClassName(styles.action); + 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 c7a5b81591..f57527d4b4 100644 --- a/src/test-utils/dom/flashbar/flash.ts +++ b/src/test-utils/dom/flashbar/flash.ts @@ -20,7 +20,7 @@ export default class FlashWrapper extends ComponentWrapper { * Returns the action slot. */ findAction(): ElementWrapper | null { - return this.findByClassName(styles['action-button-wrapper']); + return this.findByClassName(styles['action-slot']); } /**