From 10f7c53d5d147d2ed014ec9ea305744aba0ca5bf Mon Sep 17 00:00:00 2001 From: Georgii Lobko Date: Wed, 25 Feb 2026 13:31:09 +0100 Subject: [PATCH] feat: AppLayout error boundaries --- .../app-layout/with-error-boundaries.page.tsx | 377 ++++++++++++++++++ src/__a11y__/a11y-app-layout-toolbar.test.ts | 1 + src/__a11y__/run-a11y-tests.ts | 1 + .../app-layout-error-boundaries.test.ts | 89 +++++ .../widget-areas-error-boundaries.test.tsx | 284 +++++++++++++ .../drawer/global-ai-drawer.tsx | 245 ++++++------ .../drawer/global-bottom-drawer.tsx | 238 +++++------ .../drawer/global-drawer.tsx | 203 +++++----- .../visual-refresh-toolbar/index.tsx | 5 +- .../visual-refresh-toolbar/state/index.tsx | 11 +- .../toolbar/drawer-triggers.tsx | 248 ++++++------ .../visual-refresh-toolbar/toolbar/index.tsx | 175 ++++---- .../widget-areas/after-main-slot.tsx | 45 ++- .../widget-areas/before-main-slot.tsx | 23 +- .../widget-areas/bottom-content-slot.tsx | 11 +- .../widget-areas/top-content-slot.tsx | 9 +- src/error-boundary/interfaces.ts | 4 + src/error-boundary/internal.tsx | 29 +- src/error-boundary/styles.scss | 3 +- 19 files changed, 1433 insertions(+), 568 deletions(-) create mode 100644 pages/app-layout/with-error-boundaries.page.tsx create mode 100644 src/app-layout/__integ__/app-layout-error-boundaries.test.ts create mode 100644 src/app-layout/__tests__/widget-areas-error-boundaries.test.tsx diff --git a/pages/app-layout/with-error-boundaries.page.tsx b/pages/app-layout/with-error-boundaries.page.tsx new file mode 100644 index 0000000000..d7944089d8 --- /dev/null +++ b/pages/app-layout/with-error-boundaries.page.tsx @@ -0,0 +1,377 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React, { useContext, useEffect, useRef, useState } from 'react'; + +import { AppLayout, Button, Container, ContentLayout, Header, SpaceBetween, SplitPanel, Toggle } from '~components'; +import { AppLayoutProps } from '~components/app-layout'; +import BreadcrumbGroup from '~components/breadcrumb-group'; +import ErrorBoundary from '~components/error-boundary'; +import awsuiPlugins from '~components/internal/plugins'; +import { registerBottomDrawer, registerLeftDrawer } from '~components/internal/plugins/widget'; +import { mount, unmount } from '~mount'; + +import AppContext, { AppContextType } from '../app/app-context'; +import { drawerLabels } from './utils/drawers'; +import appLayoutLabels from './utils/labels'; +import { splitPaneli18nStrings } from './utils/strings'; + +type DemoContext = React.Context< + AppContextType<{ + hasParentErrorBoundary: boolean | undefined; + hasDrawers: boolean | undefined; + splitPanelPosition: AppLayoutProps.SplitPanelPreferences['position']; + }> +>; + +export default function WithErrorBoundariesPage() { + const { urlParams, setUrlParams } = useContext(AppContext as DemoContext); + const hasParentErrorBoundary = urlParams.hasParentErrorBoundary ?? false; + const [isBrokenNavigation, setIsBrokenNavigation] = useState(false); + const appLayoutRef = useRef(null); + const [breadcrumbsItems, setBreadcrumbsItems] = useState([ + { text: 'Home', href: '#' }, + { text: 'Service', href: '#' }, + ]); + + useEffect(() => { + window.addEventListener( + 'error', + error => { + console.log('The error gets caught by the global event handler: ', error); + }, + true + ); + }, []); + + const appLayout = ( + } + content={ + + +
Error boundaries in app layout slots
+ + } + > + + setUrlParams({ hasParentErrorBoundary: detail.checked })} + > + Has parent error boundary + + Toolbar}> + + + + + + Panels}> + + + + + + + + +
+ + } + /> + } + splitPanel={ + + This is the Split Panel! + + } + splitPanelPreferences={{ + position: urlParams.splitPanelPosition, + }} + onSplitPanelPreferencesChange={event => { + const { position } = event.detail; + setUrlParams({ splitPanelPosition: position === 'side' ? position : undefined }); + }} + toolsHide={true} + navigation={isBrokenNavigation ? ({} as any) :
navigation
} + /> + ); + + return hasParentErrorBoundary ? ( + + console.log('The error gets caught by the parent error boundary wrapping app layout: ', error) + } + > + {appLayout} + + ) : ( + appLayout + ); +} diff --git a/src/__a11y__/a11y-app-layout-toolbar.test.ts b/src/__a11y__/a11y-app-layout-toolbar.test.ts index 062ee81152..b2e47addb0 100644 --- a/src/__a11y__/a11y-app-layout-toolbar.test.ts +++ b/src/__a11y__/a11y-app-layout-toolbar.test.ts @@ -10,6 +10,7 @@ const EXCLUDED_PAGES = [ // Test page for an app layout nested inside another through an iframe. // Not a use case that's encouraged. 'app-layout/multi-layout-global-drawer-child-layout', + 'app-layout/with-error-boundaries', ]; describe('A11y checks for app layout toolbar', () => { diff --git a/src/__a11y__/run-a11y-tests.ts b/src/__a11y__/run-a11y-tests.ts index eb38f0a14e..5a4d61f3f2 100644 --- a/src/__a11y__/run-a11y-tests.ts +++ b/src/__a11y__/run-a11y-tests.ts @@ -32,6 +32,7 @@ export default function runA11yTests(theme: Theme, mode: Mode, skip: string[] = 'theming/tokens', // this page intentionally has issues to test the helper 'undefined-texts', + 'app-layout/with-error-boundaries', ]; const testFunction = skipPages.includes(inputUrl) || diff --git a/src/app-layout/__integ__/app-layout-error-boundaries.test.ts b/src/app-layout/__integ__/app-layout-error-boundaries.test.ts new file mode 100644 index 0000000000..b01534ecc5 --- /dev/null +++ b/src/app-layout/__integ__/app-layout-error-boundaries.test.ts @@ -0,0 +1,89 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import { BasePageObject } from '@cloudscape-design/browser-test-tools/page-objects'; +import useBrowser from '@cloudscape-design/browser-test-tools/use-browser'; + +import createWrapper from '../../../lib/components/test-utils/selectors'; +import { getUrlParams } from './utils'; + +const wrapper = createWrapper().findAppLayout(); + +const ERROR_BOUNDARY_TEST_SELECTORS = { + 'left drawer trigger': '[data-testid="break-left-drawer-trigger"]', + breadcrumbs: '[data-testid="break-breadcrumbs"]', + 'local drawer trigger': '[data-testid="break-local-drawer-trigger"]', + 'global drawer trigger': '[data-testid="break-global-drawer-trigger"]', + 'left drawer content': '[data-testid="break-left-drawer-content"]', + 'left drawer header': '[data-testid="break-left-drawer-header"]', + 'navigation panel': '[data-testid="break-nav-panel"]', + 'local drawer content': '[data-testid="break-local-drawer-content"]', + 'global drawer content': '[data-testid="break-global-drawer-content"]', + 'bottom drawer content': '[data-testid="break-bottom-drawer-content"]', +} as const; + +describe('Visual refresh toolbar only', () => { + class PageObject extends BasePageObject { + async getConsoleErrorLogs() { + const total = (await this.browser.getLogs('browser')) as Array<{ + level: string; + message: string; + source: string; + }>; + + return total.filter(entry => entry.level === 'SEVERE' && entry.source !== 'network').map(entry => entry.message); + } + + async expectConsoleErrors() { + const consoleErrors = await this.getConsoleErrorLogs(); + const errorPattern = /The above error occurred in the .+ component:/; + const matchingErrors = consoleErrors.filter(error => errorPattern.test(error)); + expect(matchingErrors.length).toBeGreaterThan(0); + } + + async expectContentAreaStaysFunctional() { + await expect(this.getText(wrapper.findContentRegion().toSelector())).resolves.toContain( + 'Error boundaries in app layout slots' + ); + } + } + function setupTest({ hasParentErrorBoundary = 'true' }, testFn: (page: PageObject) => Promise) { + return useBrowser(async browser => { + const page = new PageObject(browser); + + await browser.url( + `#/light/app-layout/with-error-boundaries?${getUrlParams('refresh-toolbar', { + appLayoutToolbar: 'true', + hasParentErrorBoundary, + })}` + ); + await page.waitForVisible(wrapper.findContentRegion().toSelector(), true); + await testFn(page); + }); + } + + describe('with parent error boundary', () => { + Object.entries(ERROR_BOUNDARY_TEST_SELECTORS).forEach(([areaName, selector]) => { + test( + `should handle error boundary in ${areaName}`, + setupTest({ hasParentErrorBoundary: 'true' }, async page => { + await page.click(selector); + await page.expectConsoleErrors(); + await page.expectContentAreaStaysFunctional(); + }) + ); + }); + }); + + describe('without parent error boundary', () => { + Object.entries(ERROR_BOUNDARY_TEST_SELECTORS).forEach(([areaName, selector]) => { + test( + `should handle error boundary in ${areaName}`, + setupTest({ hasParentErrorBoundary: 'false' }, async page => { + await page.click(selector); + await page.expectConsoleErrors(); + await page.expectContentAreaStaysFunctional(); + }) + ); + }); + }); +}); diff --git a/src/app-layout/__tests__/widget-areas-error-boundaries.test.tsx b/src/app-layout/__tests__/widget-areas-error-boundaries.test.tsx new file mode 100644 index 0000000000..dd798f2936 --- /dev/null +++ b/src/app-layout/__tests__/widget-areas-error-boundaries.test.tsx @@ -0,0 +1,284 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import React from 'react'; +import { render } from '@testing-library/react'; + +import AppLayout from '../../../lib/components/app-layout'; +import ErrorBoundary from '../../../lib/components/error-boundary'; +import awsuiPlugins from '../../../lib/components/internal/plugins'; +import { DrawerConfig } from '../../../lib/components/internal/plugins/controllers/drawers'; +import * as awsuiWidgetPlugins from '../../../lib/components/internal/plugins/widget'; +import createWrapper, { ErrorBoundaryWrapper } from '../../../lib/components/test-utils/dom'; +import { describeEachAppLayout, getGlobalDrawersTestUtils } from './utils'; + +const drawerDefaults: DrawerConfig = { + id: 'test', + ariaLabels: {}, + trigger: { iconSvg: 'icon placeholder' }, + mountContent: container => (container.textContent = 'runtime drawer content'), + unmountContent: () => {}, +}; + +function renderComponent(jsx: React.ReactElement) { + const { container, rerender, getByTestId, ...rest } = render(jsx); + const wrapper = createWrapper(container).findAppLayout()!; + const errorBoundaryWrapper = createWrapper(container).findErrorBoundary()!; + const globalDrawersWrapper = getGlobalDrawersTestUtils(wrapper); + return { + wrapper, + errorBoundaryWrapper, + globalDrawersWrapper, + rerender, + getByTestId, + container, + ...rest, + }; +} + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('AppLayout error boundaries: errors in different areas does not crash the entire app layout', () => { + const ThrowError = ({ message = 'Test error' }: { message?: string }) => { + throw new Error(message); + }; + + const expectInvisibleErrorBoundary = (errorBoundaryWrapper: ErrorBoundaryWrapper) => { + expect(errorBoundaryWrapper.getElement()).toBeInTheDocument(); + expect(errorBoundaryWrapper.findHeader()).toBeFalsy(); + expect(errorBoundaryWrapper.findDescription()).toBeFalsy(); + expect(errorBoundaryWrapper.findAction()).toBeFalsy(); + }; + + describeEachAppLayout({ themes: ['refresh-toolbar'] }, () => { + describe.each([true, false])( + 'entire AppLayout wrapped with error boundary component : %p', + (wrappedWithErrorBoundary: boolean) => { + const onError = jest.fn(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const AppLayoutWrapper = wrappedWithErrorBoundary ? ErrorBoundary : 'div'; + const appLayoutWrapperProps = wrappedWithErrorBoundary ? { onError } : {}; + const content =
content
; + + const expectErrorCallbacksToBeCalled = () => { + if (wrappedWithErrorBoundary) { + expect(onError).toHaveBeenCalled(); + } + expect(consoleSpy).toHaveBeenCalled(); + }; + + test('left drawer content', () => { + awsuiWidgetPlugins.registerLeftDrawer({ + defaultActive: true, + ...drawerDefaults, + id: '1', + trigger: undefined, + mountContent: () => { + throw new Error('Mount error in drawer content'); + }, + }); + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('left drawer header', () => { + awsuiWidgetPlugins.registerLeftDrawer({ + ...drawerDefaults, + id: '2', + defaultActive: true, + trigger: undefined, + mountHeader: () => { + throw new Error('Mount error in drawer content'); + }, + }); + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('left drawer trigger', () => { + awsuiWidgetPlugins.registerLeftDrawer({ + ...drawerDefaults, + id: '3', + trigger: { + iconSvg: Symbol() as any, + }, + }); + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('breadcrumbs', () => { + const { errorBoundaryWrapper, wrapper } = renderComponent( + + } /> + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('local drawer trigger', () => { + awsuiPlugins.appLayout.registerDrawer({ + ...drawerDefaults, + type: 'local', + trigger: { + iconSvg: Symbol() as any, + }, + }); + + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('global drawer trigger', () => { + awsuiPlugins.appLayout.registerDrawer({ + ...drawerDefaults, + type: 'global', + trigger: { + iconSvg: Symbol() as any, + }, + }); + + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('nav panel', () => { + const { errorBoundaryWrapper, wrapper } = renderComponent( + + } + navigationOpen={true} + /> + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('local drawer content', () => { + awsuiPlugins.appLayout.registerDrawer({ + ...drawerDefaults, + type: 'local', + mountContent: () => { + throw new Error('Mount error in drawer content'); + }, + }); + + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('global drawer content', () => { + awsuiPlugins.appLayout.registerDrawer({ + ...drawerDefaults, + type: 'global', + mountContent: () => { + throw new Error('Mount error in drawer content'); + }, + }); + + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('bottom drawer content', () => { + awsuiWidgetPlugins.registerBottomDrawer({ + defaultActive: true, + ...drawerDefaults, + id: '4', + mountContent: () => { + throw new Error('Mount error in drawer content'); + }, + }); + + const { errorBoundaryWrapper, wrapper } = renderComponent( + + + + ); + + expect(wrapper.findContentRegion().getElement()).toHaveTextContent('content'); + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + }); + + test('content area error are not caught by app layout error boundaries', () => { + if (wrappedWithErrorBoundary) { + const { errorBoundaryWrapper } = renderComponent( + + } /> + + ); + + expectInvisibleErrorBoundary(errorBoundaryWrapper); + expectErrorCallbacksToBeCalled(); + } else { + expect(() => + render( + + } /> + + ) + ).toThrow('Test error'); + } + }); + } + ); + }); +}); diff --git a/src/app-layout/visual-refresh-toolbar/drawer/global-ai-drawer.tsx b/src/app-layout/visual-refresh-toolbar/drawer/global-ai-drawer.tsx index c86ea70a08..27b9221e3d 100644 --- a/src/app-layout/visual-refresh-toolbar/drawer/global-ai-drawer.tsx +++ b/src/app-layout/visual-refresh-toolbar/drawer/global-ai-drawer.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import { InternalItemOrGroup } from '../../../button-group/interfaces'; import ButtonGroup from '../../../button-group/internal'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import PanelResizeHandle from '../../../internal/components/panel-resize-handle'; import customCssProps from '../../../internal/generated/custom-css-properties'; import { usePrevious } from '../../../internal/hooks/use-previous'; @@ -130,127 +131,135 @@ export function AppLayoutGlobalAiDrawerImplementation({ } return ( - - {drawerTransitionState => { - return ( - - {expandedTransitionState => { - return ( - - ); - }} - - ); - }} - + + ); + }} + + ); + }} + + ); } diff --git a/src/app-layout/visual-refresh-toolbar/drawer/global-bottom-drawer.tsx b/src/app-layout/visual-refresh-toolbar/drawer/global-bottom-drawer.tsx index 7fdabd08d2..318229db1f 100644 --- a/src/app-layout/visual-refresh-toolbar/drawer/global-bottom-drawer.tsx +++ b/src/app-layout/visual-refresh-toolbar/drawer/global-bottom-drawer.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import { InternalItemOrGroup } from '../../../button-group/interfaces'; import ButtonGroup from '../../../button-group/internal'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import PanelResizeHandle from '../../../internal/components/panel-resize-handle'; import customCssProps from '../../../internal/generated/custom-css-properties'; import { usePrevious } from '../../../internal/hooks/use-previous'; @@ -18,7 +19,6 @@ import { useResize } from './use-resize'; import sharedStyles from '../../resize/styles.css.js'; import testutilStyles from '../../test-classes/styles.css.js'; import styles from './styles.css.js'; - export function AppLayoutBottomDrawerWrapper({ widgetizedState }: { widgetizedState: AppLayoutWidgetizedState }) { const { activeGlobalBottomDrawerId, bottomDrawers } = widgetizedState; const openBottomDrawersHistory = useRef>(new Set()); @@ -189,130 +189,132 @@ function AppLayoutGlobalBottomDrawerImplementation({ }, [reportBottomDrawerSize, size]); return ( - - {state => { - return ( - + ); + }} + + ); } diff --git a/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx b/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx index 27ed111208..a14f4861db 100644 --- a/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx +++ b/src/app-layout/visual-refresh-toolbar/drawer/global-drawer.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import { InternalItemOrGroup } from '../../../button-group/interfaces'; import ButtonGroup from '../../../button-group/internal'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import PanelResizeHandle from '../../../internal/components/panel-resize-handle'; import customCssProps from '../../../internal/generated/custom-css-properties'; import { usePrevious } from '../../../internal/hooks/use-previous'; @@ -109,110 +110,112 @@ function AppLayoutGlobalDrawerImplementation({ } return ( - - {state => { - return ( - + ); + }} + + ); } diff --git a/src/app-layout/visual-refresh-toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/index.tsx index 4c61bbfb68..1c4e1f07b4 100644 --- a/src/app-layout/visual-refresh-toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/index.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { unstable_batchedUpdates } from 'react-dom'; +import { AppLayoutBuiltInErrorBoundary } from '../../error-boundary/internal'; import ScreenreaderOnly from '../../internal/components/screenreader-only'; import { AppLayoutProps } from '../interfaces'; import { AppLayoutVisibilityContext } from './contexts'; @@ -76,7 +77,9 @@ const AppLayoutVisualRefreshToolbar = React.forwardRef {/* Rendering a hidden copy of breadcrumbs to trigger their deduplication */} {(embeddedViewMode || !toolbarProps) && props.breadcrumbs ? ( - {props.breadcrumbs} + + {props.breadcrumbs} + ) : null} ; } -export const AppLayoutStateProvider = ({ appLayoutProps, stateManager, forwardRef }: AppLayoutStateProps) => { +export const AppLayoutStateProviderInternal = ({ appLayoutProps, stateManager, forwardRef }: AppLayoutStateProps) => { const [hasToolbar, setHasToolbar] = useState(stateManager.current.hasToolbar ?? false); const appLayoutState = useAppLayout(hasToolbar, appLayoutProps, forwardRef); const skeletonSlotsAttributes = useSkeletonSlotsAttributes(hasToolbar, appLayoutProps, appLayoutState); @@ -55,4 +56,12 @@ export const AppLayoutStateProvider = ({ appLayoutProps, stateManager, forwardRe return <>; }; +export const AppLayoutStateProvider = (props: AppLayoutStateProps) => { + return ( + + + + ); +}; + export const createWidgetizedAppLayoutState = createWidgetizedComponent(AppLayoutStateProvider); diff --git a/src/app-layout/visual-refresh-toolbar/toolbar/drawer-triggers.tsx b/src/app-layout/visual-refresh-toolbar/toolbar/drawer-triggers.tsx index d0ad20a7ba..1f5fc8b05f 100644 --- a/src/app-layout/visual-refresh-toolbar/toolbar/drawer-triggers.tsx +++ b/src/app-layout/visual-refresh-toolbar/toolbar/drawer-triggers.tsx @@ -5,6 +5,7 @@ import clsx from 'clsx'; import { useContainerQuery } from '@cloudscape-design/component-toolkit'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import { useMobile } from '../../../internal/hooks/use-mobile'; import { splitItems } from '../../drawer/drawers-helpers'; import OverflowMenu from '../../drawer/overflow-menu'; @@ -188,42 +189,44 @@ export function DrawerTriggers({ const selected = !expandedDrawerId && item.id === activeDrawerId; const isFeatureNotificationsDrawer = featureNotificationsProps?.drawer?.id === item.id; return ( - { - exitExpandedMode(); - if (!!expandedDrawerId && activeDrawerId === item.id) { - return; + + { + exitExpandedMode(); + if (!!expandedDrawerId && activeDrawerId === item.id) { + return; + } + onActiveDrawerChange?.(activeDrawerId !== item.id ? item.id : null, { initiatedByUserAction: true }); + }} + ref={ + item.id === previousActiveLocalDrawerId.current + ? drawersFocusRef + : isFeatureNotificationsDrawer + ? featureNotificationTriggerRef + : null } - onActiveDrawerChange?.(activeDrawerId !== item.id ? item.id : null, { initiatedByUserAction: true }); - }} - ref={ - item.id === previousActiveLocalDrawerId.current - ? drawersFocusRef - : isFeatureNotificationsDrawer - ? featureNotificationTriggerRef - : null - } - selected={selected} - badge={item.badge} - testId={`awsui-app-layout-trigger-${item.id}`} - hasTooltip={true} - hasOpenDrawer={hasOpenDrawer} - tooltipText={item.ariaLabels?.drawerName} - isForPreviousActiveDrawer={isForPreviousActiveDrawer} - isMobile={isMobile} - disabled={disabled} - /> + selected={selected} + badge={item.badge} + testId={`awsui-app-layout-trigger-${item.id}`} + hasTooltip={true} + hasOpenDrawer={hasOpenDrawer} + tooltipText={item.ariaLabels?.drawerName} + isForPreviousActiveDrawer={isForPreviousActiveDrawer} + isMobile={isMobile} + disabled={disabled} + /> + ); })} {globalDrawersStartIndex > 0 && visibleItems.length > globalDrawersStartIndex && ( @@ -240,98 +243,103 @@ export function DrawerTriggers({ } return ( - { - exitExpandedMode(); - if (!!expandedDrawerId && item.id !== expandedDrawerId && activeGlobalDrawersIds.includes(item.id)) { - return; + + { + exitExpandedMode(); + if (!!expandedDrawerId && item.id !== expandedDrawerId && activeGlobalDrawersIds.includes(item.id)) { + return; + } + if (isBottom) { + onActiveGlobalBottomDrawerChange?.(selected ? null : item.id, { initiatedByUserAction: true }); + return; + } + onActiveGlobalDrawersChange?.(item.id, { initiatedByUserAction: true }); + }} + ref={isBottom ? bottomDrawersFocusRef : globalDrawersFocusControl?.refs[item.id]?.toggle} + selected={selected} + badge={item.badge} + testId={`awsui-app-layout-trigger-${item.id}`} + hasTooltip={true} + hasOpenDrawer={hasOpenDrawer} + tooltipText={item.ariaLabels?.drawerName} + isForPreviousActiveDrawer={isForPreviousActiveDrawer} + isMobile={isMobile} + disabled={disabled} + /> + + ); + })} + {overflowItems.length > 0 && ( + + { + const isBottom = item?.position === 'bottom'; + let active = + activeGlobalDrawersIds.includes(item.id) && (!expandedDrawerId || item.id === expandedDrawerId); + if (isBottom) { + active = + item.id === activeGlobalBottomDrawerId && (!expandedDrawerId || item.id === expandedDrawerId); } + return { + ...item, + active, + }; + })} + ariaLabel={overflowMenuHasBadge ? ariaLabels?.drawersOverflowWithBadge : ariaLabels?.drawersOverflow} + customTriggerBuilder={({ onClick, triggerRef, ariaLabel, ariaExpanded, testUtilsClass }) => { + return ( + + ); + }} + onItemClick={event => { + const id = event.detail.id; + exitExpandedMode(); + const item = overflowItems.find(item => item.id === id); + const isBottom = item?.position === 'bottom'; if (isBottom) { + const selected = + item.id === activeGlobalBottomDrawerId && (!expandedDrawerId || item.id === expandedDrawerId); onActiveGlobalBottomDrawerChange?.(selected ? null : item.id, { initiatedByUserAction: true }); return; } - onActiveGlobalDrawersChange?.(item.id, { initiatedByUserAction: true }); + if (globalDrawers.find(drawer => drawer.id === id)) { + if (!!expandedDrawerId && id !== expandedDrawerId && activeGlobalDrawersIds.includes(id)) { + return; + } + onActiveGlobalDrawersChange?.(id, { initiatedByUserAction: true }); + } else { + onActiveDrawerChange?.(event.detail.id, { initiatedByUserAction: true }); + } }} - ref={isBottom ? bottomDrawersFocusRef : globalDrawersFocusControl?.refs[item.id]?.toggle} - selected={selected} - badge={item.badge} - testId={`awsui-app-layout-trigger-${item.id}`} - hasTooltip={true} - hasOpenDrawer={hasOpenDrawer} - tooltipText={item.ariaLabels?.drawerName} - isForPreviousActiveDrawer={isForPreviousActiveDrawer} - isMobile={isMobile} - disabled={disabled} + globalDrawersStartIndex={globalDrawersStartIndex - indexOfOverflowItem} /> - ); - })} - {overflowItems.length > 0 && ( - { - const isBottom = item?.position === 'bottom'; - let active = - activeGlobalDrawersIds.includes(item.id) && (!expandedDrawerId || item.id === expandedDrawerId); - if (isBottom) { - active = item.id === activeGlobalBottomDrawerId && (!expandedDrawerId || item.id === expandedDrawerId); - } - return { - ...item, - active, - }; - })} - ariaLabel={overflowMenuHasBadge ? ariaLabels?.drawersOverflowWithBadge : ariaLabels?.drawersOverflow} - customTriggerBuilder={({ onClick, triggerRef, ariaLabel, ariaExpanded, testUtilsClass }) => { - return ( - - ); - }} - onItemClick={event => { - const id = event.detail.id; - exitExpandedMode(); - const item = overflowItems.find(item => item.id === id); - const isBottom = item?.position === 'bottom'; - if (isBottom) { - const selected = - item.id === activeGlobalBottomDrawerId && (!expandedDrawerId || item.id === expandedDrawerId); - onActiveGlobalBottomDrawerChange?.(selected ? null : item.id, { initiatedByUserAction: true }); - return; - } - if (globalDrawers.find(drawer => drawer.id === id)) { - if (!!expandedDrawerId && id !== expandedDrawerId && activeGlobalDrawersIds.includes(id)) { - return; - } - onActiveGlobalDrawersChange?.(id, { initiatedByUserAction: true }); - } else { - onActiveDrawerChange?.(event.detail.id, { initiatedByUserAction: true }); - } - }} - globalDrawersStartIndex={globalDrawersStartIndex - indexOfOverflowItem} - /> + )} diff --git a/src/app-layout/visual-refresh-toolbar/toolbar/index.tsx b/src/app-layout/visual-refresh-toolbar/toolbar/index.tsx index 10391aac5e..4d401ba8bf 100644 --- a/src/app-layout/visual-refresh-toolbar/toolbar/index.tsx +++ b/src/app-layout/visual-refresh-toolbar/toolbar/index.tsx @@ -6,6 +6,7 @@ import clsx from 'clsx'; import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import { createWidgetizedComponent } from '../../../internal/widgets'; import { AppLayoutProps } from '../../interfaces'; import { OnChangeParams } from '../../utils/use-drawers'; @@ -158,102 +159,110 @@ export function AppLayoutToolbarImplementation({ insetBlockStart: verticalOffsets.toolbar, }} > - - {state => ( -
- { - if (setExpandedDrawerId) { - setExpandedDrawerId(null); - } - onActiveAiDrawerChange?.(aiDrawer?.id ?? null, { initiatedByUserAction: true }); + + + {state => ( +
-
- )} -
+ > + { + if (setExpandedDrawerId) { + setExpandedDrawerId(null); + } + onActiveAiDrawerChange?.(aiDrawer?.id ?? null, { initiatedByUserAction: true }); + }} + ref={aiDrawerFocusRef} + selected={!drawerExpandedMode && !!activeAiDrawerId} + disabled={anyPanelOpenInMobile} + variant={aiDrawer?.trigger?.customIcon ? 'custom' : 'circle'} + testId={`awsui-app-layout-trigger-${aiDrawer?.id}`} + isForPreviousActiveDrawer={true} + /> +
+ )} +
+ {hasNavigation && ( )} {(breadcrumbs || discoveredBreadcrumbs) && ( - + + + )} {(drawers?.length || globalDrawers?.length || bottomDrawers?.length || (hasSplitPanel && splitPanelToggleProps?.displayed)) && (
- !!item.trigger) ?? []} - drawersFocusRef={drawersFocusRef} - onActiveDrawerChange={onActiveDrawerChange} - splitPanelToggleProps={splitPanelToggleProps?.displayed ? splitPanelToggleProps : undefined} - splitPanelFocusRef={splitPanelFocusRef} - onSplitPanelToggle={onSplitPanelToggle} - disabled={anyPanelOpenInMobile} - globalDrawersFocusControl={globalDrawersFocusControl} - bottomDrawersFocusRef={bottomDrawersFocusRef} - globalDrawers={globalDrawers?.filter(item => !!item.trigger) ?? []} - activeGlobalDrawersIds={activeGlobalDrawersIds ?? []} - onActiveGlobalDrawersChange={onActiveGlobalDrawersChange} - expandedDrawerId={expandedDrawerId} - setExpandedDrawerId={setExpandedDrawerId!} - bottomDrawers={bottomDrawers} - onActiveGlobalBottomDrawerChange={onActiveGlobalBottomDrawerChange} - activeGlobalBottomDrawerId={activeGlobalBottomDrawerId} - featureNotificationsProps={featureNotificationsProps} - /> + + !!item.trigger) ?? []} + drawersFocusRef={drawersFocusRef} + onActiveDrawerChange={onActiveDrawerChange} + splitPanelToggleProps={splitPanelToggleProps?.displayed ? splitPanelToggleProps : undefined} + splitPanelFocusRef={splitPanelFocusRef} + onSplitPanelToggle={onSplitPanelToggle} + disabled={anyPanelOpenInMobile} + globalDrawersFocusControl={globalDrawersFocusControl} + bottomDrawersFocusRef={bottomDrawersFocusRef} + globalDrawers={globalDrawers?.filter(item => !!item.trigger) ?? []} + activeGlobalDrawersIds={activeGlobalDrawersIds ?? []} + onActiveGlobalDrawersChange={onActiveGlobalDrawersChange} + expandedDrawerId={expandedDrawerId} + setExpandedDrawerId={setExpandedDrawerId!} + bottomDrawers={bottomDrawers} + onActiveGlobalBottomDrawerChange={onActiveGlobalBottomDrawerChange} + activeGlobalBottomDrawerId={activeGlobalBottomDrawerId} + featureNotificationsProps={featureNotificationsProps} + /> +
)}
diff --git a/src/app-layout/visual-refresh-toolbar/widget-areas/after-main-slot.tsx b/src/app-layout/visual-refresh-toolbar/widget-areas/after-main-slot.tsx index 85c2a1d406..53faeeb1fd 100644 --- a/src/app-layout/visual-refresh-toolbar/widget-areas/after-main-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/widget-areas/after-main-slot.tsx @@ -3,6 +3,7 @@ import React from 'react'; import clsx from 'clsx'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import { createWidgetizedComponent } from '../../../internal/widgets'; import { ActiveDrawersContext } from '../../utils/visibility-context'; import { @@ -17,7 +18,7 @@ import { isWidgetReady } from '../state/invariants'; import sharedStyles from '../../resize/styles.css.js'; import styles from '../skeleton/styles.css.js'; -export const AfterMainSlotImplementation = ({ appLayoutState, appLayoutProps }: SkeletonPartProps) => { +export const AfterMainSlotImplementationInternal = ({ appLayoutState, appLayoutProps }: SkeletonPartProps) => { if (!isWidgetReady(appLayoutState)) { return null; } @@ -63,23 +64,25 @@ export const AfterMainSlotImplementation = ({ appLayoutState, appLayoutProps }: )} -
- {drawers && drawers.length > 0 && ( - - )} -
+ +
+ {drawers && drawers.length > 0 && ( + + )} +
+
@@ -89,4 +92,10 @@ export const AfterMainSlotImplementation = ({ appLayoutState, appLayoutProps }: ); }; +export const AfterMainSlotImplementation = (props: SkeletonPartProps) => ( + + + +); + export const createWidgetizedAppLayoutAfterMainSlot = createWidgetizedComponent(AfterMainSlotImplementation); diff --git a/src/app-layout/visual-refresh-toolbar/widget-areas/before-main-slot.tsx b/src/app-layout/visual-refresh-toolbar/widget-areas/before-main-slot.tsx index 1edc091271..3ab05d0d04 100644 --- a/src/app-layout/visual-refresh-toolbar/widget-areas/before-main-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/widget-areas/before-main-slot.tsx @@ -3,6 +3,7 @@ import React, { useRef } from 'react'; import clsx from 'clsx'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import { createWidgetizedComponent } from '../../../internal/widgets'; import { ActiveDrawersContext } from '../../utils/visibility-context'; import { AppLayoutGlobalAiDrawerImplementation } from '../drawer/global-ai-drawer'; @@ -15,7 +16,11 @@ import { AppLayoutToolbarImplementation as AppLayoutToolbar } from '../toolbar'; import sharedStyles from '../../resize/styles.css.js'; import styles from '../skeleton/styles.css.js'; -export const BeforeMainSlotImplementation = ({ toolbarProps, appLayoutState, appLayoutProps }: SkeletonPartProps) => { +export const BeforeMainSlotImplementationInternal = ({ + toolbarProps, + appLayoutState, + appLayoutProps, +}: SkeletonPartProps) => { const wasAiDrawerOpenRef = useRef(false); if (!isWidgetReady(appLayoutState)) { return ( @@ -108,16 +113,24 @@ export const BeforeMainSlotImplementation = ({ toolbarProps, appLayoutState, app (drawerExpandedMode || drawerExpandedModeInChildLayout) && styles.hidden )} > - + + +
)} ); }; +export const BeforeMainSlotImplementation = (props: SkeletonPartProps) => ( + + + +); + export const createWidgetizedAppLayoutBeforeMainSlot = createWidgetizedComponent( BeforeMainSlotImplementation, BeforeMainSlotSkeleton diff --git a/src/app-layout/visual-refresh-toolbar/widget-areas/bottom-content-slot.tsx b/src/app-layout/visual-refresh-toolbar/widget-areas/bottom-content-slot.tsx index d69b6b84a0..990f2082c1 100644 --- a/src/app-layout/visual-refresh-toolbar/widget-areas/bottom-content-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/widget-areas/bottom-content-slot.tsx @@ -3,6 +3,7 @@ import React from 'react'; import clsx from 'clsx'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import { createWidgetizedComponent } from '../../../internal/widgets'; import { SkeletonPartProps } from '../skeleton/interfaces'; import { AppLayoutSplitPanelDrawerBottomImplementation as AppLayoutSplitPanelBottom } from '../split-panel'; @@ -11,7 +12,7 @@ import { isWidgetReady } from '../state/invariants'; import sharedStyles from '../../resize/styles.css.js'; import styles from '../skeleton/styles.css.js'; -export const BottomContentSlotImplementation = ({ appLayoutState, appLayoutProps }: SkeletonPartProps) => { +export const BottomContentSlotImplementationInternal = ({ appLayoutState, appLayoutProps }: SkeletonPartProps) => { if (!isWidgetReady(appLayoutState)) { return null; } @@ -39,4 +40,12 @@ export const BottomContentSlotImplementation = ({ appLayoutState, appLayoutProps ); }; +export const BottomContentSlotImplementation = (props: SkeletonPartProps) => { + return ( + + + + ); +}; + export const createWidgetizedAppLayoutBottomContentSlot = createWidgetizedComponent(BottomContentSlotImplementation); diff --git a/src/app-layout/visual-refresh-toolbar/widget-areas/top-content-slot.tsx b/src/app-layout/visual-refresh-toolbar/widget-areas/top-content-slot.tsx index 5265418cab..ed2ac9bb70 100644 --- a/src/app-layout/visual-refresh-toolbar/widget-areas/top-content-slot.tsx +++ b/src/app-layout/visual-refresh-toolbar/widget-areas/top-content-slot.tsx @@ -3,6 +3,7 @@ import React from 'react'; import clsx from 'clsx'; +import { AppLayoutBuiltInErrorBoundary } from '../../../error-boundary/internal'; import { highContrastHeaderClassName } from '../../../internal/utils/content-header-utils'; import { createWidgetizedComponent } from '../../../internal/widgets'; import { AppLayoutNotificationsImplementation as AppLayoutNotifications } from '../notifications'; @@ -11,7 +12,7 @@ import { isWidgetReady } from '../state/invariants'; import styles from '../skeleton/styles.css.js'; -export const TopContentSlotImplementation = ({ appLayoutProps, appLayoutState }: SkeletonPartProps) => { +export const TopContentSlotImplementationInternal = ({ appLayoutProps, appLayoutState }: SkeletonPartProps) => { if (!isWidgetReady(appLayoutState)) { return null; } @@ -34,4 +35,10 @@ export const TopContentSlotImplementation = ({ appLayoutProps, appLayoutState }: ); }; +export const TopContentSlotImplementation = (props: SkeletonPartProps) => ( + + + +); + export const createWidgetizedAppLayoutTopContentSlot = createWidgetizedComponent(TopContentSlotImplementation); diff --git a/src/error-boundary/interfaces.ts b/src/error-boundary/interfaces.ts index 88645c1f59..32ed18166a 100644 --- a/src/error-boundary/interfaces.ts +++ b/src/error-boundary/interfaces.ts @@ -109,3 +109,7 @@ export interface BuiltInErrorBoundaryProps { wrapper?: (content: React.ReactNode) => React.ReactNode; suppressNested?: boolean; } + +export interface AppLayoutBuiltInErrorBoundaryProps extends BuiltInErrorBoundaryProps { + renderFallback?: ErrorBoundaryProps['renderFallback']; +} diff --git a/src/error-boundary/internal.tsx b/src/error-boundary/internal.tsx index fec092e9d8..04492f8b51 100644 --- a/src/error-boundary/internal.tsx +++ b/src/error-boundary/internal.tsx @@ -8,7 +8,7 @@ import { useMergeRefs } from '@cloudscape-design/component-toolkit/internal'; import { InternalBaseComponentProps } from '../internal/hooks/use-base-component'; import { SomeRequired } from '../internal/types'; import { ErrorBoundaryFallback } from './fallback'; -import { BuiltInErrorBoundaryProps, ErrorBoundaryProps } from './interfaces'; +import { AppLayoutBuiltInErrorBoundaryProps, BuiltInErrorBoundaryProps, ErrorBoundaryProps } from './interfaces'; import styles from './styles.css.js'; @@ -87,6 +87,33 @@ export function BuiltInErrorBoundary({ wrapper, suppressNested = false, children ); } +export function AppLayoutBuiltInErrorBoundary({ + wrapper, + suppressNested = false, + children, + renderFallback = () => <>, +}: AppLayoutBuiltInErrorBoundaryProps) { + const context = useContext(ErrorBoundariesContext); + const thisSuppressed = context.suppressed === true || context.suppressed === RootSuppressed; + const nextSuppressed = suppressNested || thisSuppressed; + return ( + { + context?.onError?.(error); + // TODO Implement cloudscape error reporting + }} + > + + {children} + + + ); +} + interface ErrorBoundaryState { hasError: boolean; } diff --git a/src/error-boundary/styles.scss b/src/error-boundary/styles.scss index f70659a293..4ce67662c9 100644 --- a/src/error-boundary/styles.scss +++ b/src/error-boundary/styles.scss @@ -6,6 +6,7 @@ .error-boundary, .header, .description, -.action { +.action, +.app-layout-part-fallback { display: contents; }