diff --git a/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx b/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
index 3db80cf37ce76..42fb618869da1 100644
--- a/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
+++ b/apps/meteor/client/views/room/Header/RoomHeader.spec.tsx
@@ -1,11 +1,14 @@
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 } 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 });
+const mockedRoom = createFakeRoom({ prid: undefined, name: 'general', fname: 'General' });
const appRoot = mockAppRoot()
.withRoom(mockedRoom)
.wrap((children) => {children})
@@ -20,10 +23,37 @@ jest.mock('./ParentRoom', () => ({
default: jest.fn(() =>
ParentRoom
),
}));
-jest.mock('./RoomToolbox', () => ({
- __esModule: true,
- default: jest.fn(() => RoomToolbox
),
-}));
+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 mockActions: RoomToolboxActionConfig[] = [
+ { id: 'thread', icon: 'thread', title: 'Threads' as any, groups: ['channel'] },
+ { id: 'members-list', icon: 'members', title: 'Members' as any, groups: ['channel'] },
+ { id: 'discussions', icon: 'discussion', title: 'Discussions' as any, groups: ['channel'] },
+ { id: 'files', icon: 'clip', title: 'Files' as any, groups: ['channel'] },
+ { id: 'pinned-messages', icon: 'pin', title: 'Pinned Messages' as any, groups: ['channel'] },
+];
+
+const mockLayoutConfig = JSON.stringify({
+ 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', () => {
@@ -62,4 +92,154 @@ describe('RoomHeader', () => {
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,
+ settings = {
+ Accounts_AllowFeaturePreview: true,
+ Room_Toolbox_Layout: mockLayoutConfig,
+ },
+ 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 appRootWithSettings = mockAppRoot()
+ .withSetting('Accounts_AllowFeaturePreview', settings.Accounts_AllowFeaturePreview)
+ .withSetting('Room_Toolbox_Layout', settings.Room_Toolbox_Layout)
+ .withUserPreference('featuresPreview', featuresPreview)
+ .withRoom(room)
+ .wrap((children) => {children})
+ .build();
+
+ return render(
+
+
+
+
+ ,
+ { wrapper: appRootWithSettings },
+ );
+ };
+
+ 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();
+ });
+
+ 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();
+ });
+
+ 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('should fallback to legacy behavior if layout configuration is malformed JSON', () => {
+ renderWithLayout(testRoom, undefined, {
+ Accounts_AllowFeaturePreview: true,
+ Room_Toolbox_Layout: '{ invalid json }',
+ });
+
+ 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();
+ });
+ });
+
+ describe('Accessibility (a11y)', () => {
+ it('should not have any accessibility violations', async () => {
+ const { container } = renderWithLayout(testRoom);
+
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+ });
+ });
+ });
+ });
});
diff --git a/apps/meteor/client/views/room/Header/RoomToolbox/hooks/processRoomActions.ts b/apps/meteor/client/views/room/Header/RoomToolbox/hooks/processRoomActions.ts
index da6dade3a221f..d776820a2cb97 100644
--- a/apps/meteor/client/views/room/Header/RoomToolbox/hooks/processRoomActions.ts
+++ b/apps/meteor/client/views/room/Header/RoomToolbox/hooks/processRoomActions.ts
@@ -24,7 +24,7 @@ export const processRoomActions = (actionsBase: RoomToolboxBaseAction[], config:
const appActions = actionsBase.filter((a) => a.type === 'apps');
const nonAppActions = actionsBase.filter((a) => a.type !== 'apps');
- if (!config?.items || config.items.length === 0) {
+ if (!config) {
const hiddenActions: RoomToolboxHiddenSection[] = [];
if (appActions.length > 0) {
hiddenActions.push({ id: 'apps', items: appActions });
@@ -36,7 +36,7 @@ export const processRoomActions = (actionsBase: RoomToolboxBaseAction[], config:
};
}
- const itemMap = new Map(config.items.map((item) => [item.id, item]));
+ const itemMap = new Map((config.items || []).map((item) => [item.id, item]));
const [featuredWithOrder, normalWithOrder] = nonAppActions.reduce<
[{ action: RoomToolboxBaseAction; order: number }[], { action: RoomToolboxBaseAction; order: number }[]]
diff --git a/apps/meteor/client/views/room/Header/RoomToolbox/hooks/useRoomToolboxActions.ts b/apps/meteor/client/views/room/Header/RoomToolbox/hooks/useRoomToolboxActions.ts
index af9ef68bf1b60..61394dfd54574 100644
--- a/apps/meteor/client/views/room/Header/RoomToolbox/hooks/useRoomToolboxActions.ts
+++ b/apps/meteor/client/views/room/Header/RoomToolbox/hooks/useRoomToolboxActions.ts
@@ -87,7 +87,10 @@ export const useRoomToolboxActions = ({ actions, openTab }: Pick section.items as RoomToolboxActionConfig[])];
+ const orderedOverflowActions = [
+ ...typedVisible,
+ ...engineSections.flatMap((section) => section.items as RoomToolboxActionConfig[]),
+ ].filter((item) => !item.disabled);
const sectionsMap = new Map();
for (const item of orderedOverflowActions) {
@@ -109,11 +112,15 @@ export const useRoomToolboxActions = ({ actions, openTab }: Pick ({
- id: section.id,
- title: section.id === 'apps' ? t('Apps') : '',
- items: (section.items as RoomToolboxActionConfig[]).map((item) => actionToMenuItem(item, openTab, t)),
- }));
+ const hiddenActions = engineSections
+ .map((section) => ({
+ id: section.id,
+ title: section.id === 'apps' ? t('Apps') : '',
+ items: (section.items as RoomToolboxActionConfig[])
+ .filter((item) => !item.disabled)
+ .map((item) => actionToMenuItem(item, openTab, t)),
+ }))
+ .filter((section) => section.items.length > 0);
return { featuredActions: typedFeatured, visibleActions: typedVisible, hiddenActions };
}