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,78 @@
|
||||
import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings';
|
||||
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useAgentsList } from './useAgentsList';
|
||||
import { createFakeAgent } from '../../../../tests/mocks/data';
|
||||
|
||||
const formatAgentItem = (agent: Serialized<ILivechatAgent>) => ({
|
||||
_id: agent._id,
|
||||
label: `${agent.name || agent._id} (@${agent.username})`,
|
||||
value: agent._id,
|
||||
});
|
||||
|
||||
const mockGetAgents = jest.fn();
|
||||
|
||||
const appRoot = new MockedAppRootBuilder()
|
||||
.withTranslations('en', 'core', { All: 'All', Empty_no_agent_selected: 'Empty, no agent selected' })
|
||||
.withEndpoint('GET', '/v1/livechat/users/agent', mockGetAgents);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch agents', async () => {
|
||||
const limit = 5;
|
||||
|
||||
const data = Array.from({ length: 10 }, () => createFakeAgent());
|
||||
|
||||
mockGetAgents.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
|
||||
const users = data.slice(offset, offset + count);
|
||||
|
||||
return {
|
||||
users,
|
||||
count,
|
||||
offset,
|
||||
total: data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentsList({ filter: '', limit }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatAgentItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.map(formatAgentItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
// should not fetch again since total was reached
|
||||
expect(mockGetAgents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should include "All" item if haveAll is true', async () => {
|
||||
mockGetAgents.mockResolvedValueOnce({
|
||||
users: Array.from({ length: 5 }, () => createFakeAgent()),
|
||||
count: 5,
|
||||
offset: 0,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentsList({ filter: '', haveAll: true }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data[0].label).toBe('All'));
|
||||
});
|
||||
|
||||
it('should include "Empty_no_agent_selected" item if haveNoAgentsSelectedOption is true', async () => {
|
||||
mockGetAgents.mockResolvedValueOnce({
|
||||
users: Array.from({ length: 5 }, () => createFakeAgent()),
|
||||
count: 5,
|
||||
offset: 0,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentsList({ filter: '', haveNoAgentsSelectedOption: true }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data[0].label).toBe('Empty, no agent selected'));
|
||||
});
|
||||
92
apps/meteor/client/views/omnichannel/hooks/useAgentsList.ts
Normal file
92
apps/meteor/client/views/omnichannel/hooks/useAgentsList.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import type { ILivechatAgent, Serialized } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type AgentsListOptions = {
|
||||
filter: string;
|
||||
haveAll?: boolean;
|
||||
haveNoAgentsSelectedOption?: boolean;
|
||||
excludeId?: string;
|
||||
showIdleAgents?: boolean;
|
||||
onlyAvailable?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type AgentOption = {
|
||||
_id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_LIMIT = 25;
|
||||
|
||||
export const useAgentsList = (options: AgentsListOptions) => {
|
||||
const { t } = useTranslation();
|
||||
const getAgents = useEndpoint('GET', '/v1/livechat/users/agent');
|
||||
const {
|
||||
filter,
|
||||
onlyAvailable = false,
|
||||
showIdleAgents = true,
|
||||
excludeId,
|
||||
haveAll,
|
||||
haveNoAgentsSelectedOption,
|
||||
limit = DEFAULT_QUERY_LIMIT,
|
||||
} = options;
|
||||
|
||||
const formatAgentItem = (agent: Serialized<ILivechatAgent>) => ({
|
||||
_id: agent._id,
|
||||
label: `${agent.name || agent._id} (@${agent.username})`,
|
||||
value: agent._id,
|
||||
});
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['/v1/livechat/users/agent', { filter, onlyAvailable, showIdleAgents, excludeId, haveAll, haveNoAgentsSelectedOption }],
|
||||
queryFn: async ({ pageParam: offset = 0 }) => {
|
||||
const { users, ...data } = await getAgents({
|
||||
...(filter && { text: filter }),
|
||||
...(excludeId && { excludeId }),
|
||||
showIdleAgents,
|
||||
onlyAvailable,
|
||||
offset,
|
||||
count: limit,
|
||||
sort: `{ "name": 1 }`,
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
users: users.map(formatAgentItem),
|
||||
};
|
||||
},
|
||||
select: (data) => {
|
||||
const items = data.pages.flatMap<AgentOption>((page) => page.users);
|
||||
|
||||
if (haveAll) {
|
||||
items.unshift({
|
||||
label: t('All'),
|
||||
value: 'all',
|
||||
_id: 'all',
|
||||
});
|
||||
}
|
||||
|
||||
if (haveNoAgentsSelectedOption) {
|
||||
items.unshift({
|
||||
label: t('Empty_no_agent_selected'),
|
||||
value: 'no-agent-selected',
|
||||
_id: 'no-agent-selected',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
return offset < lastPage.total ? offset : undefined;
|
||||
},
|
||||
initialData: () => ({
|
||||
pages: [{ users: [], offset: 0, count: 0, total: Infinity }],
|
||||
pageParams: [0],
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useCannedResponseFilterOptions = (): string[][] => {
|
||||
const { t } = useTranslation();
|
||||
const getDepartments = useEndpoint('GET', '/v1/livechat/department');
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => [
|
||||
['all', t('All')],
|
||||
['global', t('Public')],
|
||||
['user', t('Private')],
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const [options, setOptions] = useState(defaultOptions);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async (): Promise<void> => {
|
||||
const { departments } = await getDepartments({ text: '' });
|
||||
|
||||
const newOptions = departments.map((department: any) => [department._id, department.name]);
|
||||
|
||||
setOptions(defaultOptions.concat(newOptions));
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [defaultOptions, getDepartments]);
|
||||
|
||||
return options;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
import { cannedResponsesQueryKeys } from '../../../lib/queryKeys';
|
||||
|
||||
export const useCannedResponseList = ({ filter, type }: { filter: string; type: string }) => {
|
||||
const getCannedResponses = useEndpoint('GET', '/v1/canned-responses');
|
||||
const getDepartments = useEndpoint('GET', '/v1/livechat/department');
|
||||
|
||||
const count = 25;
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: cannedResponsesQueryKeys.list({ filter, type }),
|
||||
queryFn: async ({ pageParam: offset }) => {
|
||||
const { cannedResponses, total } = await getCannedResponses({
|
||||
...(filter && { text: filter }),
|
||||
...(type && ['global', 'user'].find((option) => option === type) && { scope: type }),
|
||||
...(type &&
|
||||
!['global', 'user', 'all'].find((option) => option === type) && {
|
||||
scope: 'department',
|
||||
departmentId: type,
|
||||
}),
|
||||
offset,
|
||||
count,
|
||||
});
|
||||
|
||||
// TODO: Optimize by creating an endpoint that returns canned responses with department names
|
||||
// TODO: Use another query for departments to avoid refetching
|
||||
const { departments } = await getDepartments({ text: '' });
|
||||
|
||||
return {
|
||||
items: cannedResponses.map((cannedResponse): IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] } => {
|
||||
const departmentName = cannedResponse.departmentId
|
||||
? departments.find((department) => department._id === cannedResponse.departmentId)?.name
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...cannedResponse,
|
||||
_updatedAt: new Date(cannedResponse._updatedAt),
|
||||
_createdAt: new Date(cannedResponse._createdAt),
|
||||
departmentName,
|
||||
};
|
||||
}),
|
||||
itemCount: total,
|
||||
};
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage, _, lastOffset) => {
|
||||
const nextOffset = lastOffset + count;
|
||||
if (nextOffset >= lastPage.itemCount) return undefined;
|
||||
return nextOffset;
|
||||
},
|
||||
select: ({ pages }) => ({
|
||||
cannedItems: pages.flatMap((page) => page.items),
|
||||
total: pages.at(-1)?.itemCount,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
|
||||
|
||||
const CannedResponse = lazy(() => import('../cannedResponses/contextualBar/CannedResponse/WrapCannedResponseList'));
|
||||
|
||||
export const useCannedResponsesRoomAction = () => {
|
||||
const { data: licensed = false } = useHasLicenseModule('canned-responses');
|
||||
const enabled = useSetting('Canned_Responses_Enable', false);
|
||||
|
||||
return useMemo((): RoomToolboxActionConfig | undefined => {
|
||||
if (!licensed || !enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'canned-responses',
|
||||
groups: ['live'],
|
||||
title: 'Canned_Responses',
|
||||
icon: 'canned-response',
|
||||
tabComponent: CannedResponse,
|
||||
order: 0,
|
||||
};
|
||||
}, [enabled, licensed]);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
const ContactInfoRouter = lazy(() => import('../contactInfo/ContactInfoRouter'));
|
||||
|
||||
export const useContactProfileRoomAction = () => {
|
||||
return useMemo(
|
||||
(): RoomToolboxActionConfig => ({
|
||||
id: 'contact-profile',
|
||||
groups: ['live'],
|
||||
title: 'Contact_Info',
|
||||
icon: 'user',
|
||||
tabComponent: ContactInfoRouter,
|
||||
order: 1,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { RouteParameters } from '@rocket.chat/ui-contexts';
|
||||
import { useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export const useContactRoute = () => {
|
||||
const { navigate, getRouteParameters, getRouteName } = useRouter();
|
||||
const currentRouteName = getRouteName();
|
||||
const currentParams = getRouteParameters();
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
({ id, ...params }: RouteParameters) => {
|
||||
if (!currentRouteName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Map out all the routes that use this hook and properly handle them
|
||||
if (currentRouteName === 'omnichannel-directory' || currentRouteName === 'omnichannel-current-chats') {
|
||||
return navigate({
|
||||
name: currentRouteName,
|
||||
params: {
|
||||
...currentParams,
|
||||
tab: 'contacts',
|
||||
id: id || currentParams.id,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
navigate({
|
||||
name: currentRouteName,
|
||||
params: {
|
||||
...currentParams,
|
||||
id: currentParams.id,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
},
|
||||
[navigate, currentParams, currentRouteName],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentParams.context) {
|
||||
handleNavigate({ context: 'details' });
|
||||
}
|
||||
}, [currentParams.context, handleNavigate]);
|
||||
|
||||
return handleNavigate;
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { OperationResult } from '@rocket.chat/rest-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import type { UseQueryResult } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
|
||||
|
||||
export const useCustomFieldsQuery = (): UseQueryResult<OperationResult<'GET', '/v1/livechat/custom-fields'>> => {
|
||||
const getCustomFields = useEndpoint('GET', '/v1/livechat/custom-fields');
|
||||
return useQuery({ queryKey: omnichannelQueryKeys.livechat.customFields(), queryFn: async () => getCustomFields() });
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ILivechatDepartment, Serialized } from '@rocket.chat/core-typings';
|
||||
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useDepartmentsList } from './useDepartmentsList';
|
||||
import { createFakeDepartment } from '../../../../tests/mocks/data';
|
||||
|
||||
const formatDepartmentItem = (department: Serialized<ILivechatDepartment>) => ({
|
||||
_id: department._id,
|
||||
label: department.archived ? `${department.name} [Archived]` : department.name,
|
||||
value: department._id,
|
||||
});
|
||||
|
||||
const mockGetDepartments = jest.fn();
|
||||
const mockGetUnitDepartments = jest.fn();
|
||||
const mockGetDepartment = jest.fn();
|
||||
|
||||
const appRoot = new MockedAppRootBuilder()
|
||||
.withTranslations('en', 'core', { All: 'All', None: 'None', Archived: 'Archived' })
|
||||
.withEndpoint('GET', '/v1/livechat/department', mockGetDepartments)
|
||||
.withEndpoint('GET', '/v1/livechat/units/:unitId/departments/available', mockGetUnitDepartments)
|
||||
.withEndpoint('GET', '/v1/livechat/department/:_id', mockGetDepartment);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch departments', async () => {
|
||||
const limit = 5;
|
||||
|
||||
const data = Array.from({ length: 10 }, () => createFakeDepartment());
|
||||
|
||||
mockGetDepartments.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
|
||||
const departments = data.slice(offset, offset + count);
|
||||
|
||||
return {
|
||||
departments,
|
||||
count,
|
||||
offset,
|
||||
total: data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDepartmentsList({ filter: '', limit }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatDepartmentItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.map(formatDepartmentItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
// should not fetch again since total was reached
|
||||
expect(mockGetDepartments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fetch unit departments when unitId is provided', async () => {
|
||||
const unitDepartmentsList = Array.from({ length: 10 }, () => createFakeDepartment());
|
||||
const departmentItems = unitDepartmentsList.map(formatDepartmentItem);
|
||||
mockGetUnitDepartments.mockResolvedValue({
|
||||
departments: unitDepartmentsList,
|
||||
count: 5,
|
||||
offset: 0,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDepartmentsList({ filter: '', unitId: '123' }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(departmentItems));
|
||||
|
||||
expect(mockGetDepartment).not.toHaveBeenCalled();
|
||||
expect(mockGetDepartments).not.toHaveBeenCalled();
|
||||
expect(mockGetUnitDepartments).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should format archived departments correctly', async () => {
|
||||
mockGetDepartments.mockResolvedValueOnce({
|
||||
departments: [createFakeDepartment({ name: 'Test Department', archived: true })],
|
||||
count: 1,
|
||||
offset: 0,
|
||||
total: 1,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDepartmentsList({ filter: '' }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data.length).toBe(1));
|
||||
expect(result.current.data[0].label).toBe('Test Department [Archived]');
|
||||
});
|
||||
|
||||
it('should include "All" item if haveAll is true', async () => {
|
||||
mockGetDepartments.mockResolvedValueOnce({
|
||||
departments: Array.from({ length: 5 }, () => createFakeDepartment()),
|
||||
count: 5,
|
||||
offset: 0,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDepartmentsList({ filter: '', haveAll: true }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data[0].label).toBe('All'));
|
||||
});
|
||||
|
||||
it('should include "None" item if haveNone is true', async () => {
|
||||
mockGetDepartments.mockResolvedValueOnce({
|
||||
departments: Array.from({ length: 5 }, () => createFakeDepartment()),
|
||||
count: 5,
|
||||
offset: 0,
|
||||
total: 5,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDepartmentsList({ filter: '', haveNone: true }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data[0].label).toBe('None'));
|
||||
});
|
||||
|
||||
it('should fetch the selected department if selectedDepartmentId is provided', async () => {
|
||||
const departmentsList = Array.from({ length: 10 }, () => createFakeDepartment());
|
||||
const departmentItems = departmentsList.map(formatDepartmentItem);
|
||||
const selectedDepartmentMock = createFakeDepartment({ _id: 'selected-department-id' });
|
||||
const selectedDepartmentItem = formatDepartmentItem(selectedDepartmentMock);
|
||||
|
||||
mockGetDepartment.mockResolvedValueOnce({ department: selectedDepartmentMock });
|
||||
|
||||
mockGetDepartments.mockResolvedValueOnce({
|
||||
departments: [...departmentsList, createFakeDepartment({ _id: 'selected-department-id' })],
|
||||
count: 10,
|
||||
offset: 0,
|
||||
total: 10,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDepartmentsList({ filter: '', selectedDepartmentId: selectedDepartmentMock._id }), {
|
||||
wrapper: appRoot.build(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual([...departmentItems, selectedDepartmentItem]));
|
||||
|
||||
expect(mockGetDepartments).toHaveBeenCalled();
|
||||
expect(mockGetDepartment).toHaveBeenCalled();
|
||||
});
|
||||
111
apps/meteor/client/views/omnichannel/hooks/useDepartmentsList.ts
Normal file
111
apps/meteor/client/views/omnichannel/hooks/useDepartmentsList.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { ILivechatDepartment, Serialized } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { DepartmentListItem } from '../types/DepartmentsDefinitions';
|
||||
|
||||
type DepartmentsListOptions = {
|
||||
filter: string;
|
||||
onlyMyDepartments?: boolean;
|
||||
haveAll?: boolean;
|
||||
haveNone?: boolean;
|
||||
excludeId?: string;
|
||||
enabled?: boolean;
|
||||
showArchived?: boolean;
|
||||
selectedDepartmentId?: string;
|
||||
limit?: number;
|
||||
unitId?: string;
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_LIMIT = 50;
|
||||
|
||||
export const useDepartmentsList = (options: DepartmentsListOptions) => {
|
||||
const { t } = useTranslation();
|
||||
const { enabled, filter, unitId, showArchived, excludeId, onlyMyDepartments, limit = DEFAULT_QUERY_LIMIT } = options;
|
||||
|
||||
const getLivechatDepartments = useEndpoint('GET', '/v1/livechat/department');
|
||||
const getUnitDepartments = useEndpoint('GET', '/v1/livechat/units/:unitId/departments/available', { unitId: options.unitId || 'none' });
|
||||
const getDepartments = options.unitId !== undefined ? getUnitDepartments : getLivechatDepartments;
|
||||
const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: options.selectedDepartmentId ?? '' });
|
||||
|
||||
const formatDepartmentItem = ({ _id, name, ...department }: Serialized<ILivechatDepartment>): DepartmentListItem => ({
|
||||
_id,
|
||||
label: department.archived ? `${name} [${t('Archived')}]` : name,
|
||||
value: _id,
|
||||
});
|
||||
|
||||
const selectedQueryResult = useQuery({
|
||||
queryKey: ['/v1/livechat/department/:_id', options.selectedDepartmentId],
|
||||
queryFn: async () => getDepartment({}),
|
||||
select: (data) => formatDepartmentItem(data.department),
|
||||
enabled: options.selectedDepartmentId !== undefined,
|
||||
});
|
||||
|
||||
const listQueryResult = useInfiniteQuery({
|
||||
queryKey: ['/v1/livechat/department', options],
|
||||
queryFn: async ({ pageParam: offset = 0 }) => {
|
||||
const { departments, ...data } = await getDepartments({
|
||||
onlyMyDepartments: `${!!onlyMyDepartments}`,
|
||||
text: filter,
|
||||
offset,
|
||||
count: limit,
|
||||
sort: `{ "name": 1 }`,
|
||||
...(!unitId && {
|
||||
excludeDepartmentId: excludeId,
|
||||
enabled: enabled ? 'true' : 'false',
|
||||
showArchived: showArchived ? 'true' : 'false',
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
departments: departments.map(formatDepartmentItem).filter((item) => item._id !== options.selectedDepartmentId),
|
||||
};
|
||||
},
|
||||
select: (data) => {
|
||||
const items = data.pages.flatMap<DepartmentListItem>((page) => page.departments);
|
||||
|
||||
if (options.haveAll) {
|
||||
items.unshift({
|
||||
_id: '',
|
||||
label: t('All'),
|
||||
value: 'all',
|
||||
});
|
||||
}
|
||||
|
||||
if (options.haveNone) {
|
||||
items.unshift({
|
||||
_id: '',
|
||||
label: t('None'),
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
return offset < lastPage.total ? offset : undefined;
|
||||
},
|
||||
initialData: () => ({
|
||||
pages: [{ departments: [], offset: 0, count: 0, total: Infinity }],
|
||||
pageParams: [0],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (selectedQueryResult.data) {
|
||||
return [...listQueryResult.data, selectedQueryResult.data];
|
||||
}
|
||||
|
||||
return listQueryResult.data;
|
||||
}, [selectedQueryResult.data, listQueryResult.data]);
|
||||
|
||||
return {
|
||||
...listQueryResult,
|
||||
data,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useIsOverMacLimit = (): boolean => {
|
||||
const { isOverMacLimit } = useOmnichannel();
|
||||
return isOverMacLimit;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isOmnichannelRoom } from '@rocket.chat/core-typings';
|
||||
import type { IRoom, IOmnichannelGenericRoom } from '@rocket.chat/core-typings';
|
||||
|
||||
import { useIsOverMacLimit } from './useIsOverMacLimit';
|
||||
|
||||
const getPeriod = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
|
||||
export const useIsRoomOverMacLimit = (room: IRoom) => {
|
||||
const isOverMacLimit = useIsOverMacLimit();
|
||||
|
||||
if (!isOmnichannelRoom(room)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (room.closedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { v: { activity = [] } = {} } = room as IOmnichannelGenericRoom;
|
||||
|
||||
const currentPeriod = getPeriod(new Date());
|
||||
return isOverMacLimit && !activity.includes(currentPeriod);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
type Props = {
|
||||
department?: string;
|
||||
text?: string;
|
||||
viewAll?: boolean;
|
||||
};
|
||||
|
||||
export const useLivechatTags = (options: Props) => {
|
||||
const getTags = useEndpoint('GET', '/v1/livechat/tags');
|
||||
const { isEnterprise } = useOmnichannel();
|
||||
|
||||
const { department, text, viewAll } = options;
|
||||
return useQuery({
|
||||
queryKey: ['/v1/livechat/tags', text, department],
|
||||
|
||||
queryFn: () =>
|
||||
getTags({
|
||||
text: text || '',
|
||||
...(department && { department }),
|
||||
viewAll: viewAll ? 'true' : 'false',
|
||||
}),
|
||||
|
||||
enabled: isEnterprise,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ILivechatMonitor, Serialized } from '@rocket.chat/core-typings';
|
||||
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useMonitorsList } from './useMonitorsList';
|
||||
import { createFakeMonitor } from '../../../../tests/mocks/data';
|
||||
|
||||
const formatMonitorItem = (monitor: Serialized<ILivechatMonitor>) => ({
|
||||
_id: monitor._id,
|
||||
label: monitor.username,
|
||||
value: monitor._id,
|
||||
});
|
||||
|
||||
const mockGetMonitors = jest.fn();
|
||||
|
||||
const appRoot = new MockedAppRootBuilder().withEndpoint('GET', '/v1/livechat/monitors', mockGetMonitors);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch monitors', async () => {
|
||||
const limit = 5;
|
||||
|
||||
const data = Array.from({ length: 10 }, () => createFakeMonitor());
|
||||
|
||||
mockGetMonitors.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
|
||||
const monitors = data.slice(offset, offset + count);
|
||||
|
||||
return {
|
||||
monitors,
|
||||
count,
|
||||
offset,
|
||||
total: data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMonitorsList({ filter: '', limit }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatMonitorItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.map(formatMonitorItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
// should not fetch again since total was reached
|
||||
expect(mockGetMonitors).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Serialized, ILivechatMonitor } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
type MonitorsListOptions = {
|
||||
filter: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
type MonitorListItem = {
|
||||
_id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_LIMIT = 25;
|
||||
|
||||
export const useMonitorsList = (options: MonitorsListOptions) => {
|
||||
const { filter, limit = DEFAULT_QUERY_LIMIT } = options;
|
||||
const getMonitors = useEndpoint('GET', '/v1/livechat/monitors');
|
||||
|
||||
const formatMonitorItem = (monitor: Serialized<ILivechatMonitor>): MonitorListItem => ({
|
||||
_id: monitor._id,
|
||||
label: monitor.username,
|
||||
value: monitor._id,
|
||||
});
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['/v1/livechat/monitors', options],
|
||||
queryFn: async ({ pageParam: offset = 0 }) => {
|
||||
const { monitors, ...data } = await getMonitors({
|
||||
text: filter,
|
||||
offset,
|
||||
count: limit,
|
||||
sort: JSON.stringify({ username: 1 }),
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
monitors: monitors.map(formatMonitorItem),
|
||||
};
|
||||
},
|
||||
select: (data) => data.pages.flatMap<MonitorListItem>((page) => page.monitors),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
return offset < lastPage.total ? offset : undefined;
|
||||
},
|
||||
initialData: () => ({
|
||||
pages: [{ monitors: [], offset: 0, count: 0, total: Infinity }],
|
||||
pageParams: [0],
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
|
||||
import type { OmnichannelContextValue } from '../../../contexts/OmnichannelContext';
|
||||
import { OmnichannelContext } from '../../../contexts/OmnichannelContext';
|
||||
|
||||
export const useOmnichannel = (): OmnichannelContextValue => useContext(OmnichannelContext);
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useOmnichannelAgentAvailable = (): boolean => useOmnichannel().agentAvailable;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useRouter, useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useOmnichannelDirectoryRouter } from '../directory/hooks/useOmnichannelDirectoryRouter';
|
||||
|
||||
export const useOmnichannelCloseRoute = () => {
|
||||
const hideConversationAfterClosing = useUserPreference<boolean>('omnichannelHideConversationAfterClosing') ?? true;
|
||||
const router = useRouter();
|
||||
const omnichannelDirectoryRouter = useOmnichannelDirectoryRouter();
|
||||
|
||||
const navigateHome = useCallback(() => {
|
||||
if (!hideConversationAfterClosing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (omnichannelDirectoryRouter.getRouteName()) {
|
||||
omnichannelDirectoryRouter.navigate();
|
||||
} else {
|
||||
router.navigate({ name: 'home' });
|
||||
}
|
||||
}, [hideConversationAfterClosing, omnichannelDirectoryRouter, router]);
|
||||
|
||||
return { navigateHome };
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useOmnichannelEnabled = (): boolean => useOmnichannel().enabled;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useOmnichannelEnterpriseEnabled = (): boolean => {
|
||||
const { enabled: isEnabled, isEnterprise } = useOmnichannel();
|
||||
return isEnabled && isEnterprise;
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useSetting } from '@rocket.chat/ui-contexts';
|
||||
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
const ExternalFrameContainer = lazy(() => import('../ExternalFrameContainer'));
|
||||
|
||||
export const useOmnichannelExternalFrameRoomAction = () => {
|
||||
const enabled = useSetting('Omnichannel_External_Frame_Enabled', false);
|
||||
|
||||
return useMemo((): RoomToolboxActionConfig | undefined => {
|
||||
if (!enabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'omnichannel-external-frame',
|
||||
groups: ['live'],
|
||||
title: 'Omnichannel_External_Frame',
|
||||
icon: 'cube',
|
||||
tabComponent: ExternalFrameContainer,
|
||||
order: -1,
|
||||
};
|
||||
}, [enabled]);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useOmnichannelPriorities = () => useOmnichannel().livechatPriorities;
|
||||
@@ -0,0 +1,64 @@
|
||||
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
|
||||
import { Palette } from '@rocket.chat/fuselage';
|
||||
import type { Keys } from '@rocket.chat/icons';
|
||||
import type { TranslationKey } from '@rocket.chat/ui-contexts';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOmnichannelPriorities } from './useOmnichannelPriorities';
|
||||
|
||||
type PrioritiesConfig = { iconName: Keys; color?: string; variant?: 'secondary-danger' | 'secondary-warning' | 'secondary-info' };
|
||||
|
||||
export const PRIORITIES_CONFIG: Record<number, PrioritiesConfig> = {
|
||||
[LivechatPriorityWeight.NOT_SPECIFIED]: {
|
||||
iconName: 'circle-unfilled',
|
||||
},
|
||||
[LivechatPriorityWeight.HIGHEST]: {
|
||||
iconName: 'chevron-double-up',
|
||||
color: Palette.badge['badge-background-level-4'].toString(),
|
||||
variant: 'secondary-danger',
|
||||
},
|
||||
[LivechatPriorityWeight.HIGH]: {
|
||||
iconName: 'chevron-up',
|
||||
color: Palette.badge['badge-background-level-4'].toString(),
|
||||
variant: 'secondary-danger',
|
||||
},
|
||||
[LivechatPriorityWeight.MEDIUM]: {
|
||||
iconName: 'equal',
|
||||
color: Palette.badge['badge-background-level-3'].toString(),
|
||||
variant: 'secondary-warning',
|
||||
},
|
||||
[LivechatPriorityWeight.LOW]: {
|
||||
iconName: 'chevron-down',
|
||||
color: Palette.badge['badge-background-level-2'].toString(),
|
||||
variant: 'secondary-info',
|
||||
},
|
||||
[LivechatPriorityWeight.LOWEST]: {
|
||||
iconName: 'chevron-double-down',
|
||||
color: Palette.badge['badge-background-level-2'].toString(),
|
||||
variant: 'secondary-info',
|
||||
},
|
||||
};
|
||||
|
||||
export const useOmnichannelPrioritiesConfig = (level: LivechatPriorityWeight, showUnprioritized: boolean) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { iconName, color, variant } = PRIORITIES_CONFIG[level];
|
||||
const { data: priorities } = useOmnichannelPriorities();
|
||||
|
||||
const name = useMemo(() => {
|
||||
const { _id, dirty, name, i18n } = priorities.find((p) => p.sortItem === level) || {};
|
||||
|
||||
if (!_id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return dirty ? name : t(i18n as TranslationKey);
|
||||
}, [level, priorities, t]);
|
||||
|
||||
if (!showUnprioritized && level === LivechatPriorityWeight.NOT_SPECIFIED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { iconName, name, color, variant };
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { IRoom } from '@rocket.chat/core-typings';
|
||||
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useOmnichannelPriorities } from './useOmnichannelPriorities';
|
||||
import { PRIORITIES_CONFIG } from './useOmnichannelPrioritiesConfig';
|
||||
import { roomsQueryKeys } from '../../../lib/queryKeys';
|
||||
|
||||
export const useOmnichannelPrioritiesMenu = (rid: IRoom['_id']) => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const updateRoomPriority = useEndpoint('POST', '/v1/livechat/room/:rid/priority', { rid });
|
||||
const removeRoomPriority = useEndpoint('DELETE', '/v1/livechat/room/:rid/priority', { rid });
|
||||
const { data: priorities } = useOmnichannelPriorities();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
return useMemo(() => {
|
||||
const handlePriorityChange = (priorityId: string) => async () => {
|
||||
try {
|
||||
if (priorityId) {
|
||||
await updateRoomPriority({ priorityId });
|
||||
} else {
|
||||
await removeRoomPriority();
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['current-chats'] });
|
||||
queryClient.invalidateQueries({ queryKey: roomsQueryKeys.info(rid) });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
};
|
||||
|
||||
const unprioritizedOption = {
|
||||
id: 'unprioritized',
|
||||
icon: PRIORITIES_CONFIG[LivechatPriorityWeight.NOT_SPECIFIED].iconName,
|
||||
iconColor: PRIORITIES_CONFIG[LivechatPriorityWeight.NOT_SPECIFIED].color,
|
||||
content: t('Unprioritized'),
|
||||
onClick: handlePriorityChange(''),
|
||||
};
|
||||
|
||||
const options = priorities.map(({ _id: priorityId, name, i18n, dirty, sortItem }) => {
|
||||
const label = dirty && name ? name : t(i18n);
|
||||
|
||||
return {
|
||||
id: priorityId,
|
||||
icon: PRIORITIES_CONFIG[sortItem].iconName,
|
||||
iconColor: PRIORITIES_CONFIG[sortItem].color,
|
||||
content: label,
|
||||
onClick: handlePriorityChange(priorityId),
|
||||
};
|
||||
});
|
||||
|
||||
return priorities.length ? [unprioritizedOption, ...options] : [];
|
||||
}, [t, priorities, updateRoomPriority, removeRoomPriority, queryClient, rid, dispatchToastMessage]);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { OmichannelRoutingConfig } from '@rocket.chat/core-typings';
|
||||
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useOmnichannelRouteConfig = (): OmichannelRoutingConfig | undefined => useOmnichannel().routeConfig;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useOmnichannelShowQueueLink = (): boolean => useOmnichannel().showOmnichannelQueueLink;
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { IOmnichannelSource } from '@rocket.chat/core-typings';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useOmnichannelSource = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getSourceName = useCallback(
|
||||
(source: IOmnichannelSource, allowAlias = true) => {
|
||||
const roomSource = source.alias || source.id || source.type;
|
||||
|
||||
const defaultTypesLabels: { [key: string]: string } = {
|
||||
widget: t('Livechat'),
|
||||
email: t('Email'),
|
||||
sms: t('SMS'),
|
||||
app: (allowAlias && source.alias) || t('Custom_APP'),
|
||||
api: (allowAlias && source.alias) || t('Custom_API'),
|
||||
other: t('Custom_Integration'),
|
||||
};
|
||||
|
||||
return defaultTypesLabels[source.type] || roomSource;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const getSourceLabel = useCallback(
|
||||
(source: IOmnichannelSource) => {
|
||||
return source?.destination || source?.label || t('No_app_label_provided');
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
return { getSourceName, getSourceLabel };
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Inquiries } from '@rocket.chat/core-typings';
|
||||
|
||||
import { useOmnichannel } from './useOmnichannel';
|
||||
|
||||
export const useQueuedInquiries = (): Inquiries => useOmnichannel().inquiries;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts';
|
||||
import { lazy, useMemo } from 'react';
|
||||
|
||||
const ChatsContextualBar = lazy(() => import('../directory/chats/ChatInfo/ChatsContextualBar'));
|
||||
|
||||
export const useRoomInfoRoomAction = () =>
|
||||
useMemo(
|
||||
(): RoomToolboxActionConfig => ({
|
||||
id: 'room-info',
|
||||
groups: ['live'],
|
||||
title: 'Room_Info',
|
||||
icon: 'info-circled',
|
||||
tabComponent: ChatsContextualBar,
|
||||
order: 0,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
13
apps/meteor/client/views/omnichannel/hooks/useScopeDict.ts
Normal file
13
apps/meteor/client/views/omnichannel/hooks/useScopeDict.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useScopeDict = (scope: IOmnichannelCannedResponse['scope'], departmentName: ILivechatDepartment['name'] | undefined) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dict: Record<string, string> = {
|
||||
global: t('Public'),
|
||||
user: t('Private'),
|
||||
};
|
||||
|
||||
return dict[scope] || departmentName;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ILivechatTag, Serialized } from '@rocket.chat/core-typings';
|
||||
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import type { TagListItem } from './useTagsList';
|
||||
import { useTagsList } from './useTagsList';
|
||||
import { createFakeTag } from '../../../../tests/mocks/data';
|
||||
|
||||
const formatTagItem = (tag: Serialized<ILivechatTag>): TagListItem => ({
|
||||
_id: tag._id,
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
});
|
||||
|
||||
const mockGetTags = jest.fn();
|
||||
|
||||
const appRoot = new MockedAppRootBuilder().withEndpoint('GET', '/v1/livechat/tags', mockGetTags);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch tags', async () => {
|
||||
const limit = 5;
|
||||
|
||||
const data = Array.from({ length: 10 }, () => createFakeTag());
|
||||
|
||||
mockGetTags.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
|
||||
const tags = data.slice(offset, offset + count);
|
||||
|
||||
return {
|
||||
tags,
|
||||
count,
|
||||
offset,
|
||||
total: data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTagsList({ filter: '', limit }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatTagItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.map(formatTagItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
// should not fetch again since total was reached
|
||||
expect(mockGetTags).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
59
apps/meteor/client/views/omnichannel/hooks/useTagsList.ts
Normal file
59
apps/meteor/client/views/omnichannel/hooks/useTagsList.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ILivechatTag, Serialized } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
|
||||
type TagsListOptions = {
|
||||
filter: string;
|
||||
department?: string;
|
||||
viewAll?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type TagListItem = {
|
||||
_id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_LIMIT = 25;
|
||||
|
||||
export const useTagsList = (options: TagsListOptions) => {
|
||||
const { viewAll, department, filter, limit = DEFAULT_QUERY_LIMIT } = options;
|
||||
|
||||
const getTags = useEndpoint('GET', '/v1/livechat/tags');
|
||||
|
||||
const formatTagItem = (tag: Serialized<ILivechatTag>): TagListItem => ({
|
||||
_id: tag._id,
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
});
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['/v1/livechat/tags', options],
|
||||
queryFn: async ({ pageParam: offset = 0 }) => {
|
||||
const { tags, ...data } = await getTags({
|
||||
text: filter,
|
||||
offset,
|
||||
count: limit,
|
||||
...(viewAll && { viewAll: 'true' }),
|
||||
...(department && { department }),
|
||||
sort: JSON.stringify({ name: 1 }),
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
tags: tags.map(formatTagItem),
|
||||
};
|
||||
},
|
||||
select: (data) => data.pages.flatMap<TagListItem>((page) => page.tags),
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
return offset < lastPage.total ? offset : undefined;
|
||||
},
|
||||
initialData: () => ({
|
||||
pages: [{ tags: [], offset: 0, count: 0, total: Infinity }],
|
||||
pageParams: [0],
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { IOmnichannelBusinessUnit, Serialized } from '@rocket.chat/core-typings';
|
||||
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { useUnitsList } from './useUnitsList';
|
||||
import { createFakeBusinessUnit } from '../../../../tests/mocks/data';
|
||||
|
||||
const formatUnitItem = (u: Serialized<IOmnichannelBusinessUnit>) => ({
|
||||
_id: u._id,
|
||||
label: u.name,
|
||||
value: u._id,
|
||||
});
|
||||
|
||||
const mockGetUnits = jest.fn();
|
||||
|
||||
const appRoot = new MockedAppRootBuilder().withEndpoint('GET', '/v1/livechat/units', mockGetUnits);
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch business units', async () => {
|
||||
const limit = 5;
|
||||
|
||||
const data = Array.from({ length: 10 }, () => createFakeBusinessUnit());
|
||||
|
||||
mockGetUnits.mockImplementation(({ offset, count }: { offset: number; count: number }) => {
|
||||
const units = data.slice(offset, offset + count);
|
||||
|
||||
return {
|
||||
units,
|
||||
count,
|
||||
offset,
|
||||
total: data.length,
|
||||
};
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUnitsList({ filter: '', limit }), { wrapper: appRoot.build() });
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.slice(0, 5).map(formatUnitItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
await waitFor(() => expect(result.current.data).toEqual(data.map(formatUnitItem)));
|
||||
|
||||
await act(() => result.current.fetchNextPage());
|
||||
|
||||
// should not fetch again since total was reached
|
||||
expect(mockGetUnits).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
68
apps/meteor/client/views/omnichannel/hooks/useUnitsList.ts
Normal file
68
apps/meteor/client/views/omnichannel/hooks/useUnitsList.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { IOmnichannelBusinessUnit, Serialized } from '@rocket.chat/core-typings';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type UnitsListOptions = {
|
||||
filter: string;
|
||||
haveNone?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type UnitOption = {
|
||||
_id: string;
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const DEFAULT_QUERY_LIMIT = 25;
|
||||
|
||||
export const useUnitsList = (options: UnitsListOptions) => {
|
||||
const { t } = useTranslation();
|
||||
const { haveNone = false, filter, limit = DEFAULT_QUERY_LIMIT } = options;
|
||||
const getUnits = useEndpoint('GET', '/v1/livechat/units');
|
||||
|
||||
const formatUnitItem = (u: Serialized<IOmnichannelBusinessUnit>): UnitOption => ({
|
||||
_id: u._id,
|
||||
label: u.name,
|
||||
value: u._id,
|
||||
});
|
||||
|
||||
return useInfiniteQuery({
|
||||
queryKey: ['/v1/livechat/units', options],
|
||||
queryFn: async ({ pageParam: offset = 0 }) => {
|
||||
const { units, ...data } = await getUnits({
|
||||
...(filter && { text: filter }),
|
||||
offset,
|
||||
count: limit,
|
||||
sort: `{ "name": 1 }`,
|
||||
});
|
||||
|
||||
return {
|
||||
...data,
|
||||
units: units.map(formatUnitItem),
|
||||
};
|
||||
},
|
||||
select: (data) => {
|
||||
const items = data.pages.flatMap<UnitOption>((page) => page.units);
|
||||
|
||||
haveNone &&
|
||||
items.unshift({
|
||||
_id: '',
|
||||
label: t('None'),
|
||||
value: '',
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
initialPageParam: 0,
|
||||
getNextPageParam: (lastPage) => {
|
||||
const offset = lastPage.offset + lastPage.count;
|
||||
return offset < lastPage.total ? offset : undefined;
|
||||
},
|
||||
initialData: () => ({
|
||||
pages: [{ units: [], offset: 0, count: 0, total: Infinity }],
|
||||
pageParams: [0],
|
||||
}),
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user