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 }; }