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..966f4d9707 --- /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 ( + <> +

Alert 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..138e23c67c --- /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/action-buttons-controller'; +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.clearAction(); +}); + +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..3f88c9f414 --- /dev/null +++ b/src/alert/actions-wrapper/index.tsx @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +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 { + action: React.ReactNode; + discoveredAction: React.ReactNode; + buttonText: React.ReactNode; + onButtonClick: InternalButtonProps['onClick']; + testUtilClasses: { action: string; actionButton: string }; +} + +export function ActionsWrapper({ + testUtilClasses, + action, + discoveredAction, + buttonText, + onButtonClick, +}: ActionsWrapperProps) { + const actionButton = createActionButton(testUtilClasses, action, buttonText, onButtonClick); + if (!actionButton && !discoveredAction) { + return null; + } + + return ( +
+ {actionButton} + {discoveredAction} +
+ ); +} diff --git a/src/alert/actions-wrapper/styles.scss b/src/alert/actions-wrapper/styles.scss new file mode 100644 index 0000000000..2ab35555a2 --- /dev/null +++ b/src/alert/actions-wrapper/styles.scss @@ -0,0 +1,17 @@ +/* + Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + SPDX-License-Identifier: Apache-2.0 +*/ + +@use '../../internal/styles/tokens' as awsui; + +.root { + margin-left: awsui.$space-alert-action-left; + 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..30e2828c3e 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 { discoveredAction, headerRef, contentRef } = useDiscoveredAction(type); - const hasAction = Boolean(action || buttonText); const analyticsAttributes = { [DATA_ATTR_ANALYTICS_ALERT]: type, }; @@ -98,12 +94,27 @@ 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..5c2c2f5661 100644 --- a/src/alert/styles.scss +++ b/src/alert/styles.scss @@ -46,8 +46,7 @@ } .action { - white-space: nowrap; - margin-left: awsui.$space-alert-action-left; + /* used in test-utils */ } .action-button { @@ -124,6 +123,10 @@ /* used in test-utils */ } +.action-slot { + /* used in test-utils */ +} + .icon { flex: 0 0 auto; } diff --git a/src/app-layout/runtime-api.tsx b/src/app-layout/runtime-api.tsx index 43761459f5..756ed88bdf 100644 --- a/src/app-layout/runtime-api.tsx +++ b/src/app-layout/runtime-api.tsx @@ -1,27 +1,10 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef } from 'react'; -import { DrawerConfig as RuntimeDrawerConfig } from '../internal/plugins/drawers-controller'; +import React from 'react'; +import { DrawerConfig as RuntimeDrawerConfig } from '../internal/plugins/controllers/drawers'; +import { RuntimeContentWrapper } from '../internal/plugins/helpers'; import { DrawerItem } from './drawer/interfaces'; -interface RuntimeContentWrapperProps { - mountContent: RuntimeDrawerConfig['mountContent']; - unmountContent: RuntimeDrawerConfig['unmountContent']; -} - -function RuntimeContentWrapper({ mountContent, unmountContent }: RuntimeContentWrapperProps) { - const ref = useRef(null); - - useEffect(() => { - const container = ref.current!; - mountContent(container); - return () => unmountContent(container); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return
; -} - export interface DrawersLayout { before: Array; after: Array; diff --git a/src/flashbar/__tests__/runtime-action.test.tsx b/src/flashbar/__tests__/runtime-action.test.tsx new file mode 100644 index 0000000000..62209933da --- /dev/null +++ b/src/flashbar/__tests__/runtime-action.test.tsx @@ -0,0 +1,151 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import Flashbar, { FlashbarProps } from '../../../lib/components/flashbar'; +import awsuiPlugins from '../../../lib/components/internal/plugins'; +import { ActionConfig } from '../../../lib/components/internal/plugins/controllers/action-buttons'; +import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api'; + +const defaultAction: ActionConfig = { + id: 'test-action', + mountContent: container => { + const button = document.createElement('button'); + button.dataset.testid = 'test-action'; + container.appendChild(button); + }, + unmountContent: container => (container.innerHTML = ''), +}; + +const defaultItem: FlashbarProps.MessageDefinition = {}; + +function delay() { + return act(() => new Promise(resolve => setTimeout(resolve))); +} + +afterEach(() => { + awsuiPluginsInternal.flashbar.clearAction(); +}); + +test('renders runtime action button initially', async () => { + awsuiPlugins.flashbar.registerAction(defaultAction); + render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action button asynchronously', async () => { + render(); + await delay(); + expect(screen.queryByTestId('test-action')).toBeFalsy(); + awsuiPlugins.flashbar.registerAction(defaultAction); + await delay(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action along with the props one', async () => { + awsuiPlugins.flashbar.registerAction(defaultAction); + render(test }]} />); + await delay(); + expect(screen.queryByTestId('own-button')).toBeTruthy(); + expect(screen.queryByTestId('test-action')).toBeTruthy(); +}); + +test('renders runtime action button on multiple instances', async () => { + awsuiPlugins.flashbar.registerAction(defaultAction); + render( + + ); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(2); +}); + +test('allows skipping actions for some items', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + defaultAction.mountContent(container, context); + }, + }; + awsuiPlugins.flashbar.registerAction(testAction); + render( + + ); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(1); +}); + +test('maintains action button state when dismissing a message', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + if (context.type !== 'error') { + return; + } + defaultAction.mountContent(container, context); + }, + }; + awsuiPlugins.flashbar.registerAction(testAction); + const items: Array = [ + { ...defaultItem, id: '1', type: 'info' }, + { ...defaultItem, id: '2', type: 'error' }, + ]; + const { rerender } = render(); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(1); + + rerender(); + await delay(); + expect(screen.queryAllByTestId('test-action')).toHaveLength(1); +}); + +test('propagates flash message context into callback', async () => { + const onClick = jest.fn(); + const testAction: ActionConfig = { + ...defaultAction, + mountContent: (container, context) => { + const button = document.createElement('button'); + button.dataset.testid = 'test-action'; + button.onclick = () => onClick(context); + container.appendChild(button); + }, + }; + awsuiPlugins.flashbar.registerAction(testAction); + render(); + await delay(); + fireEvent.click(screen.getByTestId('test-action')); + expect(onClick).toHaveBeenCalledWith({ + type: 'info', + headerRef: { current: expect.any(HTMLElement) }, + contentRef: { current: expect.any(HTMLElement) }, + }); +}); + +test('cleans up on unmount', async () => { + const testAction: ActionConfig = { + ...defaultAction, + mountContent: jest.fn(), + unmountContent: jest.fn(), + }; + awsuiPlugins.flashbar.registerAction(testAction); + const { rerender } = render(); + await delay(); + expect(testAction.mountContent).toHaveBeenCalledTimes(1); + expect(testAction.unmountContent).toHaveBeenCalledTimes(0); + rerender(<>); + expect(testAction.mountContent).toHaveBeenCalledTimes(1); + expect(testAction.unmountContent).toHaveBeenCalledTimes(1); +}); diff --git a/src/flashbar/flash.tsx b/src/flashbar/flash.tsx index 086328fa51..2edb2adc1b 100644 --- a/src/flashbar/flash.tsx +++ b/src/flashbar/flash.tsx @@ -18,6 +18,9 @@ import { sendDismissMetric } from './internal/analytics'; import { FOCUS_THROTTLE_DELAY } from './utils'; import { DATA_ATTR_ANALYTICS_FLASHBAR } from '../internal/analytics/selectors'; +import { createUseDiscoveredAction } from '../internal/plugins/helpers'; +import { awsuiPluginsInternal } from '../internal/plugins/api'; +import { ActionsWrapper } from '../alert/actions-wrapper'; const ICON_TYPES = { success: 'status-positive', @@ -27,16 +30,7 @@ const ICON_TYPES = { 'in-progress': 'status-in-progress', } as const; -function actionButton( - buttonText: FlashbarProps.MessageDefinition['buttonText'], - onButtonClick: FlashbarProps.MessageDefinition['onButtonClick'] -) { - return ( - - {buttonText} - - ); -} +const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.flashbar.onActionRegistered); function dismissButton( dismissLabel: FlashbarProps.MessageDefinition['dismissLabel'], @@ -107,7 +101,7 @@ export const Flash = React.forwardRef( } } - const button = action || (buttonText && actionButton(buttonText, onButtonClick)); + const { discoveredAction, headerRef, contentRef } = useDiscoveredAction(type); const iconType = ICON_TYPES[type]; @@ -158,11 +152,24 @@ export const Flash = React.forwardRef( {icon}
-
{header}
-
{content}
+
+ {header} +
+
+ {content} +
- {button &&
{button}
} + {dismissible && dismissButton(dismissLabel, handleDismiss)} {ariaRole === 'status' && ( diff --git a/src/internal/plugins/api.ts b/src/internal/plugins/api.ts index 47c4907d42..77366ca3c0 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: { + clearAction: () => void; + onActionRegistered(listener: ActionRegistrationListener): () => void; + }; + flashbar: { + clearAction: () => 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: { + clearAction: alertActions.clearAction, + onActionRegistered: alertActions.onActionRegistered, + }, + flashbar: { + clearAction: flashbarActions.clearAction, + 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..3fdb643a26 --- /dev/null +++ b/src/internal/plugins/controllers/__tests__/action-buttons.test.ts @@ -0,0 +1,93 @@ +// 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-controller'; + +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 null when there is no registered action', async () => { + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.onActionRegistered(onAction); + await delay(); + expect(onAction).toHaveBeenCalledWith(null); +}); + +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); +}); + +describe('console warnings', () => { + let consoleWarnSpy: jest.SpyInstance; + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + afterEach(() => { + consoleWarnSpy.mockRestore(); + }); + + test('warns if there are multiple action registrations ', async () => { + const firstAction = { id: 'first action' } as ActionConfig; + const secondAction = { id: 'second action' } as ActionConfig; + const onAction = jest.fn(); + const controller = new ActionButtonsController(); + controller.onActionRegistered(onAction); + controller.registerAction(firstAction); + await delay(); + expect(onAction).toHaveBeenCalledWith(firstAction); + expect(consoleWarnSpy).not.toHaveBeenCalled(); + onAction.mockReset(); + controller.registerAction(secondAction); + expect(onAction).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching(/multiple action registrations are not supported/) + ); + }); +}); diff --git a/src/internal/plugins/__tests__/drawers-controller.test.ts b/src/internal/plugins/controllers/__tests__/drawers.test.ts similarity index 94% rename from src/internal/plugins/__tests__/drawers-controller.test.ts rename to src/internal/plugins/controllers/__tests__/drawers.test.ts index 90d61019d3..43845467a1 100644 --- a/src/internal/plugins/__tests__/drawers-controller.test.ts +++ b/src/internal/plugins/controllers/__tests__/drawers.test.ts @@ -1,6 +1,9 @@ // 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-controller'; 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..ba7bf894f6 --- /dev/null +++ b/src/internal/plugins/controllers/action-buttons.ts @@ -0,0 +1,52 @@ +// 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 ActionContext { + type: string; + headerRef: RefShim; + contentRef: RefShim; +} + +export interface ActionConfig { + id: string; + mountContent: (container: HTMLElement, context: ActionContext) => void; + unmountContent: (container: HTMLElement) => void; +} + +export type ActionRegistrationListener = (action: ActionConfig | null) => void; + +export class ActionButtonsController { + private listeners: Array = []; + private action: ActionConfig | null = null; + + private scheduleUpdate = debounce(() => { + this.listeners.forEach(listener => listener(this.action)); + }, 0); + + registerAction = (action: ActionConfig) => { + if (this.action) { + console.warn('[AwsUi] [runtime actions] multiple action registrations are not supported'); + return; + } + this.action = action; + this.scheduleUpdate(); + }; + + clearAction = () => { + this.action = null; + }; + + 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..a76b89fdd7 --- /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 [discoveredAction, setDiscoveredAction] = useState(null); + const headerRef = useRef(null); + const contentRef = useRef(null); + + useEffect(() => { + return onActionRegistered(action => { + setDiscoveredAction(convertRuntimeAction(action, { type, headerRef, contentRef })); + }); + }, [type]); + + return { discoveredAction, headerRef, contentRef }; + }; +}