Skip to content

Commit a8f7aa0

Browse files
just-borisWho-is-PS
authored andcommitted
chore: Introduce updateContent callback for alert flashbar runtime action api (#3954)
1 parent 1a82fa5 commit a8f7aa0

File tree

4 files changed

+163
-19
lines changed

4 files changed

+163
-19
lines changed

src/alert/__tests__/runtime-action.test.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,46 @@ test('allows skipping rendering actions', async () => {
103103
expect(screen.queryByTestId('test-action')).toBeTruthy();
104104
});
105105

106+
test('propagates state changes to update callback', async () => {
107+
const updateContentSpy = jest.fn();
108+
const testAction: ActionConfig = {
109+
...defaultAction,
110+
updateContent: (container, context) => updateContentSpy(context.contentRef.current?.textContent),
111+
};
112+
113+
awsuiPlugins.alert.registerAction(testAction);
114+
const { rerender } = render(<Alert>Initial content</Alert>);
115+
await delay();
116+
expect(updateContentSpy).toHaveBeenCalledTimes(0);
117+
rerender(<Alert>Updated content</Alert>);
118+
expect(updateContentSpy).toHaveBeenCalledTimes(1);
119+
expect(updateContentSpy).toHaveBeenCalledWith('Updated content');
120+
});
121+
122+
test('container reference is permanent between mount/update/unmount', async () => {
123+
let container: HTMLElement | null = null;
124+
const testAction: ActionConfig = {
125+
id: 'test-action',
126+
mountContent: jest.fn(newContainer => (container = newContainer)),
127+
updateContent: jest.fn(newContainer => expect(container).toBe(newContainer)),
128+
unmountContent: jest.fn(newContainer => expect(container).toBe(newContainer)),
129+
};
130+
awsuiPlugins.alert.registerAction(testAction);
131+
const { rerender } = render(<Alert />);
132+
await delay();
133+
expect(testAction.mountContent).toHaveBeenCalledTimes(1);
134+
expect(testAction.updateContent).toHaveBeenCalledTimes(0);
135+
expect(testAction.unmountContent).toHaveBeenCalledTimes(0);
136+
rerender(<Alert />);
137+
expect(testAction.mountContent).toHaveBeenCalledTimes(1);
138+
expect(testAction.updateContent).toHaveBeenCalledTimes(1);
139+
expect(testAction.unmountContent).toHaveBeenCalledTimes(0);
140+
rerender(<></>);
141+
expect(testAction.mountContent).toHaveBeenCalledTimes(1);
142+
expect(testAction.updateContent).toHaveBeenCalledTimes(1);
143+
expect(testAction.unmountContent).toHaveBeenCalledTimes(1);
144+
});
145+
106146
test('cleans up on unmount', async () => {
107147
const testAction: ActionConfig = {
108148
...defaultAction,

src/flashbar/__tests__/runtime-action.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,101 @@ test('propagates flash message context into callback', async () => {
178178
});
179179
});
180180

181+
test('propagates state changes to update callback', async () => {
182+
const updateContentSpy = jest.fn();
183+
const testAction: ActionConfig = {
184+
...defaultAction,
185+
updateContent: (container, context) => updateContentSpy(context.contentRef.current?.textContent),
186+
};
187+
188+
awsuiPlugins.flashbar.registerAction(testAction);
189+
const { rerender } = render(<Flashbar items={[{ ...defaultItem, content: 'Initial content' }]} />);
190+
await delay();
191+
expect(updateContentSpy).toHaveBeenCalledTimes(0);
192+
rerender(<Flashbar items={[{ ...defaultItem, content: 'Updated content' }]} />);
193+
expect(updateContentSpy).toHaveBeenCalledTimes(1);
194+
expect(updateContentSpy).toHaveBeenCalledWith('Updated content');
195+
});
196+
197+
test('container reference is permanent between mount/update/unmount', async () => {
198+
let container: HTMLElement | null = null;
199+
const testAction: ActionConfig = {
200+
id: 'test-action',
201+
mountContent: jest.fn(newContainer => (container = newContainer)),
202+
updateContent: jest.fn(newContainer => expect(container).toBe(newContainer)),
203+
unmountContent: jest.fn(newContainer => expect(container).toBe(newContainer)),
204+
};
205+
awsuiPlugins.flashbar.registerAction(testAction);
206+
const { rerender } = render(<Flashbar items={[{ ...defaultItem }]} />);
207+
await delay();
208+
expect(testAction.mountContent).toHaveBeenCalledTimes(1);
209+
expect(testAction.updateContent).toHaveBeenCalledTimes(0);
210+
expect(testAction.unmountContent).toHaveBeenCalledTimes(0);
211+
rerender(<Flashbar items={[{ ...defaultItem }]} />);
212+
expect(testAction.mountContent).toHaveBeenCalledTimes(1);
213+
expect(testAction.updateContent).toHaveBeenCalledTimes(1);
214+
expect(testAction.unmountContent).toHaveBeenCalledTimes(0);
215+
rerender(<></>);
216+
expect(testAction.mountContent).toHaveBeenCalledTimes(1);
217+
expect(testAction.updateContent).toHaveBeenCalledTimes(1);
218+
expect(testAction.unmountContent).toHaveBeenCalledTimes(1);
219+
});
220+
221+
test('allows conditionally render content when an item changes', async () => {
222+
const unmount = (container: HTMLElement) => (container.innerHTML = '');
223+
const renderIfApplicable = (container: HTMLElement, content: string) => {
224+
if (content.includes('permission')) {
225+
container.innerHTML = '<button data-testid="troubleshooter"></button>';
226+
} else {
227+
unmount(container);
228+
}
229+
};
230+
const testAction: ActionConfig = {
231+
...defaultAction,
232+
mountContent: (container, { contentRef }) => renderIfApplicable(container, contentRef.current!.textContent!),
233+
updateContent: (container, { contentRef }) => renderIfApplicable(container, contentRef.current!.textContent!),
234+
unmountContent: unmount,
235+
};
236+
awsuiPlugins.flashbar.registerAction(testAction);
237+
const { rerender, queryAllByTestId } = render(<Flashbar items={[{ id: '1', content: 'random content' }]} />);
238+
await delay();
239+
expect(queryAllByTestId('troubleshooter')).toHaveLength(0);
240+
241+
// add new notification matching the pattern
242+
rerender(
243+
<Flashbar
244+
items={[
245+
{ id: '1', content: 'random content' },
246+
{ id: '2', content: 'permission issue' },
247+
]}
248+
/>
249+
);
250+
await delay();
251+
expect(queryAllByTestId('troubleshooter')).toHaveLength(1);
252+
253+
// update the existing notification to match the pattern
254+
rerender(
255+
<Flashbar
256+
items={[
257+
{ id: '1', content: 'permission issue dynamic' },
258+
{ id: '2', content: 'permission issue' },
259+
]}
260+
/>
261+
);
262+
expect(queryAllByTestId('troubleshooter')).toHaveLength(2);
263+
264+
// update the existing notification to not match the pattern anymore
265+
rerender(
266+
<Flashbar
267+
items={[
268+
{ id: '1', content: 'resolved issue' },
269+
{ id: '2', content: 'permission issue' },
270+
]}
271+
/>
272+
);
273+
expect(queryAllByTestId('troubleshooter')).toHaveLength(1);
274+
});
275+
181276
test('cleans up on unmount', async () => {
182277
const testAction: ActionConfig = {
183278
...defaultAction,

src/internal/plugins/controllers/action-buttons.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export interface ActionConfig {
1818
id: string;
1919
orderPriority?: number;
2020
mountContent: (container: HTMLElement, context: ActionContext) => void;
21+
updateContent?: (container: HTMLElement, context: ActionContext) => void;
2122
unmountContent: (container: HTMLElement) => void;
2223
}
2324

src/internal/plugins/helpers/use-discovered-action.tsx

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,26 @@ import { ActionButtonsController, ActionConfig, ActionContext } from '../control
77
interface RuntimeActionWrapperProps {
88
context: ActionContext;
99
mountContent: ActionConfig['mountContent'];
10+
updateContent: ActionConfig['updateContent'];
1011
unmountContent: ActionConfig['unmountContent'];
1112
}
1213

13-
function RuntimeActionWrapper({ mountContent, unmountContent, context }: RuntimeActionWrapperProps) {
14+
function RuntimeActionWrapper({ mountContent, updateContent, unmountContent, context }: RuntimeActionWrapperProps) {
1415
const ref = useRef<HTMLDivElement>(null);
16+
const mountedRef = useRef(false);
17+
18+
useEffect(() => {
19+
if (mountedRef.current && ref.current) {
20+
updateContent?.(ref.current, context);
21+
}
22+
});
1523

1624
useEffect(() => {
1725
const container = ref.current!;
1826
mountContent(container, context);
27+
mountedRef.current = true;
1928
return () => {
29+
mountedRef.current = false;
2030
unmountContent(container);
2131
};
2232
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -25,36 +35,34 @@ function RuntimeActionWrapper({ mountContent, unmountContent, context }: Runtime
2535
return <div ref={ref}></div>;
2636
}
2737

28-
function convertRuntimeAction(action: ActionConfig | null, context: ActionContext) {
29-
if (!action) {
30-
return null;
31-
}
32-
return (
33-
<RuntimeActionWrapper
34-
key={action.id + '-' + context.type}
35-
context={context}
36-
mountContent={action.mountContent}
37-
unmountContent={action.unmountContent}
38-
/>
39-
);
40-
}
41-
4238
export function createUseDiscoveredAction(onActionRegistered: ActionButtonsController['onActionRegistered']) {
4339
return function useDiscoveredAction(type: string): {
4440
discoveredActions: React.ReactNode[];
4541
headerRef: React.Ref<HTMLDivElement>;
4642
contentRef: React.Ref<HTMLDivElement>;
4743
} {
48-
const [discoveredActions, setDiscoveredActions] = useState<Array<React.ReactNode>>([]);
44+
const [actionConfigs, setActionConfigs] = useState<Array<ActionConfig>>([]);
4945
const headerRef = useRef<HTMLDivElement>(null);
5046
const contentRef = useRef<HTMLDivElement>(null);
5147

5248
useEffect(() => {
53-
return onActionRegistered(actions => {
54-
setDiscoveredActions(actions.map(action => convertRuntimeAction(action, { type, headerRef, contentRef })));
55-
});
49+
return onActionRegistered(actions => setActionConfigs(actions));
5650
}, [type]);
5751

52+
const discoveredActions = actionConfigs.map(action => (
53+
<RuntimeActionWrapper
54+
key={action.id + '-' + type}
55+
context={{
56+
type,
57+
headerRef,
58+
contentRef,
59+
}}
60+
mountContent={action.mountContent}
61+
updateContent={action.updateContent}
62+
unmountContent={action.unmountContent}
63+
/>
64+
));
65+
5866
return { discoveredActions, headerRef, contentRef };
5967
};
6068
}

0 commit comments

Comments
 (0)