Skip to content

Commit 0268652

Browse files
authored
feat: add support for global modal rendering at the top of the chat component tree (#2792)
1 parent 48673b6 commit 0268652

25 files changed

+741
-217
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
"@playwright/test": "^1.42.1",
184184
"@semantic-release/changelog": "^6.0.3",
185185
"@semantic-release/git": "^10.0.1",
186-
"@stream-io/stream-chat-css": "^5.11.2",
186+
"@stream-io/stream-chat-css": "^5.12.0",
187187
"@testing-library/dom": "^10.4.0",
188188
"@testing-library/jest-dom": "^6.6.3",
189189
"@testing-library/react": "^16.2.0",

src/components/Channel/Channel.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ type ChannelPropsForwardedToComponentContext = Pick<
128128
| 'MessageStatus'
129129
| 'MessageSystem'
130130
| 'MessageTimestamp'
131+
| 'Modal'
131132
| 'ModalGallery'
132133
| 'PinIndicator'
133134
| 'PollActions'
@@ -1227,6 +1228,7 @@ const ChannelInner = (
12271228
MessageStatus: props.MessageStatus,
12281229
MessageSystem: props.MessageSystem,
12291230
MessageTimestamp: props.MessageTimestamp,
1231+
Modal: props.Modal,
12301232
ModalGallery: props.ModalGallery,
12311233
PinIndicator: props.PinIndicator,
12321234
PollActions: props.PollActions,
@@ -1294,6 +1296,7 @@ const ChannelInner = (
12941296
props.MessageStatus,
12951297
props.MessageSystem,
12961298
props.MessageTimestamp,
1299+
props.Modal,
12971300
props.ModalGallery,
12981301
props.PinIndicator,
12991302
props.PollActions,

src/components/Chat/Chat.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { useChannelsQueryState } from './hooks/useChannelsQueryState';
1414
import { ChatProvider } from '../../context/ChatContext';
1515
import { TranslationProvider } from '../../context/TranslationContext';
1616
import type { CustomClasses } from '../../context/ChatContext';
17-
import type { MessageContextValue } from '../../context';
17+
import { type MessageContextValue, ModalDialogManagerProvider } from '../../context';
1818
import type { SupportedTranslations } from '../../i18n/types';
1919
import type { Streami18n } from '../../i18n/Streami18n';
2020

@@ -110,7 +110,9 @@ export const Chat = (props: PropsWithChildren<ChatProps>) => {
110110

111111
return (
112112
<ChatProvider value={chatContextValue}>
113-
<TranslationProvider value={translators}>{children}</TranslationProvider>
113+
<TranslationProvider value={translators}>
114+
<ModalDialogManagerProvider>{children}</ModalDialogManagerProvider>
115+
</TranslationProvider>
114116
</ChatProvider>
115117
);
116118
};

src/components/Dialog/DialogManager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { nanoid } from 'nanoid';
22
import { StateStore } from 'stream-chat';
33

4-
export type GetOrCreateDialogParams = {
4+
export type GetDialogParams = {
55
id: DialogId;
66
};
7+
export type GetOrCreateDialogParams = GetDialogParams;
78

89
type DialogId = string;
910

@@ -57,6 +58,10 @@ export class DialogManager {
5758
);
5859
}
5960

61+
get(id: DialogId) {
62+
return this.state.getLatestValue().dialogsById[id];
63+
}
64+
6065
getOrCreate({ id }: GetOrCreateDialogParams) {
6166
let dialog = this.state.getLatestValue().dialogsById[id];
6267
if (!dialog) {

src/components/Dialog/DialogPortal.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const DialogPortalDestination = () => {
88
const { dialogManager } = useDialogManager();
99
const openedDialogCount = useOpenedDialogCount();
1010

11+
if (!openedDialogCount) return null;
12+
1113
return (
1214
<div
1315
className='str-chat__dialog-overlay'
@@ -31,8 +33,8 @@ export const DialogPortalEntry = ({
3133
children,
3234
dialogId,
3335
}: PropsWithChildren<DialogPortalEntryProps>) => {
34-
const { dialogManager } = useDialogManager();
35-
const dialogIsOpen = useDialogIsOpen(dialogId);
36+
const { dialogManager } = useDialogManager({ dialogId });
37+
const dialogIsOpen = useDialogIsOpen(dialogId, dialogManager.id);
3638

3739
const getPortalDestination = useCallback(
3840
() => document.querySelector(`div[data-str-chat__portal-id="${dialogManager.id}"]`),
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import React from 'react';
2+
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import {
4+
DialogManagerProvider,
5+
useDialogManager,
6+
} from '../../../context/DialogManagerContext';
7+
8+
import '@testing-library/jest-dom';
9+
import { useDialogIsOpen, useOpenedDialogCount } from '../hooks';
10+
11+
const TEST_IDS = {
12+
CLOSE_DIALOG: 'close-dialog',
13+
DIALOG_COUNT: 'dialog-count',
14+
DIALOG_OPEN: 'dialog-open',
15+
MANAGER_ID_DISPLAY: 'manager-id-display',
16+
OPEN_DIALOG: 'open-dialog',
17+
TEST_COMPONENT: 'test-component',
18+
};
19+
20+
const TEST_MANAGER_ID = 'test-manager';
21+
const SHARED_MANAGER_ID = 'shared-manager';
22+
const MANAGER_1_ID = 'manager-1';
23+
const MANAGER_2_ID = 'manager-2';
24+
25+
const TestComponent = ({ dialogId, dialogManagerId, testId }) => {
26+
const { dialogManager } = useDialogManager({ dialogId, dialogManagerId });
27+
const openDialogCount = useOpenedDialogCount({ dialogManagerId });
28+
const isOpen = useDialogIsOpen(dialogId, dialogManagerId);
29+
return (
30+
<div data-testid={testId ?? TEST_IDS.TEST_COMPONENT}>
31+
<span data-testid={TEST_IDS.MANAGER_ID_DISPLAY}>{dialogManager?.id}</span>
32+
<span data-testid={TEST_IDS.DIALOG_COUNT}>{openDialogCount}</span>
33+
<span data-testid={TEST_IDS.DIALOG_OPEN}>{isOpen ? 'true' : 'false'}</span>
34+
</div>
35+
);
36+
};
37+
38+
const DialogTestComponent = ({ dialogId, managerId }) => {
39+
const { dialogManager } = useDialogManager({ dialogManagerId: managerId });
40+
41+
const handleOpenDialog = () => {
42+
if (dialogManager) {
43+
dialogManager.open({ id: dialogId });
44+
}
45+
};
46+
47+
const handleCloseDialog = () => {
48+
if (dialogManager) {
49+
dialogManager.close(dialogId);
50+
}
51+
};
52+
53+
return (
54+
<div>
55+
<button data-testid={TEST_IDS.OPEN_DIALOG} onClick={handleOpenDialog}>
56+
Open
57+
</button>
58+
<button data-testid={TEST_IDS.CLOSE_DIALOG} onClick={handleCloseDialog}>
59+
Close
60+
</button>
61+
</div>
62+
);
63+
};
64+
65+
describe('DialogManagerContext', () => {
66+
describe('DialogManagerProvider', () => {
67+
it('creates a new dialog manager when no id is provided with randomly generated id', () => {
68+
render(
69+
<DialogManagerProvider>
70+
<TestComponent />
71+
</DialogManagerProvider>,
72+
);
73+
74+
expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0');
75+
expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent).toEqual(
76+
expect.any(String),
77+
);
78+
});
79+
80+
it('creates a new dialog manager and adds it to the manager pool when id is provided', () => {
81+
render(
82+
<DialogManagerProvider id={TEST_MANAGER_ID}>
83+
<TestComponent dialogManagerId={TEST_MANAGER_ID} />
84+
</DialogManagerProvider>,
85+
);
86+
87+
expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent).toBe(
88+
TEST_MANAGER_ID,
89+
);
90+
expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0');
91+
});
92+
93+
it('provides dialog manager to non-child components', () => {
94+
render(
95+
<DialogManagerProvider id={MANAGER_1_ID}>
96+
<DialogManagerProvider id={MANAGER_2_ID} />
97+
<TestComponent dialogManagerId={MANAGER_2_ID} />
98+
</DialogManagerProvider>,
99+
);
100+
expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent).toBe(
101+
MANAGER_2_ID,
102+
);
103+
expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT).textContent).toBe('0');
104+
});
105+
106+
it('removes the dialog manager from the pool upon unmount', () => {
107+
const { rerender } = render(
108+
<DialogManagerProvider id={TEST_MANAGER_ID}>
109+
<TestComponent dialogManagerId={TEST_MANAGER_ID} />
110+
</DialogManagerProvider>,
111+
);
112+
113+
const managerId = screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent;
114+
expect(managerId).toBe(TEST_MANAGER_ID);
115+
116+
rerender(
117+
<DialogManagerProvider id='different-manager'>
118+
<TestComponent dialogManagerId={TEST_MANAGER_ID} />
119+
</DialogManagerProvider>,
120+
);
121+
122+
expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY)).toHaveTextContent(
123+
'different-manager',
124+
);
125+
expect(screen.getByTestId(TEST_IDS.DIALOG_COUNT)).toHaveTextContent('0');
126+
});
127+
128+
it('retrieves the existing dialog manager and does not create a new dialog manager', () => {
129+
const dialogId = 'shared-dialog';
130+
render(
131+
<DialogManagerProvider id={SHARED_MANAGER_ID}>
132+
<TestComponent
133+
dialogId={dialogId}
134+
dialogManagerId={SHARED_MANAGER_ID}
135+
testId={'component-1'}
136+
/>
137+
<DialogManagerProvider id={SHARED_MANAGER_ID}>
138+
<DialogTestComponent dialogId={dialogId} managerId={SHARED_MANAGER_ID} />
139+
<TestComponent
140+
dialogId={dialogId}
141+
dialogManagerId={SHARED_MANAGER_ID}
142+
testId={'component-2'}
143+
/>
144+
</DialogManagerProvider>
145+
</DialogManagerProvider>,
146+
);
147+
148+
const component1 = screen.getByTestId('component-1');
149+
const component2 = screen.getByTestId('component-2');
150+
151+
expect(
152+
component1.querySelector(`[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"`),
153+
).toHaveTextContent(SHARED_MANAGER_ID);
154+
expect(
155+
component2.querySelector(`[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"`),
156+
).toHaveTextContent(SHARED_MANAGER_ID);
157+
158+
act(() => {
159+
fireEvent.click(screen.getByTestId(TEST_IDS.OPEN_DIALOG));
160+
});
161+
expect(
162+
component1.querySelector(`[data-testid="${TEST_IDS.DIALOG_COUNT}"`),
163+
).toHaveTextContent('1');
164+
expect(
165+
component2.querySelector(`[data-testid="${TEST_IDS.DIALOG_COUNT}"`),
166+
).toHaveTextContent('1');
167+
expect(
168+
component1.querySelector(`[data-testid="${TEST_IDS.DIALOG_OPEN}"`),
169+
).toHaveTextContent('true');
170+
expect(
171+
component2.querySelector(`[data-testid="${TEST_IDS.DIALOG_OPEN}"`),
172+
).toHaveTextContent('true');
173+
});
174+
175+
it('creates different managers for different IDs', () => {
176+
render(
177+
<DialogManagerProvider id={MANAGER_1_ID}>
178+
<DialogManagerProvider id={MANAGER_2_ID}>
179+
<DialogTestComponent dialogId='dialog-1' managerId={MANAGER_1_ID} />
180+
<DialogTestComponent dialogId='dialog-2' managerId={MANAGER_2_ID} />
181+
<TestComponent dialogManagerId={MANAGER_1_ID} />
182+
<TestComponent dialogManagerId={MANAGER_2_ID} />
183+
</DialogManagerProvider>
184+
</DialogManagerProvider>,
185+
);
186+
187+
const testComponents = screen.getAllByTestId(TEST_IDS.TEST_COMPONENT);
188+
expect(testComponents).toHaveLength(2);
189+
190+
const manager1Id = testComponents[0].querySelector(
191+
`[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"]`,
192+
).textContent;
193+
const manager2Id = testComponents[1].querySelector(
194+
`[data-testid="${TEST_IDS.MANAGER_ID_DISPLAY}"]`,
195+
).textContent;
196+
197+
expect(manager1Id).toBe(MANAGER_1_ID);
198+
expect(manager2Id).toBe(MANAGER_2_ID);
199+
200+
act(() => {
201+
screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[0].click();
202+
});
203+
204+
const manager1Count = testComponents[0].querySelector(
205+
`[data-testid="${TEST_IDS.DIALOG_COUNT}"]`,
206+
).textContent;
207+
const manager2Count = testComponents[1].querySelector(
208+
`[data-testid="${TEST_IDS.DIALOG_COUNT}"]`,
209+
).textContent;
210+
211+
expect(manager1Count).toBe('1');
212+
expect(manager2Count).toBe('0');
213+
214+
act(() => {
215+
screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[1].click();
216+
});
217+
218+
const manager1CountAfter = testComponents[0].querySelector(
219+
`[data-testid="${TEST_IDS.DIALOG_COUNT}"]`,
220+
).textContent;
221+
const manager2CountAfter = testComponents[1].querySelector(
222+
`[data-testid="${TEST_IDS.DIALOG_COUNT}"]`,
223+
).textContent;
224+
225+
expect(manager1CountAfter).toBe('1');
226+
expect(manager2CountAfter).toBe('1');
227+
});
228+
229+
it('does not retrieve dialog manager only by dialog id', async () => {
230+
render(
231+
<DialogManagerProvider id={MANAGER_1_ID}>
232+
<DialogTestComponent dialogId='manager-1-dialog' managerId={MANAGER_1_ID} />
233+
<DialogManagerProvider id={MANAGER_2_ID}>
234+
<TestComponent dialogId='manager-1-dialog' />
235+
</DialogManagerProvider>
236+
</DialogManagerProvider>,
237+
);
238+
239+
await act(() => {
240+
fireEvent.click(screen.getByTestId(TEST_IDS.OPEN_DIALOG));
241+
});
242+
243+
await waitFor(async () => {
244+
expect(await screen.findByTestId(TEST_IDS.DIALOG_COUNT)).toHaveTextContent('0');
245+
const managerId = screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY).textContent;
246+
expect(managerId).toBe(MANAGER_2_ID);
247+
});
248+
});
249+
250+
it('uses the manager from the nearest context provider when manager is not found by id', () => {
251+
render(
252+
<DialogManagerProvider id={MANAGER_1_ID}>
253+
<TestComponent dialogManagerId='non-existent' />
254+
</DialogManagerProvider>,
255+
);
256+
257+
expect(screen.getByTestId(TEST_IDS.MANAGER_ID_DISPLAY)).toHaveTextContent(
258+
MANAGER_1_ID,
259+
);
260+
});
261+
});
262+
});

0 commit comments

Comments
 (0)