Skip to content

Commit

Permalink
chore: Add initialCheck capability to alert/flash content API (#2879)
Browse files Browse the repository at this point in the history
  • Loading branch information
gethinwebster authored Oct 18, 2024
1 parent cc75463 commit b22ec75
Show file tree
Hide file tree
Showing 13 changed files with 285 additions and 16 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"@types/react-resizable": "^1.7.4",
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.3.2",
"@types/react-test-renderer": "^18.3.0",
"@types/react-transition-group": "^4.4.4",
"@types/webpack-env": "^1.16.3",
"@typescript-eslint/eslint-plugin": "^5.45.0",
Expand Down
38 changes: 34 additions & 4 deletions pages/alert/runtime-content.page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { useContext, useMemo, useState } from 'react';
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';

import {
Expand All @@ -20,7 +20,9 @@ import awsuiPlugins from '~components/internal/plugins';
import AppContext, { AppContextType } from '../app/app-context';
import ScreenshotArea from '../utils/screenshot-area';

type PageContext = React.Context<AppContextType<{ loading: boolean; hidden: boolean; type: AlertProps.Type }>>;
type PageContext = React.Context<
AppContextType<{ loading: boolean; hidden: boolean; type: AlertProps.Type; autofocus: boolean }>
>;

awsuiPlugins.alertContent.registerContentReplacer({
id: 'awsui/alert-test-action',
Expand Down Expand Up @@ -60,13 +62,24 @@ awsuiPlugins.alertContent.registerContentReplacer({
},
};
},
initialCheck(context) {
return (
context.type === 'error' &&
!!(
context.content &&
typeof context.content === 'object' &&
'props' in context.content &&
context.content.props.children?.match('Access denied')
)
);
},
});

const alertTypeOptions = ['error', 'warning', 'info', 'success'].map(type => ({ value: type }));

export default function () {
const {
urlParams: { loading = false, hidden = false, type = 'error' },
urlParams: { loading = false, hidden = false, type = 'error', autofocus = false },
setUrlParams,
} = useContext(AppContext as PageContext);
const [unrelatedState, setUnrelatedState] = useState(false);
Expand All @@ -75,6 +88,14 @@ export default function () {
const content1 = useMemo(() => (loading ? <Box>Loading...</Box> : <Box>Content</Box>), [loading]);
const content2 = loading ? <Box>Loading...</Box> : <Box>There was an error: Access denied because of XYZ</Box>;

const alertRef = useRef<AlertProps.Ref>(null);

useEffect(() => {
if (autofocus && !hidden) {
alertRef.current?.focus();
}
}, [autofocus, hidden]);

return (
<Box margin="m">
<h1>Alert runtime actions</h1>
Expand All @@ -83,7 +104,11 @@ export default function () {
<Checkbox onChange={e => setUrlParams({ loading: e.detail.checked })} checked={loading}>
Content loading
</Checkbox>
<Checkbox onChange={e => setUrlParams({ hidden: e.detail.checked })} checked={hidden}>
<Checkbox
onChange={e => setUrlParams({ hidden: e.detail.checked })}
checked={hidden}
data-testid="unmount-all"
>
Unmount all
</Checkbox>
<Checkbox onChange={e => setUnrelatedState(e.detail.checked)} checked={unrelatedState}>
Expand All @@ -92,6 +117,9 @@ export default function () {
<Checkbox onChange={e => setContentSwapped(e.detail.checked)} checked={contentSwapped}>
Swap content
</Checkbox>
<Checkbox onChange={e => setUrlParams({ autofocus: e.detail.checked })} checked={autofocus}>
Auto-focus alert
</Checkbox>
<FormField label="Alert type">
<Select
options={alertTypeOptions}
Expand Down Expand Up @@ -122,6 +150,8 @@ export default function () {
dismissAriaLabel="Dismiss"
header="Header"
action={<Button>Action</Button>}
ref={alertRef}
data-testid="error-alert"
>
{!contentSwapped ? content2 : content1}
</Alert>
Expand Down
33 changes: 31 additions & 2 deletions pages/flashbar/runtime-content.page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import React, { useContext, useState } from 'react';
import React, { ReactNode, useContext, useState } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import flattenChildren from 'react-keyed-flatten-children';

import {
Box,
Expand All @@ -24,6 +25,12 @@ type PageContext = React.Context<
AppContextType<{ loading: boolean; hidden: boolean; stackItems: boolean; type: FlashbarProps.Type }>
>;

const nodeAsString = (node: ReactNode) =>
flattenChildren(node)
.map(node => (typeof node === 'object' ? node.props.children : node))
.filter(node => typeof node === 'string')
.join('');

awsuiPlugins.flashContent.registerContentReplacer({
id: 'awsui/flashbar-test-action',
runReplacer(context, replacer) {
Expand Down Expand Up @@ -62,10 +69,32 @@ awsuiPlugins.flashContent.registerContentReplacer({
},
};
},
initialCheck(context) {
return context.type === 'error' && !!nodeAsString(context.content).match('Access denied');
},
});

const messageTypeOptions = ['error', 'warning', 'info', 'success'].map(type => ({ value: type }));

const content = (
<>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
<p>There was an error: Access denied because of XYZ</p>
</>
);

export default function () {
const {
urlParams: { loading = false, hidden = false, stackItems = false, type = 'error' },
Expand Down Expand Up @@ -117,7 +146,7 @@ export default function () {
type,
statusIconAriaLabel: type,
header: 'Header',
content: loading ? 'Loading...' : 'There was an error: Access denied because of XYZ',
content: loading ? 'Loading...' : content,
action: <Button>Action</Button>,
},
]}
Expand Down
33 changes: 33 additions & 0 deletions src/alert/__integ__/runtime-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// 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';

class RuntimeContentPage extends BasePageObject {
async rerenderAlerts() {
await this.click(createWrapper().findCheckbox('[data-testid="unmount-all"]').findNativeInput().toSelector());
await this.keys(['Space']);
}
}

function setupTest(testFn: (page: RuntimeContentPage) => Promise<void>) {
return useBrowser(async browser => {
const page = new RuntimeContentPage(browser);
await browser.url('#/light/alert/runtime-content/?autofocus=true');
await page.waitForVisible('.screenshot-area');
await testFn(page);
});
}

test(
'should focus the alert',
setupTest(async page => {
await page.rerenderAlerts();

await expect(page.getFocusedElementText()).resolves.toEqual(expect.stringContaining('---REPLACEMENT---'));
// (make sure entire page isn't focused)
await expect(page.getFocusedElementText()).resolves.toEqual(expect.not.stringContaining('Header'));
})
);
59 changes: 59 additions & 0 deletions src/alert/__tests__/runtime-content-initial.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import testRenderer, { ReactTestRendererJSON } from 'react-test-renderer';

import Alert from '../../../lib/components/alert';
import awsuiPlugins from '../../../lib/components/internal/plugins';
import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api';
import { AlertFlashContentConfig } from '../../../lib/components/internal/plugins/controllers/alert-flash-content';

import stylesCss from '../../../lib/components/alert/styles.css.js';

afterEach(() => {
awsuiPluginsInternal.alertContent.clearRegisteredReplacer();
jest.resetAllMocks();
jest.restoreAllMocks();
});

// In a separate file as mixing react-test-renderer and @testing-library/react in a single file can cause some issues.
// We use react-test-renderer here as it works at a slightly lower level, so doesn't "hide" the first render cycle from tests.
describe('initialCheck method', () => {
let initialCheck: jest.Mock<boolean>;
beforeEach(() => {
initialCheck = jest.fn(() => true);
const plugin: AlertFlashContentConfig = {
id: 'plugin-1',
runReplacer: () => {
return { update: () => {}, unmount: () => {} };
},
initialCheck,
};
awsuiPlugins.alertContent.registerContentReplacer(plugin);
});

test('calls `initialCheck` method, and hides alert if true', () => {
const basicRender = testRenderer.create(<Alert type="error">Content</Alert>);
expect(initialCheck).toHaveBeenCalledTimes(1);
expect((basicRender.toJSON() as ReactTestRendererJSON).props.className.split(' ')).toEqual(
expect.arrayContaining([stylesCss['initial-hidden']])
);
});

test('re-shows alert on next render', () => {
const basicRender = testRenderer.create(<Alert type="error">Content</Alert>);
basicRender.update(<Alert type="error">Content</Alert>);
expect(initialCheck).toHaveBeenCalledTimes(1);
expect((basicRender.toJSON() as ReactTestRendererJSON).props.className.split(' ')).toEqual(
expect.not.arrayContaining([stylesCss['initial-hidden']])
);
});

test('does not hide alert if `initialCheck` returns false', () => {
initialCheck.mockReturnValue(false);
const basicRender = testRenderer.create(<Alert type="error">Content</Alert>);
expect((basicRender.toJSON() as ReactTestRendererJSON).props.className.split(' ')).toEqual(
expect.not.arrayContaining([stylesCss['initial-hidden']])
);
});
});
9 changes: 7 additions & 2 deletions src/alert/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const typeToIcon: Record<AlertProps.Type, IconProps['name']> = {
type InternalAlertProps = SomeRequired<AlertProps, 'type'> & InternalBaseComponentProps<HTMLDivElement>;

const useDiscoveredAction = createUseDiscoveredAction(awsuiPluginsInternal.alert.onActionRegistered);
const useDiscoveredContent = createUseDiscoveredContent('alert', awsuiPluginsInternal.alertContent.onContentRegistered);
const useDiscoveredContent = createUseDiscoveredContent('alert', awsuiPluginsInternal.alertContent);

const InternalAlert = React.forwardRef(
(
Expand Down Expand Up @@ -71,6 +71,7 @@ const InternalAlert = React.forwardRef(

const { discoveredActions, headerRef: headerRefAction, contentRef: contentRefAction } = useDiscoveredAction(type);
const {
initialHidden,
headerReplacementType,
contentReplacementType,
headerRef: headerRefContent,
Expand Down Expand Up @@ -100,7 +101,11 @@ const InternalAlert = React.forwardRef(
{...baseProps}
{...analyticsAttributes}
aria-hidden={!visible}
className={clsx(styles.root, { [styles.hidden]: !visible }, baseProps.className)}
className={clsx(
styles.root,
{ [styles.hidden]: !visible, [styles['initial-hidden']]: initialHidden },
baseProps.className
)}
ref={mergedRef}
>
<LinkDefaultVariantContext.Provider value={{ defaultVariant: 'primary' }}>
Expand Down
5 changes: 5 additions & 0 deletions src/alert/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
.hidden {
display: none;
}
// visibly hidden, but focusable
.initial-hidden {
overflow: hidden;
block-size: 0;
}

.header,
.header-replacement {
Expand Down
65 changes: 65 additions & 0 deletions src/flashbar/__tests__/runtime-content-initial.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import testRenderer, { ReactTestRenderer, ReactTestRendererJSON } from 'react-test-renderer';

import Flashbar from '../../../lib/components/flashbar';
import awsuiPlugins from '../../../lib/components/internal/plugins';
import { awsuiPluginsInternal } from '../../../lib/components/internal/plugins/api';
import { AlertFlashContentConfig } from '../../../lib/components/internal/plugins/controllers/alert-flash-content';

import stylesCss from '../../../lib/components/flashbar/styles.css.js';

afterEach(() => {
awsuiPluginsInternal.flashContent.clearRegisteredReplacer();
jest.resetAllMocks();
jest.restoreAllMocks();
});

// In a separate file as mixing react-test-renderer and @testing-library/react in a single file can cause some issues.
// We use react-test-renderer here as it works at a slightly lower level, so doesn't "hide" the first render cycle from tests.
describe('initialCheck method', () => {
let initialCheck: jest.Mock<boolean>;
const getFirstFlash = (basicRender: ReactTestRenderer) =>
(
((basicRender.toJSON() as ReactTestRendererJSON).children![0] as ReactTestRendererJSON)
.children![0] as ReactTestRendererJSON
).children![0] as ReactTestRendererJSON;
beforeEach(() => {
initialCheck = jest.fn(() => true);
const plugin: AlertFlashContentConfig = {
id: 'plugin-1',
runReplacer: () => {
return { update: () => {}, unmount: () => {} };
},
initialCheck,
};
awsuiPlugins.flashContent.registerContentReplacer(plugin);
});

test('calls `initialCheck` method, and hides flash if true', () => {
const basicRender = testRenderer.create(<Flashbar items={[{}]} />);
expect(initialCheck).toHaveBeenCalledTimes(1);
expect(getFirstFlash(basicRender).props.className.split(' ')).toEqual(
expect.arrayContaining([stylesCss['initial-hidden']])
);
});

test('re-shows alert on next render', () => {
const basicRender = testRenderer.create(<Flashbar items={[{}]} />);
basicRender.update(<Flashbar items={[{}]} />);
expect(initialCheck).toHaveBeenCalledTimes(1);
expect(getFirstFlash(basicRender).props.className.split(' ')).toEqual(
expect.not.arrayContaining([stylesCss['initial-hidden']])
);
});

test('does not hide flash if `initialCheck` returns false', () => {
initialCheck.mockReturnValue(false);
const basicRender = testRenderer.create(<Flashbar items={[{}]} />);
expect(initialCheck).toHaveBeenCalledTimes(1);
expect(getFirstFlash(basicRender).props.className.split(' ')).toEqual(
expect.not.arrayContaining([stylesCss['initial-hidden']])
);
});
});
Loading

0 comments on commit b22ec75

Please sign in to comment.