Skip to content

Commit

Permalink
chore: Add alert/flash content replacement runtime API (#2647)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrei Zhaleznichenka <[email protected]>
  • Loading branch information
gethinwebster and pan-kot authored Sep 13, 2024
1 parent 2a83d43 commit a039398
Show file tree
Hide file tree
Showing 16 changed files with 1,379 additions and 24 deletions.
134 changes: 134 additions & 0 deletions pages/alert/runtime-content.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

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

import {
Alert,
AlertProps,
Box,
Button,
Checkbox,
ExpandableSection,
FormField,
Select,
SpaceBetween,
} from '~components';
import awsuiPlugins from '~components/internal/plugins';

import AppContext, { AppContextType } from '../app/app-context';
import ScreenshotArea from '../utils/screenshot-area';

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

awsuiPlugins.alertContent.registerContentReplacer({
id: 'awsui/alert-test-action',
runReplacer(context, replacer) {
console.log('mount');

const doReplace = () => {
replacer.restoreHeader();
replacer.restoreContent();
if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) {
replacer.hideHeader();
replacer.replaceContent(container => {
console.log('render replacement content');
render(
<SpaceBetween size="s">
<Box>---REPLACEMENT--- Access denied message! ---REPLACEMENT---</Box>
<ExpandableSection headerText="Original message">
{context.contentRef.current?.textContent}
</ExpandableSection>
</SpaceBetween>,
container
);
});
}
};

doReplace();

return {
update() {
console.log('update');
doReplace();
},
unmount({ replacementContentContainer }) {
console.log('unmount');
unmountComponentAtNode(replacementContentContainer);
},
};
},
});

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

export default function () {
const {
urlParams: { loading = false, hidden = false, type = 'error' },
setUrlParams,
} = useContext(AppContext as PageContext);
const [unrelatedState, setUnrelatedState] = useState(false);
const [contentSwapped, setContentSwapped] = useState(false);

const content1 = useMemo(() => (loading ? <Box>Loading...</Box> : <Box>Content</Box>), [loading]);
const content2 = loading ? <Box>Loading...</Box> : <Box>There was an error: Access denied because of XYZ</Box>;

return (
<Box margin="m">
<h1>Alert runtime actions</h1>
<SpaceBetween size="m">
<SpaceBetween size="s">
<Checkbox onChange={e => setUrlParams({ loading: e.detail.checked })} checked={loading}>
Content loading
</Checkbox>
<Checkbox onChange={e => setUrlParams({ hidden: e.detail.checked })} checked={hidden}>
Unmount all
</Checkbox>
<Checkbox onChange={e => setUnrelatedState(e.detail.checked)} checked={unrelatedState}>
Unrelated state
</Checkbox>
<Checkbox onChange={e => setContentSwapped(e.detail.checked)} checked={contentSwapped}>
Swap content
</Checkbox>
<FormField label="Alert type">
<Select
options={alertTypeOptions}
selectedOption={alertTypeOptions.find(option => option.value === type) ?? alertTypeOptions[0]}
onChange={e => setUrlParams({ type: e.detail.selectedOption.value as AlertProps.Type })}
/>
</FormField>
</SpaceBetween>

<hr />

<ScreenshotArea gutters={false}>
{hidden ? null : (
<SpaceBetween size="m">
<Alert
type={type}
statusIconAriaLabel={type}
dismissAriaLabel="Dismiss"
header="Header"
action={<Button>Action</Button>}
>
{!contentSwapped ? content1 : content2}
</Alert>

<Alert
type={type}
statusIconAriaLabel={type}
dismissAriaLabel="Dismiss"
header="Header"
action={<Button>Action</Button>}
>
{!contentSwapped ? content2 : content1}
</Alert>
</SpaceBetween>
)}
</ScreenshotArea>
</SpaceBetween>
</Box>
);
}
130 changes: 130 additions & 0 deletions pages/flashbar/runtime-content.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

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

import {
Box,
Button,
Checkbox,
ExpandableSection,
Flashbar,
FlashbarProps,
FormField,
Select,
SpaceBetween,
} from '~components';
import awsuiPlugins from '~components/internal/plugins';

import AppContext, { AppContextType } from '../app/app-context';
import ScreenshotArea from '../utils/screenshot-area';

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

awsuiPlugins.flashContent.registerContentReplacer({
id: 'awsui/flashbar-test-action',
runReplacer(context, replacer) {
console.log('mount');

const doReplace = () => {
replacer.restoreHeader();
replacer.restoreContent();
if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) {
replacer.hideHeader();
replacer.replaceContent(container => {
console.log('render replacement content');
render(
<SpaceBetween size="s">
<Box>---REPLACEMENT--- Access denied message! ---REPLACEMENT---</Box>
<ExpandableSection headerText="Original message">
{context.contentRef.current?.textContent}
</ExpandableSection>
</SpaceBetween>,
container
);
});
}
};

doReplace();

return {
update() {
console.log('update');
doReplace();
},
unmount({ replacementContentContainer }) {
console.log('unmount');
unmountComponentAtNode(replacementContentContainer);
},
};
},
});

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

export default function () {
const {
urlParams: { loading = false, hidden = false, stackItems = false, type = 'error' },
setUrlParams,
} = useContext(AppContext as PageContext);
const [unrelatedState, setUnrelatedState] = useState(false);

return (
<Box margin="m">
<h1>Flashbar runtime actions</h1>
<SpaceBetween size="m">
<SpaceBetween size="s">
<Checkbox onChange={e => setUrlParams({ loading: e.detail.checked })} checked={loading}>
Content loading
</Checkbox>
<Checkbox onChange={e => setUrlParams({ hidden: e.detail.checked })} checked={hidden}>
Unmount all
</Checkbox>
<Checkbox onChange={e => setUrlParams({ stackItems: e.detail.checked })} checked={stackItems}>
Stack items
</Checkbox>
<Checkbox onChange={e => setUnrelatedState(e.detail.checked)} checked={unrelatedState}>
Unrelated state
</Checkbox>
<FormField label="Message type">
<Select
options={messageTypeOptions}
selectedOption={messageTypeOptions.find(option => option.value === type) ?? messageTypeOptions[0]}
onChange={e => setUrlParams({ type: e.detail.selectedOption.value as FlashbarProps.Type })}
/>
</FormField>
</SpaceBetween>

<hr />

<ScreenshotArea gutters={false}>
{hidden ? null : (
<Flashbar
stackItems={stackItems}
items={[
{
type,
statusIconAriaLabel: type,
header: 'Header',
content: loading ? 'Loading...' : 'Content',
action: <Button>Action</Button>,
},
{
type,
statusIconAriaLabel: type,
header: 'Header',
content: loading ? 'Loading...' : 'There was an error: Access denied because of XYZ',
action: <Button>Action</Button>,
},
]}
/>
)}
</ScreenshotArea>
</SpaceBetween>
</Box>
);
}
34 changes: 34 additions & 0 deletions src/alert/__tests__/alert.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,25 @@ import '../../__a11y__/to-validate-a11y';
import Alert, { AlertProps } from '../../../lib/components/alert';
import Button from '../../../lib/components/button';
import { DATA_ATTR_ANALYTICS_ALERT } from '../../../lib/components/internal/analytics/selectors';
import { useVisualRefresh } from '../../../lib/components/internal/hooks/use-visual-mode';
import createWrapper from '../../../lib/components/test-utils/dom';

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

jest.mock('../../../lib/components/internal/hooks/use-visual-mode', () => ({
...jest.requireActual('../../../lib/components/internal/hooks/use-visual-mode'),
useVisualRefresh: jest.fn().mockReturnValue(false),
}));

function renderAlert(props: AlertProps = {}) {
const { container } = render(<Alert {...props} />);
return { wrapper: createWrapper(container).findAlert()!, container };
}

beforeEach(() => {
jest.mocked(useVisualRefresh).mockReset();
});

describe('Alert Component', () => {
describe('structure', () => {
it('has no header container when no header is set', () => {
Expand Down Expand Up @@ -153,4 +163,28 @@ describe('Alert Component', () => {
expect(wrapper.getElement()).toHaveAttribute(DATA_ATTR_ANALYTICS_ALERT, 'success');
});
});

describe('icon size', () => {
test('classic - big if has header and content', () => {
const { wrapper } = renderAlert({ header: 'Header', children: ['Content'] });
expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeFalsy();
expect(wrapper.findByClassName(styles['icon-size-big'])).toBeTruthy();
});
test('classic - normal if only header', () => {
const { wrapper } = renderAlert({ header: 'Header' });
expect(wrapper.findByClassName(styles['icon-size-big'])).toBeFalsy();
expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeTruthy();
});
test('classic - normal if only content', () => {
const { wrapper } = renderAlert({ children: ['Content'] });
expect(wrapper.findByClassName(styles['icon-size-big'])).toBeFalsy();
expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeTruthy();
});
test('visual refresh - always normal', () => {
jest.mocked(useVisualRefresh).mockReturnValue(true);
const { wrapper } = renderAlert({ header: 'Header', children: ['Content'] });
expect(wrapper.findByClassName(styles['icon-size-big'])).toBeFalsy();
expect(wrapper.findByClassName(styles['icon-size-normal'])).toBeTruthy();
});
});
});
62 changes: 62 additions & 0 deletions src/alert/__tests__/runtime-content-utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { AlertWrapper } from '../../../lib/components/test-utils/dom';
import FlashWrapper from '../../../lib/components/test-utils/dom/flashbar/flash.js';

import alertStyles from '../../../lib/components/alert/styles.selectors.js';
import flashbarStyles from '../../../lib/components/flashbar/styles.selectors.js';

export const expectContent = (
wrapper: AlertWrapper | FlashWrapper,
stylesCss: Record<string, string>,
{
header,
headerReplaced,
content,
contentReplaced,
}: {
header?: string | false;
headerReplaced?: boolean;
content?: string | false;
contentReplaced?: boolean;
}
) => {
if (header) {
if (headerReplaced) {
if (wrapper.findHeader()) {
expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden);
}
expect(findReplacementHeader(wrapper)?.getElement().textContent).toBe(header);
} else {
expect(findReplacementHeader(wrapper)?.getElement()).toHaveClass(stylesCss.hidden);
expect(wrapper.findHeader()?.getElement().textContent).toBe(header);
}
} else if (header === false) {
if (wrapper.findHeader()) {
expect(wrapper.findHeader()?.getElement()).toHaveClass(stylesCss.hidden);
}
expect(findReplacementHeader(wrapper)?.getElement()).toHaveClass(stylesCss.hidden);
}
if (content) {
if (contentReplaced) {
expect(wrapper.findContent()?.getElement()).toHaveClass(stylesCss.hidden);
expect(findReplacementContent(wrapper)?.getElement().textContent).toBe(content);
} else {
expect(findReplacementContent(wrapper)?.getElement()).toHaveClass(stylesCss.hidden);
expect(wrapper.findContent()?.getElement().textContent).toBe(content);
}
} else if (content === false) {
expect(wrapper.findContent()?.getElement()).toHaveClass(stylesCss.hidden);
expect(findReplacementContent(wrapper)?.getElement()).toHaveClass(stylesCss.hidden);
}
};

function findReplacementHeader(wrapper: AlertWrapper | FlashWrapper) {
const styles = wrapper instanceof AlertWrapper ? alertStyles : flashbarStyles;
return wrapper.findByClassName(styles['header-replacement']);
}

function findReplacementContent(wrapper: AlertWrapper | FlashWrapper) {
const styles = wrapper instanceof AlertWrapper ? alertStyles : flashbarStyles;
return wrapper.findByClassName(styles['content-replacement'])!;
}
Loading

0 comments on commit a039398

Please sign in to comment.