snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericMenu, HeaderToolbarAction, HeaderToolbarDivider } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox, type RenderToolboxItemParams, type RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useRoomToolboxActions } from './hooks/useRoomToolboxActions';
|
||||
|
||||
export type RoomToolboxProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const RoomToolbox = ({ className }: RoomToolboxProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toolbox = useRoomToolbox();
|
||||
const { featuredActions, hiddenActions, visibleActions } = useRoomToolboxActions(toolbox);
|
||||
|
||||
const showKebabMenu = hiddenActions.length > 0;
|
||||
|
||||
const renderDefaultToolboxItem = useStableCallback(
|
||||
({ id, className, icon, title, toolbox: { tab }, action, disabled, tooltip }: RenderToolboxItemParams) => {
|
||||
return (
|
||||
<HeaderToolbarAction
|
||||
key={id}
|
||||
className={className}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
pressed={id === tab?.id}
|
||||
onClick={action}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const mapToToolboxItem = (action: RoomToolboxActionConfig) => {
|
||||
return (action.renderToolboxItem ?? renderDefaultToolboxItem)?.({
|
||||
...action,
|
||||
action: action.action ?? (() => toolbox.openTab(action.id)),
|
||||
className,
|
||||
toolbox,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{featuredActions.map(mapToToolboxItem)}
|
||||
{featuredActions.length > 0 && <HeaderToolbarDivider />}
|
||||
{visibleActions.map(mapToToolboxItem)}
|
||||
{showKebabMenu && <GenericMenu className={className} title={t('Options')} sections={hiddenActions} placement='bottom-end' />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(RoomToolbox);
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Box } from '@rocket.chat/fuselage';
|
||||
import { useStableArray } from '@rocket.chat/fuselage-hooks';
|
||||
import { HeaderToolbarAction } from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox, type RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { roomActionHooksForE2EESetup } from '../../../../ui';
|
||||
|
||||
export type RoomToolboxE2EESetupProps = {
|
||||
className?: ComponentProps<typeof Box>['className'];
|
||||
};
|
||||
|
||||
const RoomToolboxE2EESetup = ({ className }: RoomToolboxE2EESetupProps) => {
|
||||
const { t } = useTranslation();
|
||||
const toolbox = useRoomToolbox();
|
||||
|
||||
const { tab } = toolbox;
|
||||
|
||||
const actions = useStableArray(
|
||||
roomActionHooksForE2EESetup
|
||||
.map((roomActionHook) => roomActionHook())
|
||||
.filter((roomAction): roomAction is RoomToolboxActionConfig => !!roomAction),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{actions.map(({ id, icon, title, action, disabled, tooltip }) => (
|
||||
<HeaderToolbarAction
|
||||
key={id}
|
||||
className={className}
|
||||
id={id}
|
||||
icon={icon}
|
||||
title={t(title)}
|
||||
pressed={id === tab?.id}
|
||||
onClick={action ?? (() => toolbox.openTab(id))}
|
||||
disabled={disabled}
|
||||
tooltip={tooltip}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomToolboxE2EESetup;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
|
||||
const setting = (value: unknown) => JSON.stringify(value);
|
||||
|
||||
describe('resolveLayoutForRoomType', () => {
|
||||
it.each([
|
||||
['an empty string', ''],
|
||||
['invalid JSON', '{ invalid json }'],
|
||||
['a bare entry object', setting({ roomType: ['c'] })],
|
||||
['the legacy layouts wrapper', setting({ layouts: [{ roomType: ['c'] }] })],
|
||||
['a JSON primitive', '42'],
|
||||
['null', 'null'],
|
||||
])('should return null when the setting is %s', (_, raw) => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
const raw = setting([
|
||||
{ roomType: ['c', 'p'], maxVisibleNormal: 4, items: [{ id: 'thread', featured: true, order: 1 }] },
|
||||
{ roomType: ['d'], maxVisibleNormal: 1, items: [{ id: 'discussions', order: 1 }] },
|
||||
]);
|
||||
|
||||
it.each([
|
||||
['c', 4],
|
||||
['p', 4],
|
||||
['d', 1],
|
||||
] as const)('should resolve the entry declaring room type %s', (roomType, maxVisibleNormal) => {
|
||||
expect(resolveLayoutForRoomType(raw, roomType)).toEqual(
|
||||
expect.objectContaining({
|
||||
maxVisibleNormal,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should strip roomType from the resolved engine config', () => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toEqual({
|
||||
maxVisibleNormal: 4,
|
||||
items: [{ id: 'thread', featured: true, order: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for a room type no entry declares', () => {
|
||||
expect(resolveLayoutForRoomType(setting([{ roomType: ['c'] }]), 'd')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for the omnichannel room type', () => {
|
||||
expect(resolveLayoutForRoomType(raw, 'l')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when the setting itself is not parseable', () => {
|
||||
expect(resolveLayoutForRoomType('{ invalid json }', 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when there are no entries', () => {
|
||||
expect(resolveLayoutForRoomType(setting([]), 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when an entry declares no roomType, as the schema requires one', () => {
|
||||
expect(resolveLayoutForRoomType(setting([{ maxVisibleNormal: 2 }, { roomType: ['c'], maxVisibleNormal: 4 }]), 'c')).toBeNull();
|
||||
});
|
||||
|
||||
describe('when the value did not go through schema validation', () => {
|
||||
it.each([
|
||||
['items is not an array', setting([{ roomType: ['c'], items: {} }])],
|
||||
['items holds a null', setting([{ roomType: ['c'], items: [null] }])],
|
||||
['items holds a primitive', setting([{ roomType: ['c'], items: ['thread'] }])],
|
||||
['an item has no id', setting([{ roomType: ['c'], items: [{ order: 1 }] }])],
|
||||
['an item id is not a string', setting([{ roomType: ['c'], items: [{ id: 1 }] }])],
|
||||
['maxVisibleNormal is a string', setting([{ roomType: ['c'], maxVisibleNormal: '4' }])],
|
||||
['maxVisibleNormal is null', setting([{ roomType: ['c'], maxVisibleNormal: null }])],
|
||||
['the same room type is claimed by two entries', setting([{ roomType: ['c', 'p'] }, { roomType: ['c'] }])],
|
||||
['the same room type is repeated inside one entry', setting([{ roomType: ['c', 'c'] }])],
|
||||
['a roomType is not an array', setting([{ roomType: 'c' }])],
|
||||
['a roomType is empty', setting([{ roomType: [] }])],
|
||||
['a roomType mixes a supported and an unsupported type', setting([{ roomType: ['c', 'l'], maxVisibleNormal: 4 }])],
|
||||
['a roomType holds only an unsupported type', setting([{ roomType: ['l'] }, { roomType: ['c'] }])],
|
||||
['a roomType holds an uppercase type', setting([{ roomType: ['C', 'c'] }])],
|
||||
['a roomType holds a non-string', setting([{ roomType: [1, 'c'] }])],
|
||||
['an entry is null', setting([{ roomType: ['c'] }, null])],
|
||||
['the matching entry itself is null', setting([null, { roomType: ['d'] }])],
|
||||
['an entry is a primitive', setting([{ roomType: ['c'] }, 'nope'])],
|
||||
['an entry is an array', setting([{ roomType: ['c'] }, []])],
|
||||
['an item featured is not a boolean', setting([{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }])],
|
||||
['an item order is not a number', setting([{ roomType: ['c'], items: [{ id: 'thread', order: 'first' }] }])],
|
||||
['an item order is an object', setting([{ roomType: ['c'], items: [{ id: 'thread', order: {} }] }])],
|
||||
['an item order is null', setting([{ roomType: ['c'], items: [{ id: 'thread', order: null }] }])],
|
||||
['an item order parses to Infinity', '[{"roomType":["c"],"items":[{"id":"thread","order":1e999}]}]'],
|
||||
['the setting is an empty list', setting([])],
|
||||
['an entry carries an unknown key', setting([{ roomType: ['c'], somethingElse: true }])],
|
||||
['an item carries an unknown key', setting([{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }])],
|
||||
['an item id is empty', setting([{ roomType: ['c'], items: [{ id: '' }] }])],
|
||||
['an item order is fractional', setting([{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }])],
|
||||
['maxVisibleNormal is negative', setting([{ roomType: ['c'], maxVisibleNormal: -1 }])],
|
||||
['maxVisibleNormal is fractional', setting([{ roomType: ['c'], maxVisibleNormal: 1.5 }])],
|
||||
])('should return null so the toolbox keeps its default layout when %s', (_, raw) => {
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
|
||||
it('should discard the whole setting when a later entry overlaps, even if the matching one is fine', () => {
|
||||
const raw = setting([{ roomType: ['c'], maxVisibleNormal: 4 }, { roomType: ['d'] }, { roomType: ['d'] }]);
|
||||
|
||||
expect(resolveLayoutForRoomType(raw, 'c')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { RoomType } from '@rocket.chat/core-typings';
|
||||
import { isRecord } from '@rocket.chat/tools';
|
||||
|
||||
import type { RoomToolboxLayoutConfig } from './processRoomActions';
|
||||
|
||||
const ROOM_TOOLBOX_LAYOUT_ROOM_TYPES = ['c', 'p', 'd'] as const;
|
||||
|
||||
type RoomToolboxLayoutRoomType = (typeof ROOM_TOOLBOX_LAYOUT_ROOM_TYPES)[number];
|
||||
|
||||
type RoomToolboxLayoutScope = RoomToolboxLayoutConfig & {
|
||||
roomType: RoomToolboxLayoutRoomType[];
|
||||
};
|
||||
|
||||
export type RoomToolboxLayoutSetting = RoomToolboxLayoutScope[];
|
||||
|
||||
const SUPPORTED_ROOM_TYPES: ReadonlySet<unknown> = new Set(ROOM_TOOLBOX_LAYOUT_ROOM_TYPES);
|
||||
|
||||
const ENTRY_KEYS = ['roomType', 'maxVisibleNormal', 'items'];
|
||||
|
||||
const ITEM_KEYS = ['id', 'featured', 'order'];
|
||||
|
||||
// mirrors apps/meteor/lib/roomToolboxLayout/room-toolbox-layout.schema.json, which rejects the same
|
||||
// values when the setting is saved; both have to change together
|
||||
const hasOnlyKeys = (value: Record<string, unknown>, allowed: string[]): boolean =>
|
||||
Object.keys(value).every((key) => allowed.includes(key));
|
||||
|
||||
const isCount = (value: unknown): boolean => Number.isInteger(value) && (value as number) >= 0;
|
||||
|
||||
const isLayoutItem = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ITEM_KEYS) &&
|
||||
typeof value.id === 'string' &&
|
||||
value.id.length > 0 &&
|
||||
(value.featured === undefined || typeof value.featured === 'boolean') &&
|
||||
(value.order === undefined || Number.isInteger(value.order));
|
||||
|
||||
const isRoomTypeList = (value: unknown): boolean =>
|
||||
Array.isArray(value) && value.length > 0 && new Set(value).size === value.length && value.every((type) => SUPPORTED_ROOM_TYPES.has(type));
|
||||
|
||||
const isLayoutScope = (value: unknown): boolean =>
|
||||
isRecord(value) &&
|
||||
hasOnlyKeys(value, ENTRY_KEYS) &&
|
||||
isRoomTypeList(value.roomType) &&
|
||||
(value.maxVisibleNormal === undefined || isCount(value.maxVisibleNormal)) &&
|
||||
(value.items === undefined || (Array.isArray(value.items) && value.items.every(isLayoutItem)));
|
||||
|
||||
const hasOverlappingRoomTypes = (scopes: RoomToolboxLayoutSetting): boolean => {
|
||||
const claimed = new Set<RoomToolboxLayoutRoomType>();
|
||||
|
||||
for (const { roomType: roomTypes } of scopes) {
|
||||
for (const claimedType of roomTypes) {
|
||||
if (claimed.has(claimedType)) {
|
||||
return true;
|
||||
}
|
||||
claimed.add(claimedType);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const parseLayoutSetting = (raw: string): RoomToolboxLayoutSetting | null => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isLayoutScope)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed as RoomToolboxLayoutSetting;
|
||||
};
|
||||
|
||||
export const resolveLayoutForRoomType = (raw: string, roomType: RoomType): RoomToolboxLayoutConfig | null => {
|
||||
const scopes = parseLayoutSetting(raw);
|
||||
if (!scopes || hasOverlappingRoomTypes(scopes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scope = scopes.find(({ roomType: roomTypes }) => roomTypes.includes(roomType as RoomToolboxLayoutRoomType));
|
||||
if (!scope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { maxVisibleNormal: scope.maxVisibleNormal, items: scope.items };
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import { processRoomActions } from './processRoomActions';
|
||||
import type { RoomToolboxLayoutConfig } from './processRoomActions';
|
||||
|
||||
describe('RoomToolbox Layout Engine (processRoomActions)', () => {
|
||||
it('should isolate featured items into featuredActions', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 10 },
|
||||
{ id: 'start-call', featured: true, order: 5 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
});
|
||||
it('should respect maxVisibleNormal limit and sort items by order', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'clean-history' }, { id: 'team-info' }, { id: 'members-list' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 10 },
|
||||
{ id: 'clean-history', featured: false, order: 250 },
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'members-list', featured: false, order: 7 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['team-info', 'members-list']);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('thread');
|
||||
expect(hiddenIds).toContain('clean-history');
|
||||
});
|
||||
it('should force app actions into hiddenActions grouped by section, ignoring config placement', () => {
|
||||
const actionsBase = [{ id: 'app-star-actions', type: 'apps' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'app-star-actions', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(0);
|
||||
expect(result.hiddenActions).toMatchObject([
|
||||
{
|
||||
id: 'apps',
|
||||
items: [{ id: 'app-star-actions' }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
it('should safely handle new/unknown actions not present in the layout config without dropping them', () => {
|
||||
const actionsBase = [{ id: 'legacy-action' }, { id: 'brand-new-action' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'legacy-action', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
const allRenderedIds = [
|
||||
...result.featuredActions.map((a) => a.id),
|
||||
...result.visibleActions.map((a) => a.id),
|
||||
...result.hiddenActions.flatMap((section) => section.items.map((i) => i.id)),
|
||||
];
|
||||
expect(allRenderedIds).toContain('brand-new-action');
|
||||
});
|
||||
it('should return base actions unchanged when the config has no items', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config = {
|
||||
items: [],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result).toEqual({
|
||||
featuredActions: [],
|
||||
visibleActions: actionsBase,
|
||||
hiddenActions: [],
|
||||
});
|
||||
});
|
||||
it('should return base actions unchanged when items is missing from the config object', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-call' }];
|
||||
const config: RoomToolboxLayoutConfig = {};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result).toEqual({
|
||||
featuredActions: [],
|
||||
visibleActions: actionsBase,
|
||||
hiddenActions: [],
|
||||
});
|
||||
});
|
||||
it('should fall back to default maxVisibleNormal (6) when not specified in config', () => {
|
||||
const actionsBase = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }, { id: '5' }, { id: '6' }, { id: '7' }];
|
||||
const config = {
|
||||
items: [
|
||||
{ id: '1', featured: false, order: 1 },
|
||||
{ id: '2', featured: false, order: 2 },
|
||||
{ id: '3', featured: false, order: 3 },
|
||||
{ id: '4', featured: false, order: 4 },
|
||||
{ id: '5', featured: false, order: 5 },
|
||||
{ id: '6', featured: false, order: 6 },
|
||||
{ id: '7', featured: false, order: 7 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(6);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toEqual(['7']);
|
||||
});
|
||||
it('should force app actions into hiddenActions', () => {
|
||||
const actionsBase = [{ id: 'app-x', type: 'apps' }, { id: 'thread' }];
|
||||
const result = processRoomActions(actionsBase, { maxVisibleNormal: 6 });
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread']);
|
||||
expect(result.hiddenActions).toMatchObject([{ id: 'apps', items: [{ id: 'app-x' }] }]);
|
||||
});
|
||||
it('should keep actions flagged as featured by themselves when the config does not mention them', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-video-call', featured: true }, { id: 'ai-actions', featured: true }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'thread', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['start-video-call', 'ai-actions']);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread']);
|
||||
expect(result.hiddenActions).toHaveLength(0);
|
||||
});
|
||||
it('should let the config demote an action flagged as featured by itself', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'start-video-call', featured: true }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'start-video-call', featured: false, order: 1 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions).toHaveLength(0);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['start-video-call', 'thread']);
|
||||
});
|
||||
it('should keep configured actions ahead of unconfigured ones even when the configured order is very large', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'discussions' }, { id: 'clean-history' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [{ id: 'clean-history', featured: false, order: 10000 }],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['clean-history', 'thread', 'discussions']);
|
||||
});
|
||||
it('should keep unconfigured actions in their incoming order', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'discussions' }, { id: 'clean-history' }, { id: 'mentions' }];
|
||||
const result = processRoomActions(actionsBase, { maxVisibleNormal: 6, items: [] });
|
||||
expect(result.visibleActions.map((a) => a.id)).toEqual(['thread', 'discussions', 'clean-history', 'mentions']);
|
||||
});
|
||||
it('should sort featured items with an omitted order after the ones that declare it', () => {
|
||||
const actionsBase = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'a', featured: true },
|
||||
{ id: 'b', featured: true, order: 3 },
|
||||
{ id: 'c', featured: true, order: 1 },
|
||||
{ id: 'd', featured: true, order: 2 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((x) => x.id)).toEqual(['c', 'd', 'b', 'a']);
|
||||
});
|
||||
it('should sort featuredActions by order when multiple featured items exist', () => {
|
||||
const actionsBase = [{ id: 'start-call' }, { id: 'thread' }, { id: 'search' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 6,
|
||||
items: [
|
||||
{ id: 'start-call', featured: true, order: 20 },
|
||||
{ id: 'thread', featured: true, order: 5 },
|
||||
{ id: 'search', featured: false, order: 10 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
});
|
||||
it('should hide all normal actions when maxVisibleNormal is 0', () => {
|
||||
const actionsBase = [{ id: 'thread' }, { id: 'search' }];
|
||||
const config = {
|
||||
maxVisibleNormal: 0,
|
||||
items: [
|
||||
{ id: 'thread', featured: false, order: 1 },
|
||||
{ id: 'search', featured: false, order: 2 },
|
||||
],
|
||||
};
|
||||
const result = processRoomActions(actionsBase, config);
|
||||
expect(result.visibleActions).toHaveLength(0);
|
||||
const hiddenIds = result.hiddenActions.flatMap((section) => section.items.map((i) => i.id));
|
||||
expect(hiddenIds).toEqual(['thread', 'search']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export type RoomToolboxLayoutItem = {
|
||||
id: string;
|
||||
featured?: boolean;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type RoomToolboxLayoutConfig = {
|
||||
maxVisibleNormal?: number;
|
||||
items?: RoomToolboxLayoutItem[];
|
||||
};
|
||||
|
||||
export type RoomToolboxBaseAction = {
|
||||
id: string;
|
||||
type?: string;
|
||||
featured?: boolean;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type RoomToolboxHiddenSection = {
|
||||
id: string;
|
||||
items: RoomToolboxBaseAction[];
|
||||
};
|
||||
|
||||
const compareByOrder = (a: { order: number }, b: { order: number }): number => (a.order === b.order ? 0 : a.order - b.order);
|
||||
|
||||
const groupActionsByType = (actions: RoomToolboxBaseAction[]): RoomToolboxHiddenSection[] => {
|
||||
const sectionsMap = new Map<string, RoomToolboxHiddenSection>();
|
||||
for (const action of actions) {
|
||||
const group = action.type ?? '';
|
||||
const existing = sectionsMap.get(group);
|
||||
if (existing) {
|
||||
existing.items.push(action);
|
||||
} else {
|
||||
sectionsMap.set(group, { id: group, items: [action] });
|
||||
}
|
||||
}
|
||||
return Array.from(sectionsMap.values());
|
||||
};
|
||||
|
||||
export const processRoomActions = (actionsBase: RoomToolboxBaseAction[], config: RoomToolboxLayoutConfig) => {
|
||||
const appActions = actionsBase.filter((a) => a.type === 'apps');
|
||||
const nonAppActions = actionsBase.filter((a) => a.type !== 'apps');
|
||||
|
||||
const itemMap = new Map((config.items || []).map((item) => [item.id, item]));
|
||||
|
||||
const [featuredWithOrder, normalWithOrder] = nonAppActions.reduce<
|
||||
[{ action: RoomToolboxBaseAction; order: number }[], { action: RoomToolboxBaseAction; order: number }[]]
|
||||
>(
|
||||
(acc, action) => {
|
||||
const configItem = itemMap.get(action.id);
|
||||
const isFeatured = configItem?.featured ?? action.featured ?? false;
|
||||
const order = configItem?.order ?? Number.POSITIVE_INFINITY;
|
||||
acc[isFeatured ? 0 : 1].push({ action, order });
|
||||
return acc;
|
||||
},
|
||||
[[], []],
|
||||
);
|
||||
|
||||
featuredWithOrder.sort(compareByOrder);
|
||||
normalWithOrder.sort(compareByOrder);
|
||||
|
||||
const maxVisible = config.maxVisibleNormal ?? 6;
|
||||
const visibleActions = normalWithOrder.slice(0, maxVisible).map((n) => n.action);
|
||||
const overflowNormalActions = normalWithOrder.slice(maxVisible).map((n) => n.action);
|
||||
|
||||
const hiddenActions: RoomToolboxHiddenSection[] = [];
|
||||
|
||||
if (appActions.length > 0) {
|
||||
hiddenActions.push({ id: 'apps', items: appActions });
|
||||
}
|
||||
|
||||
hiddenActions.push(...groupActionsByType(overflowNormalActions));
|
||||
|
||||
return {
|
||||
featuredActions: featuredWithOrder.map((n) => n.action),
|
||||
visibleActions,
|
||||
hiddenActions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { ajv } from '@rocket.chat/rest-typings';
|
||||
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
import type { RoomToolboxLayoutSetting } from './parseLayoutSetting';
|
||||
import schema from '../../../../../../lib/roomToolboxLayout/room-toolbox-layout.schema.json';
|
||||
|
||||
const validate = ajv.compile(schema);
|
||||
|
||||
const validSetting: RoomToolboxLayoutSetting = [
|
||||
{ roomType: ['c', 'p'], maxVisibleNormal: 3, items: [{ id: 'members-list', featured: true, order: 1 }] },
|
||||
{ roomType: ['d'], maxVisibleNormal: 1 },
|
||||
];
|
||||
|
||||
describe('room toolbox layout JSON schema', () => {
|
||||
it('accepts a document typed as RoomToolboxLayoutSetting', () => {
|
||||
expect(validate(validSetting)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['an entry with only roomType', [{ roomType: ['c'] }]],
|
||||
['an entry with an empty items list', [{ roomType: ['c'], items: [] }]],
|
||||
['an item with only an id', [{ roomType: ['c'], items: [{ id: 'thread' }] }]],
|
||||
['maxVisibleNormal of zero', [{ roomType: ['c'], maxVisibleNormal: 0 }]],
|
||||
['a negative order', [{ roomType: ['c'], items: [{ id: 'thread', order: -1 }] }]],
|
||||
['entries that partition the room types', [{ roomType: ['c', 'p'] }, { roomType: ['d'] }]],
|
||||
])('accepts %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a bare entry object', { roomType: ['c'] }],
|
||||
['the legacy layouts wrapper', { layouts: [{ roomType: ['c'] }] }],
|
||||
['a JSON primitive', 42],
|
||||
['null', null],
|
||||
['an empty list', []],
|
||||
['a null entry', [null]],
|
||||
['an entry that is an array', [[]]],
|
||||
['an unknown entry key', [{ roomType: ['c'], somethingElse: true }]],
|
||||
])('rejects %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
|
||||
describe('roomType', () => {
|
||||
it.each([
|
||||
['roomType is missing', [{ items: [] }]],
|
||||
['roomType is an empty list', [{ roomType: [] }]],
|
||||
['roomType is a bare string', [{ roomType: 'c' }]],
|
||||
['roomType holds the omnichannel type', [{ roomType: ['l'] }]],
|
||||
['roomType holds an unknown type', [{ roomType: ['x'] }]],
|
||||
['roomType mixes a valid and an invalid type', [{ roomType: ['c', 'l'] }]],
|
||||
['roomType holds a non-string', [{ roomType: [1] }]],
|
||||
['roomType holds an uppercase type', [{ roomType: ['C'] }]],
|
||||
['the same room type is repeated inside one entry', [{ roomType: ['c', 'c'] }]],
|
||||
['the same room type is claimed by two entries', [{ roomType: ['c', 'p'] }, { roomType: ['c'] }]],
|
||||
['every room type is claimed twice', [{ roomType: ['c', 'p', 'd'] }, { roomType: ['c', 'p', 'd'] }]],
|
||||
])('rejects when %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('items and maxVisibleNormal', () => {
|
||||
it.each([
|
||||
['items is not an array', [{ roomType: ['c'], items: {} }]],
|
||||
['an item is null', [{ roomType: ['c'], items: [null] }]],
|
||||
['an item has no id', [{ roomType: ['c'], items: [{ order: 1 }] }]],
|
||||
['an item id is empty', [{ roomType: ['c'], items: [{ id: '' }] }]],
|
||||
['an item id is not a string', [{ roomType: ['c'], items: [{ id: {} }] }]],
|
||||
['an item featured is not a boolean', [{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }]],
|
||||
['an item order is fractional', [{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }]],
|
||||
['an item has an unknown key', [{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }]],
|
||||
['maxVisibleNormal is negative', [{ roomType: ['c'], maxVisibleNormal: -1 }]],
|
||||
['maxVisibleNormal is fractional', [{ roomType: ['c'], maxVisibleNormal: 1.5 }]],
|
||||
[
|
||||
'a later entry is invalid even if the first one is fine',
|
||||
[
|
||||
{ roomType: ['c'], items: [{ id: 'thread' }] },
|
||||
{ roomType: ['d'], items: [null] },
|
||||
],
|
||||
],
|
||||
])('rejects when %s', (_name, setting) => {
|
||||
expect(validate(setting)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('client validation parity with the JSON schema', () => {
|
||||
const documents: [string, unknown][] = [
|
||||
['a complete document', validSetting],
|
||||
['an entry with only roomType', [{ roomType: ['c'] }]],
|
||||
['maxVisibleNormal of zero', [{ roomType: ['c'], maxVisibleNormal: 0 }]],
|
||||
['an item with only an id', [{ roomType: ['c'], items: [{ id: 'thread' }] }]],
|
||||
['an empty list', []],
|
||||
['an entry with no roomType', [{ roomType: ['c'] }, { maxVisibleNormal: 1 }]],
|
||||
['an unsupported room type', [{ roomType: ['c', 'l'] }]],
|
||||
['a duplicated room type', [{ roomType: ['c', 'c'] }]],
|
||||
['overlapping entries', [{ roomType: ['c', 'p'] }, { roomType: ['c'] }]],
|
||||
['an unknown entry key', [{ roomType: ['c'], somethingElse: true }]],
|
||||
['an unknown item key', [{ roomType: ['c'], items: [{ id: 'thread', pinned: true }] }]],
|
||||
['an empty item id', [{ roomType: ['c'], items: [{ id: '' }] }]],
|
||||
['a fractional order', [{ roomType: ['c'], items: [{ id: 'thread', order: 1.5 }] }]],
|
||||
['a non-boolean featured', [{ roomType: ['c'], items: [{ id: 'thread', featured: 'yes' }] }]],
|
||||
['a negative maxVisibleNormal', [{ roomType: ['c'], maxVisibleNormal: -1 }]],
|
||||
['a fractional maxVisibleNormal', [{ roomType: ['c'], maxVisibleNormal: 1.5 }]],
|
||||
['a null entry', [{ roomType: ['c'] }, null]],
|
||||
];
|
||||
|
||||
it.each(documents)('agrees on %s', (_name, document) => {
|
||||
const raw = JSON.stringify(document);
|
||||
// ajv coerces in place, so it gets its own copy
|
||||
const acceptedBySchema = validate(JSON.parse(raw));
|
||||
const claimsPublicChannel = (document as { roomType?: string[] }[]).some((entry) => entry?.roomType?.includes('c'));
|
||||
|
||||
expect(resolveLayoutForRoomType(raw, 'c') !== null).toBe(acceptedBySchema && claimsPublicChannel);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,464 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { LayoutContext } from '@rocket.chat/ui-contexts';
|
||||
import type { LayoutContextValue, RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { useRoomToolboxActions } from './useRoomToolboxActions';
|
||||
import FakeRoomProvider from '../../../../../../tests/mocks/client/FakeRoomProvider';
|
||||
|
||||
const MockTabComponent = () => <div>tab</div>;
|
||||
|
||||
describe('useRoomToolboxActions', () => {
|
||||
it('should return an empty array if there are no actions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions: [], openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.featuredActions).toEqual([]);
|
||||
expect(result.current.hiddenActions).toEqual([]);
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return apps actions only inside hiddenActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions: appsActions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
const appsSection = result.current.hiddenActions[0];
|
||||
const appsItems = appsSection.items;
|
||||
|
||||
expect(appsSection).toBeDefined();
|
||||
expect(appsSection).toHaveProperty('id', 'apps');
|
||||
expect(appsItems).toMatchObject(appsActions);
|
||||
});
|
||||
|
||||
it('should return max of 6 items on visibleActions and the rest items inside hiddenActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.hiddenActions.length).toBeGreaterThan(0);
|
||||
expect(result.current.visibleActions.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should return featured items inside featuredActions', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
expect(result.current.featuredActions).toMatchObject(actions.filter((action) => action.featured));
|
||||
});
|
||||
|
||||
describe('with roomToolboxLayout feature preview enabled', () => {
|
||||
const layoutSetting = (...scopes: object[]) => JSON.stringify(scopes);
|
||||
|
||||
const mockLayoutConfig = layoutSetting({
|
||||
roomType: ['c'],
|
||||
maxVisibleNormal: 2,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'thread', featured: true, order: 10 },
|
||||
{ id: 'discussions', featured: false, order: 2 },
|
||||
{ id: 'rocket-search', featured: false, order: 3 },
|
||||
],
|
||||
});
|
||||
|
||||
it('should respect custom featured and visible ordering for public channels', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(['team-info', 'discussions']);
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('rocket-search');
|
||||
});
|
||||
|
||||
describe('with a single setting scoped by room type', () => {
|
||||
const scopedSetting = layoutSetting(
|
||||
{
|
||||
roomType: ['c', 'p'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'thread', featured: true, order: 1 }],
|
||||
},
|
||||
{
|
||||
roomType: ['d'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'discussions', featured: false, order: 1 }],
|
||||
},
|
||||
);
|
||||
|
||||
const renderForRoomType = (roomType: 'c' | 'p' | 'd' | 'l') =>
|
||||
renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', scopedSetting)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: roomType }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
it.each(['c', 'p'] as const)('should apply the scope declaring both channel types to room type %s', (roomType) => {
|
||||
const { result } = renderForRoomType(roomType);
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
});
|
||||
|
||||
it('should apply the direct message scope from the same setting', () => {
|
||||
const { result } = renderForRoomType('d');
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(['discussions']);
|
||||
});
|
||||
|
||||
it('should fall back to legacy behavior for a room type no scope declares', () => {
|
||||
const { result } = renderForRoomType('l');
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep actions flagged as featured by themselves out of the visible row and the kebab menu', () => {
|
||||
const configWithoutStartCall = layoutSetting({
|
||||
roomType: ['c'],
|
||||
maxVisibleNormal: 1,
|
||||
items: [{ id: 'team-info', featured: false, order: 1 }],
|
||||
});
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', configWithoutStartCall)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).not.toContain('start-call');
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).not.toContain('start-call');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['invalid JSON', '{ invalid json }'],
|
||||
['an empty array', '[]'],
|
||||
['a bare scope object instead of an array', JSON.stringify({ roomType: ['c'], maxVisibleNormal: 1, items: [{ id: 'team-info' }] })],
|
||||
['the legacy layouts wrapper', JSON.stringify({ layouts: [{ roomType: ['c'], maxVisibleNormal: 1 }] })],
|
||||
['non-array items', layoutSetting({ roomType: ['c'], items: {} })],
|
||||
['items with invalid item types', layoutSetting({ roomType: ['c'], items: [null] })],
|
||||
['a scope without roomType', layoutSetting({ maxVisibleNormal: 1, items: [] })],
|
||||
['an empty roomType', layoutSetting({ roomType: [], items: [] })],
|
||||
['an unsupported roomType', layoutSetting({ roomType: ['l'], items: [] })],
|
||||
['room types overlapping across scopes', layoutSetting({ roomType: ['c', 'p'] }, { roomType: ['c'] })],
|
||||
['a room type repeated inside one scope', layoutSetting({ roomType: ['c', 'c'] })],
|
||||
])('should fall back to legacy behavior if config is %s', (_, layoutConfigValue) => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', layoutConfigValue)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
|
||||
it('should fall back to legacy behavior if feature preview is disabled', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: false }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => <FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>)
|
||||
.build(),
|
||||
});
|
||||
|
||||
const expectedVisible = actions.filter((action) => !action.featured && action.type !== 'apps').slice(0, 6);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['start-call']);
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toEqual(expectedVisible.map((a) => a.id));
|
||||
});
|
||||
|
||||
const collapsedLayoutContextValue: LayoutContextValue = {
|
||||
isEmbedded: false,
|
||||
showTopNavbarEmbeddedLayout: false,
|
||||
isTablet: false,
|
||||
isMobile: false,
|
||||
roomToolboxExpanded: false,
|
||||
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: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe('when a demoted action can not render as a menu item', () => {
|
||||
// mirrors useAppsRoomStarActions: renders itself, has no `action` and no `tabComponent`
|
||||
const renderOnlyAction = {
|
||||
id: 'ai-actions',
|
||||
title: 'AI_Actions',
|
||||
icon: 'stars',
|
||||
groups: ['channel'],
|
||||
featured: true,
|
||||
order: 3,
|
||||
renderToolboxItem: () => null,
|
||||
} as unknown as RoomToolboxActionConfig;
|
||||
|
||||
const renderWithConfig = (config: object, layoutContextValue?: LayoutContextValue) => {
|
||||
const withLayout = (children: ReactNode) =>
|
||||
layoutContextValue ? <LayoutContext.Provider value={layoutContextValue}>{children}</LayoutContext.Provider> : children;
|
||||
|
||||
return renderHook(() => useRoomToolboxActions({ actions: [...actions, renderOnlyAction], openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', layoutSetting({ roomType: ['c'], ...config }))
|
||||
.wrap((children) => withLayout(<FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>))
|
||||
.build(),
|
||||
});
|
||||
};
|
||||
|
||||
const hiddenIdsOf = (result: { hiddenActions: { items: { id?: string }[] }[] }) =>
|
||||
result.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
|
||||
it('should keep it in the visible row when it fits, honouring the configured order', () => {
|
||||
const { result } = renderWithConfig({
|
||||
maxVisibleNormal: 3,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 2 },
|
||||
{ id: 'ai-actions', featured: false, order: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id).slice(0, 2)).toEqual(['ai-actions', 'team-info']);
|
||||
expect(result.current.featuredActions.map((a) => a.id)).not.toContain('ai-actions');
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
|
||||
it('should pull it back into the visible row when it would overflow into the kebab', () => {
|
||||
const { result } = renderWithConfig({
|
||||
maxVisibleNormal: 1,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'ai-actions', featured: false, order: 2 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.current.visibleActions.map((a) => a.id)).toContain('ai-actions');
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
|
||||
it('should pin it to the featured row when the toolbox is collapsed and the visible row is gone', () => {
|
||||
const { result } = renderWithConfig(
|
||||
{
|
||||
maxVisibleNormal: 1,
|
||||
items: [
|
||||
{ id: 'team-info', featured: false, order: 1 },
|
||||
{ id: 'ai-actions', featured: false, order: 2 },
|
||||
],
|
||||
},
|
||||
collapsedLayoutContextValue,
|
||||
);
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toContain('ai-actions');
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
expect(hiddenIdsOf(result.current)).not.toContain('ai-actions');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle unexpanded layout properly (roomToolboxExpanded = false)', () => {
|
||||
const { result } = renderHook(() => useRoomToolboxActions({ actions, openTab: () => undefined }), {
|
||||
wrapper: mockAppRoot()
|
||||
.withSetting('Accounts_AllowFeaturePreview', true)
|
||||
.withUserPreference('featuresPreview', [{ name: 'roomToolboxLayout', value: true }])
|
||||
.withSetting('Room_Toolbox_Layout', mockLayoutConfig)
|
||||
.wrap((children) => (
|
||||
<LayoutContext.Provider value={collapsedLayoutContextValue}>
|
||||
<FakeRoomProvider roomOverrides={{ t: 'c' }}>{children}</FakeRoomProvider>
|
||||
</LayoutContext.Provider>
|
||||
))
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(result.current.featuredActions.map((a) => a.id)).toEqual(['thread', 'start-call']);
|
||||
|
||||
expect(result.current.visibleActions).toEqual([]);
|
||||
|
||||
const hiddenIds = result.current.hiddenActions.flatMap((s) => s.items.map((i) => i.id));
|
||||
expect(hiddenIds).toContain('team-info');
|
||||
expect(hiddenIds).toContain('discussions');
|
||||
expect(hiddenIds).toContain('rocket-search');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const appsActions: RoomToolboxActionConfig[] = [
|
||||
{
|
||||
id: 'app1',
|
||||
title: 'app-42212581-0966-44aa-8366-b3e92aa00df4.action_button_label_files',
|
||||
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
|
||||
type: 'apps',
|
||||
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
|
||||
},
|
||||
{
|
||||
id: 'app2',
|
||||
title: 'app-42212581-0966-44aa-8366-b3e92aa00df4.action_button_label_files',
|
||||
groups: ['group', 'channel', 'live', 'team', 'direct', 'direct_multiple'],
|
||||
type: 'apps',
|
||||
icon: undefined as unknown as RoomToolboxActionConfig['icon'],
|
||||
},
|
||||
];
|
||||
|
||||
const baseActions: RoomToolboxActionConfig[] = [
|
||||
{
|
||||
id: 'team-info',
|
||||
groups: ['team'],
|
||||
anonymous: true,
|
||||
full: true,
|
||||
title: 'Teams_Info',
|
||||
icon: 'info-circled',
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
id: 'thread',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
full: true,
|
||||
title: 'Threads',
|
||||
icon: 'thread',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'team-channels',
|
||||
groups: ['team'],
|
||||
anonymous: true,
|
||||
full: true,
|
||||
title: 'Team_Channels',
|
||||
icon: 'hash',
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: 'discussions',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Discussions',
|
||||
icon: 'discussion',
|
||||
full: true,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
id: 'start-call',
|
||||
title: 'Call',
|
||||
icon: 'phone',
|
||||
groups: ['direct', 'direct_multiple', 'group', 'team', 'channel', 'direct'],
|
||||
disabled: false,
|
||||
full: true,
|
||||
order: 4,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: 'rocket-search',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
|
||||
title: 'Search_Messages',
|
||||
icon: 'magnifier',
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
id: 'mentions',
|
||||
groups: ['channel', 'group', 'team'],
|
||||
title: 'Mentions',
|
||||
icon: 'at',
|
||||
order: 6,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'members-list',
|
||||
groups: ['channel', 'group', 'team'],
|
||||
title: 'Teams_members',
|
||||
icon: 'members',
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
id: 'uploaded-files-list',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'live', 'team'],
|
||||
title: 'Files',
|
||||
icon: 'clip',
|
||||
order: 8,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'pinned-messages',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Pinned_Messages',
|
||||
icon: 'pin',
|
||||
order: 9,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'starred-messages',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Starred_Messages',
|
||||
icon: 'star',
|
||||
order: 10,
|
||||
type: 'organization',
|
||||
},
|
||||
{
|
||||
id: 'keyboard-shortcut-list',
|
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'],
|
||||
title: 'Keyboard_Shortcuts_Title',
|
||||
icon: 'keyboard',
|
||||
order: 99,
|
||||
type: 'customization',
|
||||
},
|
||||
{
|
||||
id: 'clean-history',
|
||||
groups: ['channel', 'group', 'team', 'direct_multiple', 'direct'],
|
||||
full: true,
|
||||
title: 'Prune_Messages',
|
||||
icon: 'eraser',
|
||||
order: 250,
|
||||
type: 'customization',
|
||||
},
|
||||
];
|
||||
|
||||
// real toolbox actions open a contextual bar or run an action; keep fixtures faithful to that
|
||||
const actions: RoomToolboxActionConfig[] = baseActions.map((action) => ({ tabComponent: MockTabComponent, ...action }));
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { GenericMenuItemProps } from '@rocket.chat/ui-client';
|
||||
import { useFeaturePreview } from '@rocket.chat/ui-client';
|
||||
import { useLayout, useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig, RoomToolboxContextValue } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { resolveLayoutForRoomType } from './parseLayoutSetting';
|
||||
import { processRoomActions } from './processRoomActions';
|
||||
import type { RoomToolboxBaseAction } from './processRoomActions';
|
||||
import { useRoom } from '../../../contexts/RoomContext';
|
||||
|
||||
type MenuSection = {
|
||||
id: string;
|
||||
title?: string;
|
||||
items: GenericMenuItemProps[];
|
||||
};
|
||||
|
||||
const canRenderAsMenuItem = (item: RoomToolboxActionConfig): boolean => Boolean(item.action ?? item.tabComponent);
|
||||
|
||||
const isSelfRendering = (item: RoomToolboxActionConfig): boolean => Boolean(item.renderToolboxItem) && !canRenderAsMenuItem(item);
|
||||
|
||||
const actionToMenuItem = (
|
||||
item: RoomToolboxBaseAction,
|
||||
openTab: RoomToolboxContextValue['openTab'],
|
||||
t: (key: string) => string,
|
||||
): GenericMenuItemProps => ({
|
||||
...(item as Record<string, unknown>),
|
||||
id: item.id,
|
||||
content: t(item.title as string),
|
||||
onClick:
|
||||
(item.action as (() => void) | undefined) ??
|
||||
((): void => {
|
||||
openTab(item.id);
|
||||
}),
|
||||
});
|
||||
|
||||
export const useRoomToolboxActions = ({ actions, openTab }: Pick<RoomToolboxContextValue, 'actions' | 'openTab'>) => {
|
||||
const { t } = useTranslation();
|
||||
const { roomToolboxExpanded } = useLayout();
|
||||
const room = useRoom();
|
||||
const isLayoutPreviewEnabled = useFeaturePreview('roomToolboxLayout');
|
||||
|
||||
const layoutSettingJSON = useSetting('Room_Toolbox_Layout', '');
|
||||
|
||||
const layoutConfig = useMemo(() => {
|
||||
if (!isLayoutPreviewEnabled) {
|
||||
return null;
|
||||
}
|
||||
return resolveLayoutForRoomType(layoutSettingJSON, room.t);
|
||||
}, [isLayoutPreviewEnabled, layoutSettingJSON, room.t]);
|
||||
|
||||
if (isLayoutPreviewEnabled && layoutConfig) {
|
||||
const { featuredActions, visibleActions: engineVisible, hiddenActions: engineSections } = processRoomActions(actions, layoutConfig);
|
||||
|
||||
const typedFeatured = featuredActions as RoomToolboxActionConfig[];
|
||||
const typedVisible = engineVisible as RoomToolboxActionConfig[];
|
||||
const overflowActions = engineSections.flatMap((section) => section.items as RoomToolboxActionConfig[]);
|
||||
|
||||
if (!roomToolboxExpanded) {
|
||||
const pinnedSelfRendering = [...typedVisible, ...overflowActions].filter(isSelfRendering);
|
||||
|
||||
const orderedOverflowActions = [...typedVisible, ...overflowActions].filter((item) => !item.disabled && canRenderAsMenuItem(item));
|
||||
|
||||
const sectionsMap = new Map<string, MenuSection>();
|
||||
for (const item of orderedOverflowActions) {
|
||||
const group = item.type ?? '';
|
||||
const menuItem = actionToMenuItem(item, openTab, t);
|
||||
const existing = sectionsMap.get(group);
|
||||
if (existing) {
|
||||
existing.items.push(menuItem);
|
||||
} else {
|
||||
sectionsMap.set(group, {
|
||||
id: group,
|
||||
title: group === 'apps' ? t('Apps') : '',
|
||||
items: [menuItem],
|
||||
});
|
||||
}
|
||||
}
|
||||
const hiddenActions = Array.from(sectionsMap.values());
|
||||
|
||||
return { featuredActions: [...typedFeatured, ...pinnedSelfRendering], visibleActions: [], hiddenActions };
|
||||
}
|
||||
|
||||
const hiddenActions = engineSections
|
||||
.map((section) => ({
|
||||
id: section.id,
|
||||
title: section.id === 'apps' ? t('Apps') : '',
|
||||
items: (section.items as RoomToolboxActionConfig[])
|
||||
.filter((item) => !item.disabled && canRenderAsMenuItem(item))
|
||||
.map((item) => actionToMenuItem(item, openTab, t)),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
|
||||
const rescuedFromOverflow = overflowActions.filter(isSelfRendering);
|
||||
|
||||
return { featuredActions: typedFeatured, visibleActions: [...typedVisible, ...rescuedFromOverflow], hiddenActions };
|
||||
}
|
||||
|
||||
const normalActions = actions.filter((action) => !action.featured && action.type !== 'apps');
|
||||
const featuredActions = actions.filter((action) => action.featured);
|
||||
const appsActions = actions.filter((action) => action.type === 'apps');
|
||||
const visibleActions = !roomToolboxExpanded ? [] : normalActions.slice(0, 6);
|
||||
|
||||
const hiddenActions = (!roomToolboxExpanded ? actions : [...appsActions, ...normalActions.slice(6)])
|
||||
.filter((item) => !item.disabled && !item.featured)
|
||||
.map((item) => ({
|
||||
content: t(item.title),
|
||||
onClick:
|
||||
item.action ??
|
||||
((): void => {
|
||||
openTab(item.id);
|
||||
}),
|
||||
...item,
|
||||
}))
|
||||
.reduce((acc, item) => {
|
||||
const group = item.type ? item.type : '';
|
||||
const section = acc.find((section: { id: string }) => section.id === group);
|
||||
if (section) {
|
||||
section.items.push(item);
|
||||
return acc;
|
||||
}
|
||||
|
||||
const newSection = { id: group, title: group === 'apps' ? t('Apps') : '', items: [item] };
|
||||
acc.push(newSection);
|
||||
|
||||
return acc;
|
||||
}, [] as MenuSection[]);
|
||||
|
||||
return { hiddenActions, featuredActions, visibleActions };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default lazy(() => import('./RoomToolbox'));
|
||||
Reference in New Issue
Block a user