Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 74 additions & 4 deletions src/components/Dialog/__tests__/DialogManagerContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
useDialogManager,
} from '../../../context/DialogManagerContext';

import { useDialogIsOpen, useOpenedDialogCount } from '../hooks';
import { useDialogIsOpen, useDialogIsTopmost, useOpenedDialogCount } from '../hooks';

const TEST_IDS = {
CLOSE_DIALOG: 'close-dialog',
DIALOG_COUNT: 'dialog-count',
DIALOG_OPEN: 'dialog-open',
DIALOG_TOPMOST: 'dialog-topmost',
MANAGER_ID_DISPLAY: 'manager-id-display',
OPEN_DIALOG: 'open-dialog',
TEST_COMPONENT: 'test-component',
Expand All @@ -21,20 +22,34 @@ const SHARED_MANAGER_ID = 'shared-manager';
const MANAGER_1_ID = 'manager-1';
const MANAGER_2_ID = 'manager-2';

const TestComponent = ({ dialogId, dialogManagerId, testId }: any) => {
type TestComponentProps = {
dialogId?: string;
dialogManagerId?: string;
testId?: string;
};

const TestComponent = ({ dialogId, dialogManagerId, testId }: TestComponentProps) => {
const { dialogManager } = useDialogManager({ dialogId, dialogManagerId });
const selectorDialogId = dialogId ?? 'test-dialog';
const openDialogCount = useOpenedDialogCount({ dialogManagerId });
const isOpen = useDialogIsOpen(dialogId, dialogManagerId);
const isOpen = useDialogIsOpen(selectorDialogId, dialogManagerId);
const isTopmost = useDialogIsTopmost(selectorDialogId, dialogManagerId);
return (
<div data-testid={testId ?? TEST_IDS.TEST_COMPONENT}>
<span data-testid={TEST_IDS.MANAGER_ID_DISPLAY}>{dialogManager?.id}</span>
<span data-testid={TEST_IDS.DIALOG_COUNT}>{openDialogCount}</span>
<span data-testid={TEST_IDS.DIALOG_OPEN}>{isOpen ? 'true' : 'false'}</span>
<span data-testid={TEST_IDS.DIALOG_TOPMOST}>{isTopmost ? 'true' : 'false'}</span>
</div>
);
};

const DialogTestComponent = ({ dialogId, managerId }: any) => {
type DialogTestComponentProps = {
dialogId: string;
managerId?: string;
};

const DialogTestComponent = ({ dialogId, managerId }: DialogTestComponentProps) => {
const { dialogManager } = useDialogManager({ dialogManagerId: managerId });

const handleOpenDialog = () => {
Expand Down Expand Up @@ -171,6 +186,61 @@ describe('DialogManagerContext', () => {
).toHaveTextContent('true');
});

it('updates topmost dialog state when the dialog stack changes', () => {
render(
<DialogManagerProvider id={SHARED_MANAGER_ID}>
<DialogTestComponent dialogId='first-dialog' managerId={SHARED_MANAGER_ID} />
<DialogTestComponent dialogId='second-dialog' managerId={SHARED_MANAGER_ID} />
<TestComponent
dialogId='first-dialog'
dialogManagerId={SHARED_MANAGER_ID}
testId='first-component'
/>
<TestComponent
dialogId='second-dialog'
dialogManagerId={SHARED_MANAGER_ID}
testId='second-component'
/>
</DialogManagerProvider>,
);

const firstComponent = screen.getByTestId('first-component');
const secondComponent = screen.getByTestId('second-component');

act(() => {
fireEvent.click(screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[0]);
});

expect(
firstComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
).toHaveTextContent('true');
expect(
secondComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
).toHaveTextContent('false');

act(() => {
fireEvent.click(screen.getAllByTestId(TEST_IDS.OPEN_DIALOG)[1]);
});

expect(
firstComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
).toHaveTextContent('false');
expect(
secondComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
).toHaveTextContent('true');

act(() => {
fireEvent.click(screen.getAllByTestId(TEST_IDS.CLOSE_DIALOG)[1]);
});

expect(
firstComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
).toHaveTextContent('true');
expect(
secondComponent.querySelector(`[data-testid="${TEST_IDS.DIALOG_TOPMOST}"]`),
).toHaveTextContent('false');
});

it('creates different managers for different IDs', () => {
render(
<DialogManagerProvider closeOnClickOutside={false} id={MANAGER_1_ID}>
Expand Down
45 changes: 45 additions & 0 deletions src/components/Dialog/__tests__/DialogsManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,40 @@ describe('DialogManager', () => {
expect(dialogManager.openDialogCount).toBe(1);
});

it('tracks opened dialogs in stack order', () => {
const dialogManager = new DialogManager();

dialogManager.open({ id: 'first-dialog' });
dialogManager.open({ id: 'second-dialog' });
dialogManager.open({ id: 'third-dialog' });

expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([
'first-dialog',
'second-dialog',
'third-dialog',
]);
expect(dialogManager.openDialogCount).toBe(3);

dialogManager.close('third-dialog');

expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([
'first-dialog',
'second-dialog',
]);
expect(dialogManager.openDialogCount).toBe(2);
});

it('keeps only the target dialog in the stack when opening with closeRest', () => {
const dialogManager = new DialogManager();

dialogManager.open({ id: 'first-dialog' });
dialogManager.open({ id: 'second-dialog' });
dialogManager.open({ id: dialogId }, true);

expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([dialogId]);
expect(dialogManager.openDialogCount).toBe(1);
});

it('opens existing dialog', () => {
const dialogManager = new DialogManager();
dialogManager.getOrCreate({ id: dialogId });
Expand Down Expand Up @@ -154,6 +188,17 @@ describe('DialogManager', () => {
expect(Object.keys(dialogManager.state.getLatestValue().dialogsById)).toHaveLength(0);
});

it('removes a dialog from the open stack', () => {
const dialogManager = new DialogManager();

dialogManager.open({ id: 'first-dialog' });
dialogManager.open({ id: dialogId });
dialogManager.remove('first-dialog');

expect(dialogManager.state.getLatestValue().openedDialogIds).toEqual([dialogId]);
expect(dialogManager.openDialogCount).toBe(1);
});

it('handles attempt to remove non-existent dialog', () => {
const dialogManager = new DialogManager();
dialogManager.getOrCreate({ id: dialogId });
Expand Down
27 changes: 19 additions & 8 deletions src/components/Dialog/hooks/useDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ export const useDialogOnNearestManager = ({ id }: Pick<UseDialogParams, 'id'>) =

export const modalDialogId = 'modal-dialog' as const;

export const useModalDialog = () =>
useDialog({ dialogManagerId: modalDialogManagerId, id: modalDialogId });
export const useModalDialog = (id: string = modalDialogId) =>
useDialog({ dialogManagerId: modalDialogManagerId, id });

export const useDialogIsOpen = (id: string, dialogManagerId?: string) => {
const { dialogManager } = useDialogManager({ dialogManagerId });
Expand All @@ -61,14 +61,25 @@ export const useDialogIsOpen = (id: string, dialogManagerId?: string) => {
return useStateStore(dialogManager.state, dialogIsOpenSelector).isOpen;
};

export const useModalDialogIsOpen = () =>
useDialogIsOpen(modalDialogId, modalDialogManagerId);
export const useModalDialogIsOpen = (id: string = modalDialogId) =>
useDialogIsOpen(id, modalDialogManagerId);

export const useDialogIsTopmost = (id: string, dialogManagerId?: string) => {
const { dialogManager } = useDialogManager({ dialogManagerId });
const dialogIsTopmostSelector = useCallback(
({ openedDialogIds }: DialogManagerState) => ({
isTopmost: openedDialogIds[openedDialogIds.length - 1] === id,
}),
[id],
);
return useStateStore(dialogManager.state, dialogIsTopmostSelector).isTopmost;
};

export const useModalDialogIsTopmost = (id: string = modalDialogId) =>
useDialogIsTopmost(id, modalDialogManagerId);

const openedDialogCountSelector = (nextValue: DialogManagerState) => ({
openedDialogCount: Object.values(nextValue.dialogsById).reduce((count, dialog) => {
if (dialog.isOpen) return count + 1;
return count;
}, 0),
openedDialogCount: nextValue.openedDialogIds.length,
});

export const useOpenedDialogCount = ({
Expand Down
16 changes: 9 additions & 7 deletions src/components/Dialog/service/DialogManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type Dialogs = Record<DialogId, Dialog>;

export type DialogManagerState = {
dialogsById: Dialogs;
openedDialogIds: DialogId[];
};

/**
Expand All @@ -53,6 +54,7 @@ export class DialogManager {
id: string;
state = new StateStore<DialogManagerState>({
dialogsById: {},
openedDialogIds: [],
});

constructor({ closeOnClickOutside = true, id }: DialogManagerOptions = {}) {
Expand All @@ -61,13 +63,7 @@ export class DialogManager {
}

get openDialogCount() {
return Object.values(this.state.getLatestValue().dialogsById).reduce(
(count, dialog) => {
if (dialog.isOpen) return count + 1;
return count;
},
0,
);
return this.state.getLatestValue().openedDialogIds.length;
}

get(id: DialogId): Dialog | undefined {
Expand Down Expand Up @@ -131,6 +127,10 @@ export class DialogManager {
this.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: true } },
openedDialogIds: [
...current.openedDialogIds.filter((dialogId) => dialogId !== dialog.id),
dialog.id,
],
}));
}

Expand All @@ -140,6 +140,7 @@ export class DialogManager {
this.state.next((current) => ({
...current,
dialogsById: { ...current.dialogsById, [dialog.id]: { ...dialog, isOpen: false } },
openedDialogIds: current.openedDialogIds.filter((dialogId) => dialogId !== id),
}));
}

Expand Down Expand Up @@ -171,6 +172,7 @@ export class DialogManager {
return {
...current,
dialogsById: newDialogs,
openedDialogIds: current.openedDialogIds.filter((dialogId) => dialogId !== id),
};
});
};
Expand Down
1 change: 1 addition & 0 deletions src/components/Dialog/styling/Alert.scss
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

.str-chat__alert-header__description {
font: var(--str-chat__font-caption-default);
margin: 0;
}
}
}
Expand Down
62 changes: 49 additions & 13 deletions src/components/Message/__tests__/MessageText.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ChatProvider,
ComponentProvider,
DialogManagerProvider,
MessageProvider,
TranslationProvider,
} from '../../../context';
import {
Expand All @@ -23,6 +24,7 @@ import {
mockChannelStateContext,
mockChatContext,
mockComponentContext,
mockMessageContext,
mockTranslationContextValue,
} from '../../../mock-builders';

Expand Down Expand Up @@ -89,6 +91,8 @@ async function renderMessageText({
const channelCapabilities = { 'send-reaction': true, ...channelCapabilitiesOverrides };
const channelConfig = channel['getConfig']();
const customDateTimeParser = vi.fn(() => ({ format: vi.fn() }));
const renderMessageTextDirectly =
'customInnerClass' in customProps || 'customWrapperClass' in customProps;

return render(
<ChatProvider value={mockChatContext({ client })}>
Expand Down Expand Up @@ -119,9 +123,28 @@ async function renderMessageText({
})}
>
<DialogManagerProvider id='message-dialog-manager-provider'>
<Message {...(defaultProps as MessageProps)} {...customProps}>
<MessageText {...(defaultProps as MessageTextProps)} {...customProps} />
</Message>
{renderMessageTextDirectly ? (
<MessageProvider
value={mockMessageContext({
message: defaultProps.message,
onMentionsClickMessage: onMentionsClickMock,
onMentionsHoverMessage: onMentionsHoverMock,
...customProps,
})}
>
<MessageText
{...(defaultProps as MessageTextProps)}
{...customProps}
/>
</MessageProvider>
) : (
<Message {...(defaultProps as MessageProps)} {...customProps}>
<MessageText
{...(defaultProps as MessageTextProps)}
{...customProps}
/>
</Message>
)}
</DialogManagerProvider>
</ComponentProvider>
</TranslationProvider>
Expand Down Expand Up @@ -418,26 +441,39 @@ describe('<MessageText />', () => {
it('should render with a custom wrapper class when one is set', async () => {
const customWrapperClass = 'custom-wrapper';
const message = generateMessage({ text: 'hello world' });
const { container } = await renderMessageText({
customProps: { customWrapperClass, message },
const { getByText } = await renderMessageText({
customProps: {
customWrapperClass,
message,
},
});
expect(container).toMatchSnapshot();

expect(getByText('hello world').closest(`.${customWrapperClass}`)).toHaveClass(
customWrapperClass,
);
});

it('should render with a custom inner class when one is set', async () => {
const customInnerClass = 'custom-inner';
const message = generateMessage({ text: 'hi mate' });
const { container } = await renderMessageText({
customProps: { customInnerClass, message },
const { getByTestId } = await renderMessageText({
customProps: {
customInnerClass,
message,
},
});
expect(container).toMatchSnapshot();

expect(getByTestId(messageTextTestId)).toHaveClass(customInnerClass);
});

it('should render with custom theme identifier in generated css classes when theme is set', async () => {
it('should render with the default wrapper class when no custom wrapper class is set', async () => {
const message = generateMessage({ text: 'whatup?!' });
const { container } = await renderMessageText({
customProps: { message, theme: 'custom' },
const { getByText } = await renderMessageText({
customProps: { message },
});
expect(container).toMatchSnapshot();

expect(getByText('whatup?!').closest('.str-chat__message-text')).toHaveClass(
'str-chat__message-text',
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Loading