Skip to content

Commit

Permalink
feat: Add alert content replacement runtime API
Browse files Browse the repository at this point in the history
  • Loading branch information
gethinwebster committed Sep 2, 2024
1 parent bce2ba0 commit 5c61041
Show file tree
Hide file tree
Showing 10 changed files with 935 additions and 31 deletions.
77 changes: 77 additions & 0 deletions pages/alert/runtime-content.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import React, { useState } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';

import { Checkbox, TextContent } from '~components';
import Alert, { AlertProps } from '~components/alert';
import Button from '~components/button';
import awsuiPlugins from '~components/internal/plugins';

import createPermutations from '../utils/permutations';
import PermutationsView from '../utils/permutations-view';
import ScreenshotArea from '../utils/screenshot-area';

awsuiPlugins.alertContent.registerContent({
id: 'awsui/alert-test-action',
mountContent: (container, context) => {
if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) {
render(
<div>
Access was denied
<TextContent>
<code>Some more details</code>
</TextContent>
</div>,
container
);
return true;
}
return null;
},
mountHeader: (container, context) => {
if (context.type === 'error' && context.contentRef.current?.textContent?.match('Access denied')) {
container.replaceChildren();
return false;
}
return null;
},
unmountContent: container => unmountComponentAtNode(container),
unmountHeader: () => {},
});

/* eslint-disable react/jsx-key */
const permutations = createPermutations<AlertProps>([
{
header: [null, 'Alert'],
children: ['Content', 'There was an error: Access denied because of XYZ'],
type: ['success', 'error'],
action: [null, <Button>Action</Button>],
},
]);
/* eslint-enable react/jsx-key */

export default function () {
const [loading, setLoading] = useState(true);
return (
<>
<h1>Alert runtime actions</h1>
<Checkbox onChange={e => setLoading(e.detail.checked)} checked={loading}>
Loading
</Checkbox>
<ScreenshotArea>
<PermutationsView
permutations={permutations}
render={permutation => (
<>
<Alert statusIconAriaLabel={permutation.type} dismissAriaLabel="Dismiss" {...permutation} />
<Alert statusIconAriaLabel={permutation.type} dismissAriaLabel="Dismiss" {...permutation}>
{loading ? 'Loading' : permutation.children}
</Alert>
</>
)}
/>
</ScreenshotArea>
</>
);
}
275 changes: 275 additions & 0 deletions src/alert/__tests__/runtime-content.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import * as React from 'react';
import { act, render, screen } from '@testing-library/react';

import Alert from '../../../lib/components/alert';
import Button from '../../../lib/components/button';
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 { AlertWrapper } from '../../../lib/components/test-utils/dom';

const pause = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout));

const defaultContent: AlertFlashContentConfig = {
id: 'test-content',
mountContent: container => {
const content = document.createElement('div');
content.append('New content');
content.dataset.testid = 'test-content';
container.replaceChildren(content);
return true;
},
mountHeader: container => {
const content = document.createElement('div');
content.append('New header');
content.dataset.testid = 'test-header';
container.replaceChildren(content);
return true;
},
unmountContent: jest.fn(),
unmountHeader: jest.fn(),
};

function delay(advanceBy = 1) {
const promise = act(() => new Promise(resolve => setTimeout(resolve)));
jest.advanceTimersByTime(advanceBy);
return promise;
}

beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
awsuiPluginsInternal.alertContent.clearRegisteredContent();
jest.useRealTimers();
jest.resetAllMocks();
});

test('renders runtime content initially', async () => {
awsuiPlugins.alertContent.registerContent(defaultContent);
const { container } = render(<Alert>Alert content</Alert>);
const alertWrapper = new AlertWrapper(container);
await delay();
expect(screen.queryByTestId('test-content')).toBeTruthy();
expect(alertWrapper.findContent().getElement().textContent).toBe('New content');
});

test('renders runtime content asynchronously', async () => {
render(<Alert />);
await delay();
expect(screen.queryByTestId('test-content')).toBeFalsy();
awsuiPlugins.alertContent.registerContent(defaultContent);
await delay();
expect(screen.queryByTestId('test-content')).toBeTruthy();
});

describe.each([true, false])('existing header:%p', existingHeader => {
test('renders runtime header initially', async () => {
awsuiPlugins.alertContent.registerContent(defaultContent);
const { container } = render(<Alert header={existingHeader ? 'Header content' : undefined}>Alert content</Alert>);
const alertWrapper = new AlertWrapper(container);
await delay();
expect(screen.queryByTestId('test-header')).toBeTruthy();
expect(alertWrapper.findHeader()!.getElement().textContent).toBe('New header');
});

test('renders runtime header asynchronously', async () => {
const { container } = render(<Alert header={existingHeader ? 'Header content' : undefined}>Alert content</Alert>);
const alertWrapper = new AlertWrapper(container);
await delay();
expect(screen.queryByTestId('test-header')).toBeFalsy();
awsuiPlugins.alertContent.registerContent(defaultContent);
await delay();
expect(screen.queryByTestId('test-header')).toBeTruthy();
expect(alertWrapper.findHeader()!.getElement().textContent).toBe('New header');
});
});

test('removes header styling if runtime header is explicitly empty', async () => {
const plugin: AlertFlashContentConfig = {
id: 'test-content',
mountHeader(container) {
container.replaceChildren();
return false;
},
};
awsuiPlugins.alertContent.registerContent(plugin);
const { container } = render(<Alert header="Initial header content" />);
const alertWrapper = new AlertWrapper(container);
expect(alertWrapper.findHeader()).toBeTruthy();
await delay();
expect(alertWrapper.findHeader()).toBeFalsy();
});

describe('mountContent arguments', () => {
const mountContent = jest.fn();
beforeEach(() => {
const plugin: AlertFlashContentConfig = {
id: 'test-content',
mountContent,
};
awsuiPlugins.alertContent.registerContent(plugin);
});
test('refs', async () => {
render(
<Alert header="Alert header" action={<Button>Action button</Button>}>
Alert content
</Alert>
);
await delay();
expect(mountContent.mock.lastCall[1].headerRef.current).toHaveTextContent('Alert header');
expect(mountContent.mock.lastCall[1].contentRef.current).toHaveTextContent('Alert content');
expect(mountContent.mock.lastCall[1].actionsRef.current).toHaveTextContent('Action button');
});
test('type - default', async () => {
render(<Alert />);
await delay();
expect(mountContent.mock.lastCall[1].type).toBe('info');
});
test('type - custom', async () => {
render(<Alert type="error" />);
await delay();
expect(mountContent.mock.lastCall[1].type).toBe('error');
});
});

describe('unmounting', () => {
test('unmounts content and header', async () => {
const plugin: AlertFlashContentConfig = {
id: 'test-content',
mountContent: () => true,
unmountContent: jest.fn(),
unmountHeader: jest.fn(),
};
awsuiPlugins.alertContent.registerContent(plugin);
const { unmount } = render(<Alert>Alert content</Alert>);
await delay();
expect(plugin.unmountContent).toBeCalledTimes(0);
expect(plugin.unmountHeader).toBeCalledTimes(0);
unmount();
await delay();
expect(plugin.unmountContent).toBeCalledTimes(1);
expect(plugin.unmountHeader).toBeCalledTimes(1);
});

test('does not unmount if not rendered', async () => {
const noRender: AlertFlashContentConfig = {
id: 'test-content',
mountContent: () => null,
mountHeader: () => null,
unmountContent: jest.fn(),
unmountHeader: jest.fn(),
};
awsuiPlugins.alertContent.registerContent(noRender);
const { unmount } = render(<Alert>Alert content</Alert>);
await delay();
unmount();
await delay();
expect(noRender.unmountContent).toBeCalledTimes(0);
expect(noRender.unmountHeader).toBeCalledTimes(0);
});
});

describe('asynchronous rendering', () => {
test('allows asynchronous rendering of content', async () => {
const asyncContent: AlertFlashContentConfig = {
id: 'test-content-async',
mountContent: async container => {
await pause(1000);
const content = document.createElement('div');
content.append('New content');
content.dataset.testid = 'test-content-async';
container.replaceChildren(content);
return true;
},
};
awsuiPlugins.alertContent.registerContent(asyncContent);
const { container } = render(<Alert>Alert content</Alert>);
const alertWrapper = new AlertWrapper(container);
await delay();
expect(screen.queryByTestId('test-content-async')).toBeFalsy();
expect(alertWrapper.findContent().getElement().textContent).toBe('Alert content');
await delay(1000);
expect(screen.queryByTestId('test-content-async')).toBeTruthy();
expect(alertWrapper.findContent().getElement().textContent).toBe('New content');
});

test('cancels asynchronous rendering when unmounting', async () => {
let rendered = false;
const asyncContent: AlertFlashContentConfig = {
id: 'test-content-async',
mountContent: async (container, { signal }) => {
await pause(1000);
if (!signal.aborted) {
rendered = true;
return true;
}
return false;
},
};
awsuiPlugins.alertContent.registerContent(asyncContent);
const { unmount } = render(<Alert>Alert content</Alert>);
await delay(500);
unmount();
await delay(1000);
expect(rendered).toBeFalsy();
});

test('waits for first async plugin before trying next', async () => {
const asyncContent: AlertFlashContentConfig = {
id: 'test-content-async-1',
mountContent: jest.fn(async () => {
await pause(1000);
return null;
}),
};
const asyncContent2: AlertFlashContentConfig = {
id: 'test-content-async-2',
mountContent: jest.fn(async () => {
await pause(1000);
return true;
}),
mountHeader: jest.fn(),
};
const asyncContent3: AlertFlashContentConfig = {
id: 'test-content-async-3',
mountContent: jest.fn(),
};
awsuiPlugins.alertContent.registerContent(asyncContent);
awsuiPlugins.alertContent.registerContent(asyncContent2);
render(<Alert>Alert content</Alert>);
await delay();
expect(asyncContent.mountContent).toBeCalled();
expect(asyncContent2.mountContent).not.toBeCalled();
expect(asyncContent2.mountHeader).not.toBeCalled();
awsuiPlugins.alertContent.registerContent(asyncContent3);
expect(asyncContent3.mountContent).not.toBeCalled();
await delay(1000);
expect(asyncContent2.mountContent).toBeCalled();
expect(asyncContent2.mountHeader).toBeCalled();
expect(asyncContent3.mountContent).not.toBeCalled();
await delay(1000);
expect(asyncContent3.mountContent).not.toBeCalled();
});
});

test('only uses first plugin to return a result', async () => {
const first: AlertFlashContentConfig = {
id: 'test-content-1',
mountContent: jest.fn(() => false),
};
const second: AlertFlashContentConfig = {
id: 'test-content-2',
mountContent: jest.fn(),
mountHeader: jest.fn(),
};
awsuiPlugins.alertContent.registerContent(first);
awsuiPlugins.alertContent.registerContent(second);
render(<Alert />);
await delay();
expect(first.mountContent).toBeCalled();
expect(second.mountContent).not.toBeCalled();
expect(second.mountHeader).not.toBeCalled();
});
36 changes: 17 additions & 19 deletions src/alert/actions-wrapper/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,23 +32,21 @@ interface ActionsWrapperProps {
onButtonClick: InternalButtonProps['onClick'];
}

export function ActionsWrapper({
className,
testUtilClasses,
action,
discoveredActions,
buttonText,
onButtonClick,
}: ActionsWrapperProps) {
const actionButton = createActionButton(testUtilClasses, action, buttonText, onButtonClick);
if (!actionButton && discoveredActions.length === 0) {
return null;
}
export const ActionsWrapper = React.forwardRef(
(
{ className, testUtilClasses, action, discoveredActions, buttonText, onButtonClick }: ActionsWrapperProps,
ref: React.Ref<HTMLDivElement>
) => {
const actionButton = createActionButton(testUtilClasses, action, buttonText, onButtonClick);
if (!actionButton && discoveredActions.length === 0) {
return null;
}

return (
<div className={clsx(styles.root, className)}>
{actionButton}
{discoveredActions}
</div>
);
}
return (
<div className={clsx(styles.root, className)} ref={ref}>
{actionButton}
{discoveredActions}
</div>
);
}
);
Loading

0 comments on commit 5c61041

Please sign in to comment.