import { mockAppRoot } from '@rocket.chat/mock-providers';
import { LayoutContext, RoomToolboxContext } from '@rocket.chat/ui-contexts';
import type { RoomToolboxActionConfig, LayoutContextValue } from '@rocket.chat/ui-contexts';
import { render, screen, within } from '@testing-library/react';
import { axe } from 'jest-axe';
import RoomHeader from './RoomHeader';
import FakeRoomProvider from '../../../../tests/mocks/client/FakeRoomProvider';
import { createFakeRoom } from '../../../../tests/mocks/data';
const mockedRoom = createFakeRoom({ prid: undefined, name: 'general', fname: 'General' });
const appRoot = mockAppRoot()
.withRoom(mockedRoom)
.wrap((children) => {children})
.build();
jest.mock('../../../../client/lib/getURL', () => ({
getURL: (url: string) => url,
}));
jest.mock('./ParentRoom', () => ({
__esModule: true,
default: jest.fn(() =>
ParentRoom
),
}));
const mockUseRealRoomToolbox = { value: false };
jest.mock('./RoomToolbox', () => {
const ActualRoomToolbox = jest.requireActual('./RoomToolbox/RoomToolbox').default;
return {
__esModule: true,
default: jest.fn((props) => {
if (mockUseRealRoomToolbox.value) {
return ;
}
return RoomToolbox
;
}),
};
});
const MockTabComponent = () => tab
;
const mockActions: RoomToolboxActionConfig[] = [
{ id: 'thread', icon: 'thread', title: 'Threads' as any, groups: ['channel'], tabComponent: MockTabComponent },
{ id: 'members-list', icon: 'members', title: 'Members' as any, groups: ['channel'], tabComponent: MockTabComponent },
{ id: 'discussions', icon: 'discussion', title: 'Discussions' as any, groups: ['channel'], tabComponent: MockTabComponent },
{ id: 'files', icon: 'clip', title: 'Files' as any, groups: ['channel'], tabComponent: MockTabComponent },
{ id: 'pinned-messages', icon: 'pin', title: 'Pinned Messages' as any, groups: ['channel'], tabComponent: MockTabComponent },
];
const layoutSetting = (...scopes: object[]) => JSON.stringify(scopes);
const mockLayoutConfig = {
maxVisibleNormal: 2,
items: [
{ id: 'thread', featured: true, order: 1 },
{ id: 'members-list', featured: false, order: 2 },
{ id: 'discussions', featured: false, order: 3 },
],
};
describe('RoomHeader', () => {
describe('Toolbox', () => {
it('should render toolbox by default', async () => {
render(, { wrapper: appRoot });
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
});
it('should not render toolbox if roomToolbox is null and no slots are provided', () => {
render(
,
{ wrapper: appRoot },
);
expect(screen.queryByLabelText('Toolbox_room_actions')).not.toBeInTheDocument();
});
it('should render toolbox if slots.toolbox is provided', () => {
render(, { wrapper: appRoot });
expect(screen.getByLabelText('Toolbox_room_actions')).toBeInTheDocument();
});
it('should render custom toolbox content from roomToolbox prop', () => {
render(Custom Toolbox } }} />, { wrapper: appRoot });
expect(screen.getByText('Custom Toolbox')).toBeInTheDocument();
});
it('should render custom toolbox content from slots.toolbox.content', () => {
render(Slotted Toolbox } }} />, { wrapper: appRoot });
expect(screen.getByText('Slotted Toolbox')).toBeInTheDocument();
});
});
describe('Room Toolbox Layout Engine', () => {
beforeAll(() => {
mockUseRealRoomToolbox.value = true;
});
afterAll(() => {
mockUseRealRoomToolbox.value = false;
});
const renderWithLayout = (
room = mockedRoom,
layoutContextValue?: Partial,
extraSettings?: Record,
featuresPreview = [{ name: 'roomToolboxLayout', value: true }],
) => {
const mockLayoutContextValue: LayoutContextValue = {
isEmbedded: false,
showTopNavbarEmbeddedLayout: false,
isTablet: false,
isMobile: false,
roomToolboxExpanded: true,
navbar: { searchExpanded: false },
sidebar: {
overlayed: false,
setOverlayed: () => undefined,
isCollapsed: false,
shouldToggle: false,
toggle: () => undefined,
collapse: () => undefined,
expand: () => undefined,
close: () => undefined,
},
sidePanel: {
displaySidePanel: true,
closeSidePanel: () => undefined,
openSidePanel: () => undefined,
},
size: { sidebar: '240px', contextualBar: '380px' },
contextualBarPosition: 'relative',
contextualBarExpanded: false,
hiddenActions: {
roomToolbox: [],
messageToolbox: [],
composerToolbox: [],
userToolbox: [],
},
...layoutContextValue,
};
const mockToolboxValue = {
actions: mockActions,
openTab: () => undefined,
closeTab: () => undefined,
};
const allowFeaturePreview = extraSettings?.Accounts_AllowFeaturePreview ?? true;
const layoutValue = extraSettings?.Room_Toolbox_Layout ?? layoutSetting({ roomType: [room.t], ...mockLayoutConfig });
const appRootWithSettings = mockAppRoot()
.withSetting('Accounts_AllowFeaturePreview', allowFeaturePreview as boolean)
.withSetting('Room_Toolbox_Layout', layoutValue as string)
.withUserPreference('featuresPreview', featuresPreview)
.withRoom(room)
.wrap((children) => {children})
.build();
return render(
,
{ wrapper: appRootWithSettings },
);
};
const getToolbarSequence = () => {
const toolbar = within(screen.getByRole('toolbar', { name: 'Toolbox_room_actions' }));
return [...toolbar.getAllByRole('button'), ...toolbar.queryAllByRole('separator')]
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1))
.map((element) => element.getAttribute('title') ?? 'divider');
};
const roomScenarios = [
{ type: 'c', name: 'public-channel', title: 'Public Channel' },
{ type: 'p', name: 'private-group', title: 'Private Group' },
{ type: 'd', name: 'direct-message', title: 'Direct Message' },
] as const;
roomScenarios.forEach(({ type, name, title }) => {
describe(`Room Type: ${title}`, () => {
const testRoom = createFakeRoom({
prid: undefined,
t: type,
name,
fname: name,
});
it('should respect custom featured and visible actions layout and send remaining normal actions to options dropdown', () => {
renderWithLayout(testRoom);
expect(screen.getByTitle('Threads')).toBeInTheDocument();
expect(screen.getByTitle('Members')).toBeInTheDocument();
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
expect(screen.queryByTitle('Files')).not.toBeInTheDocument();
expect(screen.getByTitle('Options')).toBeInTheDocument();
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Members', 'Discussions', 'Options']);
});
it('should order featured and visible actions by their configured order instead of their declaration order', () => {
renderWithLayout(testRoom, undefined, {
Room_Toolbox_Layout: layoutSetting({
roomType: [type],
maxVisibleNormal: 2,
items: [
{ id: 'thread', featured: true, order: 2 },
{ id: 'pinned-messages', featured: true, order: 1 },
{ id: 'members-list', featured: false, order: 3 },
{ id: 'discussions', featured: false, order: 2 },
{ id: 'files', featured: false, order: 1 },
],
}),
});
expect(getToolbarSequence()).toEqual(['Pinned Messages', 'Threads', 'divider', 'Files', 'Discussions', 'Options']);
expect(screen.queryByTitle('Members')).not.toBeInTheDocument();
});
it('should collapse normal actions into kebab menu when roomToolboxExpanded is false (mobile viewport)', () => {
renderWithLayout(testRoom, { roomToolboxExpanded: false });
expect(screen.getByTitle('Threads')).toBeInTheDocument();
expect(screen.queryByTitle('Members')).not.toBeInTheDocument();
expect(screen.queryByTitle('Discussions')).not.toBeInTheDocument();
expect(screen.queryByTitle('Files')).not.toBeInTheDocument();
expect(screen.getByTitle('Options')).toBeInTheDocument();
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Options']);
});
describe('Soft Fallbacks', () => {
it('should fallback to legacy behavior if feature preview is disabled', () => {
renderWithLayout(testRoom, undefined, undefined, [{ name: 'roomToolboxLayout', value: false }]);
expect(screen.getByTitle('Threads')).toBeInTheDocument();
expect(screen.getByTitle('Members')).toBeInTheDocument();
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
expect(screen.getByTitle('Files')).toBeInTheDocument();
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
});
it.each([
['malformed JSON', '{ invalid json }'],
['a bare scope object instead of an array', JSON.stringify({ roomType: [type], ...mockLayoutConfig })],
['the legacy layouts wrapper', JSON.stringify({ layouts: [{ roomType: [type], ...mockLayoutConfig }] })],
['scoped to an unsupported room type', layoutSetting({ roomType: ['l'], ...mockLayoutConfig })],
[
'claiming the same room type twice',
layoutSetting({ roomType: [type], ...mockLayoutConfig }, { roomType: [type], maxVisibleNormal: 1 }),
],
])('should fallback to legacy behavior if layout configuration is %s', (_, layoutValue) => {
renderWithLayout(testRoom, undefined, { Room_Toolbox_Layout: layoutValue });
expect(screen.getByTitle('Threads')).toBeInTheDocument();
expect(screen.getByTitle('Members')).toBeInTheDocument();
expect(screen.getByTitle('Discussions')).toBeInTheDocument();
expect(screen.getByTitle('Files')).toBeInTheDocument();
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
});
it('should fallback to legacy behavior if no scope declares this room type', () => {
const otherType = type === 'd' ? 'c' : 'd';
renderWithLayout(testRoom, undefined, {
Room_Toolbox_Layout: layoutSetting({ roomType: [otherType], ...mockLayoutConfig }),
});
expect(screen.getByTitle('Files')).toBeInTheDocument();
expect(screen.getByTitle('Pinned Messages')).toBeInTheDocument();
expect(screen.queryByTitle('Options')).not.toBeInTheDocument();
});
});
describe('Accessibility (a11y)', () => {
it('should not have any accessibility violations', async () => {
const { container } = renderWithLayout(testRoom);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
});
});
describe('a single setting serving every room type', () => {
const sharedSetting = layoutSetting(
{
roomType: ['c', 'p'],
maxVisibleNormal: 2,
items: [
{ id: 'thread', featured: true, order: 1 },
{ id: 'members-list', featured: false, order: 2 },
{ id: 'discussions', featured: false, order: 3 },
],
},
{
roomType: ['d'],
maxVisibleNormal: 1,
items: [{ id: 'files', featured: true, order: 1 }],
},
);
it.each(['c', 'p'] as const)('should apply the channel scope to room type %s', (type) => {
const room = createFakeRoom({ prid: undefined, t: type, name: type, fname: type });
renderWithLayout(room, undefined, { Room_Toolbox_Layout: sharedSetting });
expect(getToolbarSequence()).toEqual(['Threads', 'divider', 'Members', 'Discussions', 'Options']);
});
it('should apply the direct message scope from the same setting', () => {
const room = createFakeRoom({ prid: undefined, t: 'd', name: 'dm', fname: 'dm' });
renderWithLayout(room, undefined, { Room_Toolbox_Layout: sharedSetting });
expect(getToolbarSequence()).toEqual(['Files', 'divider', 'Threads', 'Options']);
});
});
});
});