diff --git a/.changeset/large-islands-behave.md b/.changeset/large-islands-behave.md new file mode 100644 index 0000000000000..1dc1643dde8f5 --- /dev/null +++ b/.changeset/large-islands-behave.md @@ -0,0 +1,10 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/models': minor +'@rocket.chat/i18n': minor +'@rocket.chat/meteor': minor +--- + +Adds a new "Unit" field to the create/edit department page, allowing users to specify a business unit when creating or editing a department. diff --git a/apps/meteor/client/components/Omnichannel/hooks/useUnitsList.ts b/apps/meteor/client/components/Omnichannel/hooks/useUnitsList.ts index 146a377d3f51a..8960ce069e5eb 100644 --- a/apps/meteor/client/components/Omnichannel/hooks/useUnitsList.ts +++ b/apps/meteor/client/components/Omnichannel/hooks/useUnitsList.ts @@ -1,5 +1,6 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { useScrollableRecordList } from '../../../hooks/lists/useScrollableRecordList'; import { useComponentDidUpdate } from '../../../hooks/useComponentDidUpdate'; @@ -7,9 +8,10 @@ import { RecordList } from '../../../lib/lists/RecordList'; type UnitsListOptions = { text: string; + haveNone?: boolean; }; -type UnitOption = { value: string; label: string; _id: string }; +export type UnitOption = { value: string; label: string; _id: string }; export const useUnitsList = ( options: UnitsListOptions, @@ -19,6 +21,8 @@ export const useUnitsList = ( reload: () => void; loadMoreItems: (start: number, end: number) => void; } => { + const { t } = useTranslation(); + const { haveNone = false } = options; const [itemsList, setItemsList] = useState(() => new RecordList()); const reload = useCallback(() => setItemsList(new RecordList()), []); @@ -44,12 +48,19 @@ export const useUnitsList = ( value: u._id, })); + haveNone && + items.unshift({ + _id: '', + label: t('None'), + value: '', + }); + return { items, - itemCount: total, + itemCount: haveNone ? total + 1 : total, }; }, - [getUnits, text], + [getUnits, haveNone, t, text], ); const { loadMoreItems, initialItemCount } = useScrollableRecordList(itemsList, fetchData, 25); diff --git a/apps/meteor/client/omnichannel/additionalForms/AutoCompleteUnit.tsx b/apps/meteor/client/omnichannel/additionalForms/AutoCompleteUnit.tsx new file mode 100644 index 0000000000000..5dfd1205058e5 --- /dev/null +++ b/apps/meteor/client/omnichannel/additionalForms/AutoCompleteUnit.tsx @@ -0,0 +1,68 @@ +import { PaginatedSelectFiltered } from '@rocket.chat/fuselage'; +import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { UnitOption } from '../../components/Omnichannel/hooks/useUnitsList'; +import { useUnitsList } from '../../components/Omnichannel/hooks/useUnitsList'; +import { useRecordList } from '../../hooks/lists/useRecordList'; +import { AsyncStatePhase } from '../../lib/asyncState'; +import type { RecordList } from '../../lib/lists/RecordList'; + +type AutoCompleteUnitProps = { + disabled?: boolean; + value: string | undefined; + error?: string; + placeholder?: string; + haveNone?: boolean; + onChange: (value: string) => void; + onLoadItems?: (list: RecordList) => void; +}; + +const AutoCompleteUnit = ({ + value, + disabled = false, + error, + placeholder, + haveNone, + onChange, + onLoadItems = () => undefined, +}: AutoCompleteUnitProps) => { + const { t } = useTranslation(); + const [unitsFilter, setUnitsFilter] = useState(''); + + const debouncedUnitFilter = useDebouncedValue(unitsFilter, 500); + + const { itemsList, loadMoreItems: loadMoreUnits } = useUnitsList( + useMemo(() => ({ text: debouncedUnitFilter, haveNone }), [debouncedUnitFilter, haveNone]), + ); + const { phase: unitsPhase, itemCount: unitsTotal, items: unitsList } = useRecordList(itemsList); + + const handleLoadItems = useEffectEvent(onLoadItems); + + useEffect(() => { + handleLoadItems(itemsList); + }, [handleLoadItems, unitsTotal, itemsList]); + + return ( + void} + value={value} + width='100%' + endReached={ + unitsPhase === AsyncStatePhase.LOADING ? (): void => undefined : (start): void => loadMoreUnits(start, Math.min(50, unitsTotal)) + } + /> + ); +}; + +export default AutoCompleteUnit; diff --git a/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AddAgent.tsx b/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AddAgent.tsx index 482397dff0fe8..86cb80c7c4d5c 100644 --- a/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AddAgent.tsx +++ b/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AddAgent.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import AutoCompleteAgent from '../../../../components/AutoCompleteAgent'; import { useEndpointAction } from '../../../../hooks/useEndpointAction'; -import type { IDepartmentAgent } from '../EditDepartment'; +import type { IDepartmentAgent } from '../definitions'; function AddAgent({ agentList, onAdd }: { agentList: IDepartmentAgent[]; onAdd: (agent: IDepartmentAgent) => void }) { const { t } = useTranslation(); diff --git a/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AgentRow.tsx b/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AgentRow.tsx index c35d05fab78f6..bd429b163d8d6 100644 --- a/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AgentRow.tsx +++ b/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AgentRow.tsx @@ -4,14 +4,14 @@ import type { UseFormRegister } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { GenericTableRow, GenericTableCell } from '../../../../components/GenericTable'; -import type { FormValues, IDepartmentAgent } from '../EditDepartment'; +import type { EditDepartmentFormData, IDepartmentAgent } from '../definitions'; import AgentAvatar from './AgentAvatar'; import RemoveAgentButton from './RemoveAgentButton'; type AgentRowProps = { agent: IDepartmentAgent; index: number; - register: UseFormRegister; + register: UseFormRegister; onRemove: (agentId: string) => void; }; diff --git a/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/DepartmentAgentsTable.tsx b/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/DepartmentAgentsTable.tsx index 928cf504634a5..462bfbf20efb8 100644 --- a/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/DepartmentAgentsTable.tsx +++ b/apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/DepartmentAgentsTable.tsx @@ -6,13 +6,13 @@ import { useTranslation } from 'react-i18next'; import { GenericTable, GenericTableBody, GenericTableHeader, GenericTableHeaderCell } from '../../../../components/GenericTable'; import { usePagination } from '../../../../components/GenericTable/hooks/usePagination'; -import type { FormValues } from '../EditDepartment'; +import type { EditDepartmentFormData } from '../definitions'; import AddAgent from './AddAgent'; import AgentRow from './AgentRow'; type DepartmentAgentsTableProps = { - control: Control; - register: UseFormRegister; + control: Control; + register: UseFormRegister; }; function DepartmentAgentsTable({ control, register }: DepartmentAgentsTableProps) { diff --git a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx index f404600ca70f5..497109f8c7f49 100644 --- a/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx +++ b/apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx @@ -18,11 +18,15 @@ import { Option, } from '@rocket.chat/fuselage'; import { useDebouncedValue, useEffectEvent } from '@rocket.chat/fuselage-hooks'; -import { useToastMessageDispatch, useMethod, useEndpoint, useTranslation, useRouter } from '@rocket.chat/ui-contexts'; +import { useToastMessageDispatch, useEndpoint, useTranslation, useRouter, usePermission } from '@rocket.chat/ui-contexts'; import { useQueryClient } from '@tanstack/react-query'; import { useId, useMemo, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import type { EditDepartmentFormData } from './definitions'; +import { formatAgentListPayload } from './utils/formatAgentListPayload'; +import { formatEditDepartmentPayload } from './utils/formatEditDepartmentPayload'; +import { getFormInitialValues } from './utils/getFormInititalValues'; import { validateEmail } from '../../../../lib/emailValidator'; import AutoCompleteDepartment from '../../../components/AutoCompleteDepartment'; import { Page, PageHeader, PageScrollableContentWithShadow } from '../../../components/Page'; @@ -33,6 +37,7 @@ import { AsyncStatePhase } from '../../../lib/asyncState'; import { EeTextInput, EeTextAreaInput, EeNumberInput, DepartmentForwarding, DepartmentBusinessHours } from '../additionalForms'; import DepartmentsAgentsTable from './DepartmentAgentsTable/DepartmentAgentsTable'; import DepartmentTags from './DepartmentTags'; +import AutoCompleteUnit from '../../../omnichannel/additionalForms/AutoCompleteUnit'; export type EditDepartmentProps = { id?: string; @@ -46,62 +51,8 @@ export type EditDepartmentProps = { }>; }; -type InitialValueParams = { - department?: Serialized | null; - agents?: Serialized[]; - allowedToForwardData?: EditDepartmentProps['allowedToForwardData']; -}; - -export type IDepartmentAgent = Pick & { - _id?: string; - name?: string; -}; - -export type FormValues = { - name: string; - email: string; - description: string; - enabled: boolean; - maxNumberSimultaneousChat: number; - showOnRegistration: boolean; - showOnOfflineForm: boolean; - abandonedRoomsCloseCustomMessage: string; - requestTagBeforeClosingChat: boolean; - offlineMessageChannelName: string; - visitorInactivityTimeoutInSeconds: number; - waitingQueueMessage: string; - departmentsAllowedToForward: { label: string; value: string }[]; - fallbackForwardDepartment: string; - agentList: IDepartmentAgent[]; - chatClosingTags: string[]; - allowReceiveForwardOffline: boolean; -}; - -function withDefault(key: T | undefined | null, defaultValue: T) { - return key || defaultValue; -} - -const getInitialValues = ({ department, agents, allowedToForwardData }: InitialValueParams) => ({ - name: withDefault(department?.name, ''), - email: withDefault(department?.email, ''), - description: withDefault(department?.description, ''), - enabled: !!department?.enabled, - maxNumberSimultaneousChat: department?.maxNumberSimultaneousChat, - showOnRegistration: !!department?.showOnRegistration, - showOnOfflineForm: !!department?.showOnOfflineForm, - abandonedRoomsCloseCustomMessage: withDefault(department?.abandonedRoomsCloseCustomMessage, ''), - requestTagBeforeClosingChat: !!department?.requestTagBeforeClosingChat, - offlineMessageChannelName: withDefault(department?.offlineMessageChannelName, ''), - visitorInactivityTimeoutInSeconds: department?.visitorInactivityTimeoutInSeconds, - waitingQueueMessage: withDefault(department?.waitingQueueMessage, ''), - departmentsAllowedToForward: allowedToForwardData?.departments?.map((dep) => ({ label: dep.name, value: dep._id })) || [], - fallbackForwardDepartment: withDefault(department?.fallbackForwardDepartment, ''), - chatClosingTags: department?.chatClosingTags ?? [], - agentList: agents || [], - allowReceiveForwardOffline: withDefault(department?.allowReceiveForwardOffline, false), -}); - function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmentProps) { + const dispatchToastMessage = useToastMessageDispatch(); const t = useTranslation(); const router = useRouter(); const queryClient = useQueryClient(); @@ -109,8 +60,9 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen const { department, agents = [] } = data || {}; const hasLicense = useHasLicenseModule('livechat-enterprise'); + const canManageUnits = usePermission('manage-livechat-units'); - const initialValues = getInitialValues({ department, agents, allowedToForwardData }); + const initialValues = getFormInitialValues({ department, agents, allowedToForwardData }); const { register, @@ -118,11 +70,12 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen handleSubmit, watch, formState: { errors, isValid, isDirty, isSubmitting }, - } = useForm({ mode: 'onChange', defaultValues: initialValues }); + } = useForm({ mode: 'onChange', defaultValues: initialValues }); const requestTagBeforeClosingChat = watch('requestTagBeforeClosingChat'); const [fallbackFilter, setFallbackFilter] = useState(''); + const [isUnitRequired, setUnitRequired] = useState(false); const debouncedFallbackFilter = useDebouncedValue(fallbackFilter, 500); @@ -132,75 +85,38 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen const { phase: roomsPhase, items: roomsItems, itemCount: roomsTotal } = useRecordList(RoomsList); - const saveDepartmentInfo = useMethod('livechat:saveDepartment'); + const createDepartment = useEndpoint('POST', '/v1/livechat/department'); + const updateDepartmentInfo = useEndpoint('PUT', '/v1/livechat/department/:_id', { _id: id || '' }); const saveDepartmentAgentsInfoOnEdit = useEndpoint('POST', `/v1/livechat/department/:_id/agents`, { _id: id || '' }); - const dispatchToastMessage = useToastMessageDispatch(); - - const handleSave = useEffectEvent(async (data: FormValues) => { - const { - agentList, - enabled, - name, - description, - showOnRegistration, - showOnOfflineForm, - email, - chatClosingTags, - offlineMessageChannelName, - maxNumberSimultaneousChat, - visitorInactivityTimeoutInSeconds, - abandonedRoomsCloseCustomMessage, - waitingQueueMessage, - departmentsAllowedToForward, - fallbackForwardDepartment, - allowReceiveForwardOffline, - } = data; - - const payload = { - enabled, - name, - description, - showOnRegistration, - showOnOfflineForm, - requestTagBeforeClosingChat, - email, - chatClosingTags, - offlineMessageChannelName, - maxNumberSimultaneousChat, - visitorInactivityTimeoutInSeconds, - abandonedRoomsCloseCustomMessage, - waitingQueueMessage, - departmentsAllowedToForward: departmentsAllowedToForward?.map((dep) => dep.value), - fallbackForwardDepartment, - allowReceiveForwardOffline, - }; - + const handleSave = useEffectEvent(async (data: EditDepartmentFormData) => { try { + const { agentList } = data; + const payload = formatEditDepartmentPayload(data); + const departmentUnit = data.unit ? { _id: data.unit } : undefined; + if (id) { + await updateDepartmentInfo({ + department: payload, + agents: [], + departmentUnit, + }); + const { agentList: initialAgentList } = initialValues; + const agentListPayload = formatAgentListPayload(initialAgentList, agentList); - const agentListPayload = { - upsert: agentList.filter( - (agent) => - !initialAgentList.some( - (initialAgent) => - initialAgent._id === agent._id && agent.count === initialAgent.count && agent.order === initialAgent.order, - ), - ), - remove: initialAgentList.filter((initialAgent) => !agentList.some((agent) => initialAgent._id === agent._id)), - }; - - await saveDepartmentInfo(id, payload, []); if (agentListPayload.upsert.length > 0 || agentListPayload.remove.length > 0) { await saveDepartmentAgentsInfoOnEdit(agentListPayload); } } else { - await saveDepartmentInfo(id ?? null, payload, agentList); + await createDepartment({ + department: payload, + agents: agentList.map(({ agentId, count, order }) => ({ agentId, count, order })), + departmentUnit, + }); } - queryClient.invalidateQueries({ - queryKey: ['/v1/livechat/department/:_id', id], - }); + + queryClient.invalidateQueries({ queryKey: ['/v1/livechat/department/:_id', id] }); dispatchToastMessage({ type: 'success', message: t('Saved') }); router.navigate('/omnichannel/departments'); } catch (error) { @@ -249,6 +165,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen + {t('Name')} @@ -269,6 +186,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen )} + {t('Description')} @@ -280,12 +198,14 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen /> + {t('Show_on_registration_page')} + {t('Email')} @@ -310,12 +230,14 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen )} + {t('Show_on_offline_page')} + {t('Livechat_DepartmentOfflineMessageToChannel')} @@ -342,6 +264,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen /> + {hasLicense && ( <> @@ -357,6 +280,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen )} /> + + + + + {t('Fallback_forward_department')} + + + {t('Unit')} + + ( + { + // NOTE: list.itemCount > 1 to account for the "None" option + setUnitRequired(!canManageUnits && list.itemCount > 1); + }} + /> + )} + /> + + )} + {t('Request_tag_before_closing_chat')} + {requestTagBeforeClosingChat && ( @@ -463,6 +416,7 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen )} )} + {t('Accept_receive_inquiry_no_online_agents')} @@ -475,7 +429,9 @@ function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmen + + {t('Agents')} diff --git a/apps/meteor/client/views/omnichannel/departments/definitions/index.ts b/apps/meteor/client/views/omnichannel/departments/definitions/index.ts new file mode 100644 index 0000000000000..52b85811a9a92 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/departments/definitions/index.ts @@ -0,0 +1,27 @@ +import type { ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; + +export type IDepartmentAgent = Pick & { + _id?: string; + name?: string; +}; + +export type EditDepartmentFormData = { + name: string; + email: string; + description: string; + enabled: boolean; + maxNumberSimultaneousChat: number; + showOnRegistration: boolean; + showOnOfflineForm: boolean; + abandonedRoomsCloseCustomMessage: string; + requestTagBeforeClosingChat: boolean; + offlineMessageChannelName: string; + visitorInactivityTimeoutInSeconds: number; + waitingQueueMessage: string; + departmentsAllowedToForward: { label: string; value: string }[]; + fallbackForwardDepartment: string; + agentList: IDepartmentAgent[]; + chatClosingTags: string[]; + allowReceiveForwardOffline: boolean; + unit?: string; +}; diff --git a/apps/meteor/client/views/omnichannel/departments/utils/formatAgentListPayload.ts b/apps/meteor/client/views/omnichannel/departments/utils/formatAgentListPayload.ts new file mode 100644 index 0000000000000..267f0c68257a6 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/departments/utils/formatAgentListPayload.ts @@ -0,0 +1,22 @@ +import type { IDepartmentAgent } from '../definitions'; + +export const formatAgentListPayload = (oldAgentList: IDepartmentAgent[], newAgentList: IDepartmentAgent[]) => { + const upsert: IDepartmentAgent[] = []; + const remove: IDepartmentAgent[] = []; + + for (const agent of newAgentList) { + const initialAgent = agent._id ? oldAgentList.find((initialAgent) => initialAgent._id === agent._id) : undefined; + + if (!initialAgent || agent.count !== initialAgent.count || agent.order !== initialAgent.order) { + upsert.push(agent); + } + } + + for (const initialAgent of oldAgentList) { + if (!newAgentList.some((agent) => initialAgent._id === agent._id)) { + remove.push(initialAgent); + } + } + + return { upsert, remove }; +}; diff --git a/apps/meteor/client/views/omnichannel/departments/utils/formatEditDepartmentPayload.ts b/apps/meteor/client/views/omnichannel/departments/utils/formatEditDepartmentPayload.ts new file mode 100644 index 0000000000000..be235c63423ae --- /dev/null +++ b/apps/meteor/client/views/omnichannel/departments/utils/formatEditDepartmentPayload.ts @@ -0,0 +1,41 @@ +import type { EditDepartmentFormData } from '../definitions'; + +export const formatEditDepartmentPayload = (data: EditDepartmentFormData) => { + const { + enabled, + name, + description, + showOnRegistration, + showOnOfflineForm, + email, + chatClosingTags, + offlineMessageChannelName, + maxNumberSimultaneousChat, + visitorInactivityTimeoutInSeconds, + abandonedRoomsCloseCustomMessage, + waitingQueueMessage, + departmentsAllowedToForward, + fallbackForwardDepartment, + allowReceiveForwardOffline, + requestTagBeforeClosingChat, + } = data; + + return { + enabled, + name, + description, + showOnRegistration, + showOnOfflineForm, + requestTagBeforeClosingChat, + email, + chatClosingTags, + offlineMessageChannelName, + maxNumberSimultaneousChat, + visitorInactivityTimeoutInSeconds, + abandonedRoomsCloseCustomMessage, + waitingQueueMessage, + departmentsAllowedToForward: departmentsAllowedToForward?.map((dep) => dep.value), + fallbackForwardDepartment, + allowReceiveForwardOffline, + }; +}; diff --git a/apps/meteor/client/views/omnichannel/departments/utils/getFormInititalValues.ts b/apps/meteor/client/views/omnichannel/departments/utils/getFormInititalValues.ts new file mode 100644 index 0000000000000..6225155659441 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/departments/utils/getFormInititalValues.ts @@ -0,0 +1,34 @@ +import type { ILivechatDepartment, Serialized, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; + +import type { EditDepartmentProps } from '../EditDepartment'; + +type InitialValueParams = { + department?: Serialized | null; + agents?: Serialized[]; + allowedToForwardData?: EditDepartmentProps['allowedToForwardData']; +}; + +function withDefault(key: T | undefined | null, defaultValue: T) { + return key || defaultValue; +} + +export const getFormInitialValues = ({ department, agents, allowedToForwardData }: InitialValueParams) => ({ + name: withDefault(department?.name, ''), + email: withDefault(department?.email, ''), + description: withDefault(department?.description, ''), + enabled: !!department?.enabled, + maxNumberSimultaneousChat: department?.maxNumberSimultaneousChat, + showOnRegistration: !!department?.showOnRegistration, + showOnOfflineForm: !!department?.showOnOfflineForm, + abandonedRoomsCloseCustomMessage: withDefault(department?.abandonedRoomsCloseCustomMessage, ''), + requestTagBeforeClosingChat: !!department?.requestTagBeforeClosingChat, + offlineMessageChannelName: withDefault(department?.offlineMessageChannelName, ''), + visitorInactivityTimeoutInSeconds: department?.visitorInactivityTimeoutInSeconds, + waitingQueueMessage: withDefault(department?.waitingQueueMessage, ''), + departmentsAllowedToForward: allowedToForwardData?.departments?.map((dep) => ({ label: dep.name, value: dep._id })) || [], + fallbackForwardDepartment: withDefault(department?.fallbackForwardDepartment, ''), + chatClosingTags: department?.chatClosingTags ?? [], + agentList: agents || [], + allowReceiveForwardOffline: withDefault(department?.allowReceiveForwardOffline, false), + unit: withDefault(department?.ancestors?.[0], ''), // NOTE: A department should only have one ancestor +}); diff --git a/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts new file mode 100644 index 0000000000000..026e04406b5c5 --- /dev/null +++ b/apps/meteor/tests/e2e/omnichannel/omnichannel-monitor-department.spec.ts @@ -0,0 +1,163 @@ +import { faker } from '@faker-js/faker'; +import type { Page } from '@playwright/test'; + +import { IS_EE } from '../config/constants'; +import { Users } from '../fixtures/userStates'; +import { OmnichannelDepartments } from '../page-objects'; +import { createAgent } from '../utils/omnichannel/agents'; +import { createDepartment } from '../utils/omnichannel/departments'; +import { createMonitor } from '../utils/omnichannel/monitors'; +import { createOrUpdateUnit } from '../utils/omnichannel/units'; +import { test, expect } from '../utils/test'; + +const MONITOR = 'user3'; + +test.use({ storageState: Users.user3.state }); + +test.describe.serial('OC - Monitor Role', () => { + test.skip(!IS_EE, 'Enterprise Edition Only'); + + let departments: Awaited>[]; + let agents: Awaited>[]; + let monitor: Awaited>; + let units: Awaited>[]; + let poOmnichannelDepartments: OmnichannelDepartments; + const newDepartmentName = faker.string.uuid(); + + // Reset user3 roles + test.beforeAll(async ({ api }) => { + const res = await api.post('/users.update', { data: { roles: ['user'] }, userId: MONITOR }); + await expect(res.status()).toBe(200); + }); + + // Create agents + test.beforeAll(async ({ api }) => { + agents = await Promise.all([createAgent(api, 'user1'), createAgent(api, 'user2')]); + }); + + // Create department + test.beforeAll(async ({ api }) => { + departments = await Promise.all([createDepartment(api), createDepartment(api)]); + }); + + // Create monitor + test.beforeAll(async ({ api }) => { + monitor = await createMonitor(api, MONITOR); + }); + + // Create unit + test.beforeAll(async ({ api }) => { + const [departmentA, departmentB] = departments.map((dep) => dep.data); + + units = await Promise.all([ + await createOrUpdateUnit(api, { + monitors: [{ monitorId: 'user2', username: 'user2' }], + departments: [{ departmentId: departmentB._id }], + }), + await createOrUpdateUnit(api, { + monitors: [{ monitorId: MONITOR, username: MONITOR }], + departments: [{ departmentId: departmentA._id }], + }), + await createOrUpdateUnit(api, { + monitors: [{ monitorId: MONITOR, username: MONITOR }], + departments: [{ departmentId: departmentA._id }], + }), + ]); + }); + + // Delete all created data + test.afterAll(async () => { + await Promise.all([ + ...agents.map((agent) => agent.delete()), + ...departments.map((department) => department.delete()), + ...units.map((unit) => unit.delete()), + monitor.delete(), + ]); + }); + + test.beforeEach(async ({ page }: { page: Page }) => { + poOmnichannelDepartments = new OmnichannelDepartments(page); + + await page.goto('/omnichannel/departments'); + }); + + test('OC - Monitor Role - Create department with business unit', async () => { + const [departmentA] = departments.map((dep) => dep.data); + const [unitA, unitB, unitC] = units.map((unit) => unit.data); + + await test.step('expect to see only departmentA in the list', async () => { + await expect(poOmnichannelDepartments.findDepartment(departmentA.name)).toBeVisible(); + }); + + await test.step('expect to fill departments mandatory field', async () => { + await poOmnichannelDepartments.headingButtonNew('Create department').click(); + await poOmnichannelDepartments.inputName.fill(newDepartmentName); + await poOmnichannelDepartments.inputEmail.fill(faker.internet.email()); + }); + + await test.step('expect to only have the units from monitor visible', async () => { + await poOmnichannelDepartments.inputUnit.click(); + await expect(poOmnichannelDepartments.findOption(unitA.name)).not.toBeVisible(); + await expect(poOmnichannelDepartments.findOption(unitB.name)).toBeVisible(); + await expect(poOmnichannelDepartments.findOption(unitC.name)).toBeVisible(); + await poOmnichannelDepartments.findOption(unitB.name).click(); + }); + + await test.step('expect to save department', async () => { + await poOmnichannelDepartments.btnEnabled.click(); + await poOmnichannelDepartments.btnSave.click(); + }); + + await test.step('expect to have departmentA and departmentB visible', async () => { + await expect(poOmnichannelDepartments.findDepartment(departmentA.name)).toBeVisible(); + await expect(poOmnichannelDepartments.findDepartment(newDepartmentName)).toBeVisible(); + }); + }); + + test('OC - Monitor Role - Not allow editing department business unit', async () => { + await test.step('expect not to be able to edit unit', async () => { + await poOmnichannelDepartments.search(newDepartmentName); + await poOmnichannelDepartments.selectedDepartmentMenu(newDepartmentName).click(); + await poOmnichannelDepartments.menuEditOption.click(); + await expect(poOmnichannelDepartments.inputUnit).toBeDisabled(); + }); + }); + + // TODO: We are going to prevent editing busines unit for now + test.skip('OC - Monitor Role - Edit department business unit', async () => { + const [departmentA] = departments.map((dep) => dep.data); + const [, , unitC] = units.map((unit) => unit.data); + + await test.step('expect to edit unit', async () => { + await poOmnichannelDepartments.search(newDepartmentName); + await poOmnichannelDepartments.selectedDepartmentMenu(newDepartmentName).click(); + await poOmnichannelDepartments.menuEditOption.click(); + await poOmnichannelDepartments.selectUnit(unitC.name); + await poOmnichannelDepartments.btnEnabled.click(); + await poOmnichannelDepartments.btnSave.click(); + }); + + await test.step('expect departmentB to still be visible', async () => { + await expect(poOmnichannelDepartments.findDepartment(departmentA.name)).toBeVisible(); + await expect(poOmnichannelDepartments.findDepartment(newDepartmentName)).toBeVisible(); + }); + }); + + test.skip('OC - Monitor Role - Edit department and remove business unit', async () => { + const [departmentA] = departments.map((dep) => dep.data); + + await test.step('expect to edit unit', async () => { + await poOmnichannelDepartments.search(newDepartmentName); + await poOmnichannelDepartments.selectedDepartmentMenu(newDepartmentName).click(); + await poOmnichannelDepartments.menuEditOption.click(); + await poOmnichannelDepartments.selectUnit('None'); + await poOmnichannelDepartments.btnEnabled.click(); + await poOmnichannelDepartments.btnSave.click(); + }); + + await test.step('expect departmentB to not be visible', async () => { + await expect(poOmnichannelDepartments.findDepartment(departmentA.name)).toBeVisible(); + await expect(poOmnichannelDepartments.findDepartment(newDepartmentName)).not.toBeVisible(); + }); + }); +}); diff --git a/apps/meteor/tests/e2e/page-objects/omnichannel-departments.ts b/apps/meteor/tests/e2e/page-objects/omnichannel-departments.ts index a41332122a363..9bab98fcfe00a 100644 --- a/apps/meteor/tests/e2e/page-objects/omnichannel-departments.ts +++ b/apps/meteor/tests/e2e/page-objects/omnichannel-departments.ts @@ -86,7 +86,7 @@ export class OmnichannelDepartments { } findDepartment(name: string) { - return this.page.locator('tr', { has: this.page.locator(`td >> text="${name}`) }); + return this.page.locator('tr', { has: this.page.locator(`td >> text="${name}"`) }); } selectedDepartmentMenu(name: string) { @@ -149,6 +149,11 @@ export class OmnichannelDepartments { return this.toastSuccess.locator('button'); } + get inputUnit(): Locator { + // TODO: Improve PaginatedSelectFiltered to allow for more accessible locators + return this.page.locator('[data-qa="autocomplete-unit"] input'); + } + btnTag(tagName: string) { return this.page.locator('button', { hasText: tagName }); } @@ -156,4 +161,13 @@ export class OmnichannelDepartments { errorMessage(message: string): Locator { return this.page.locator(`.rcx-field__error >> text="${message}"`); } + + findOption(optionText: string) { + return this.page.locator(`role=option[name="${optionText}"]`); + } + + async selectUnit(unitName: string) { + await this.inputUnit.click(); + await this.findOption(unitName).click(); + } } diff --git a/packages/core-typings/src/ILivechatDepartment.ts b/packages/core-typings/src/ILivechatDepartment.ts index 0138a88226fb3..3910635960fc7 100644 --- a/packages/core-typings/src/ILivechatDepartment.ts +++ b/packages/core-typings/src/ILivechatDepartment.ts @@ -35,4 +35,7 @@ export type LivechatDepartmentDTO = { fallbackForwardDepartment?: string | undefined; departmentsAllowedToForward?: string[] | undefined; allowReceiveForwardOffline?: boolean; + offlineMessageChannelName?: string | undefined; + abandonedRoomsCloseCustomMessage?: string | undefined; + waitingQueueMessage?: string | undefined; }; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 3fce23220d016..eebaae1d817ec 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -2160,6 +2160,7 @@ "Exit_Full_Screen": "Exit Full Screen", "EmojiCustomFilesystem_Description": "Specify how emojis are stored.", "Empty_no_agent_selected": "Empty, no agent selected", + "Empty_no_unit_selected": "Empty, no unit selected", "Enable_CSP": "Enable Content-Security-Policy", "Enable_CSP_Description": "Do not disable this option unless you have a custom build and are having problems due to inline-scripts", "Export_My_Data": "Export My Data (JSON)", @@ -5989,6 +5990,7 @@ "Uncollapse": "Uncollapse", "Undefined": "Undefined", "Units": "Units", + "Unit": "Unit", "Unit_removed": "Unit Removed", "Unique_ID_change_detected_description": "Information that identifies this workspace has changed. This can happen when the site URL or database connection string are changed or when a new workspace is created from a copy of an existing database.

Would you like to proceed with a configuration update to the existing workspace or create a new workspace and unique ID?", "Unique_ID_change_detected_learn_more_link": "Learn more", diff --git a/packages/model-typings/src/models/ILivechatDepartmentModel.ts b/packages/model-typings/src/models/ILivechatDepartmentModel.ts index 71c1f825cc25e..a38eac14e85e8 100644 --- a/packages/model-typings/src/models/ILivechatDepartmentModel.ts +++ b/packages/model-typings/src/models/ILivechatDepartmentModel.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, LivechatDepartmentDTO } from '@rocket.chat/core-typings'; import type { FindOptions, FindCursor, Filter, UpdateResult, Document } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -31,21 +31,7 @@ export interface ILivechatDepartmentModel extends IBaseModel; removeBusinessHourFromDepartmentsByBusinessHourId(businessHourId: string): Promise; - createOrUpdateDepartment( - _id: string | null, - data: { - enabled: boolean; - name: string; - description?: string; - showOnRegistration: boolean; - email: string; - showOnOfflineForm: boolean; - requestTagBeforeClosingChat?: boolean; - chatClosingTags?: string[]; - fallbackForwardDepartment?: string; - departmentsAllowedToForward?: string[]; - }, - ): Promise; + createOrUpdateDepartment(_id: string | null, data: LivechatDepartmentDTO & { type?: string }): Promise; unsetFallbackDepartmentByDepartmentId(departmentId: string): Promise; removeDepartmentFromForwardListById(_departmentId: string): Promise; diff --git a/packages/models/src/models/LivechatDepartment.ts b/packages/models/src/models/LivechatDepartment.ts index 95630602821d4..b4929890eb239 100644 --- a/packages/models/src/models/LivechatDepartment.ts +++ b/packages/models/src/models/LivechatDepartment.ts @@ -1,4 +1,4 @@ -import type { ILivechatDepartment, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ILivechatDepartment, LivechatDepartmentDTO, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; import type { ILivechatDepartmentModel } from '@rocket.chat/model-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { @@ -229,22 +229,7 @@ export class LivechatDepartmentRaw extends BaseRaw implemen return this.updateOne({ _id }, { $set: { parentId: null, ancestors: null } }); } - async createOrUpdateDepartment( - _id: string | null, - data: { - enabled: boolean; - name: string; - description?: string; - showOnRegistration: boolean; - email: string; - showOnOfflineForm: boolean; - requestTagBeforeClosingChat?: boolean; - chatClosingTags?: string[]; - fallbackForwardDepartment?: string; - departmentsAllowedToForward?: string[]; - type?: string; - }, - ): Promise { + async createOrUpdateDepartment(_id: string | null, data: LivechatDepartmentDTO & { type?: string }): Promise { const current = _id ? await this.findOneById(_id) : null; const record = { diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index d592c27be7542..834dc8efc459d 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -563,17 +563,7 @@ const LivechatDepartmentSchema = { export const isGETLivechatDepartmentProps = ajv.compile(LivechatDepartmentSchema); type POSTLivechatDepartmentProps = { - department: { - enabled: boolean; - name: string; - email: string; - description?: string; - showOnRegistration: boolean; - showOnOfflineForm: boolean; - requestTagsBeforeClosingChat?: boolean; - chatClosingTags?: string[]; - fallbackForwardDepartment?: string; - }; + department: LivechatDepartmentDTO; agents?: { agentId: string; count?: number; order?: number }[]; departmentUnit?: { _id?: string }; }; @@ -618,6 +608,29 @@ const POSTLivechatDepartmentSchema = { email: { type: 'string', }, + departmentsAllowedToForward: { + type: 'array', + items: { + type: 'string', + }, + nullable: true, + }, + allowReceiveForwardOffline: { + type: 'boolean', + nullable: true, + }, + offlineMessageChannelName: { + type: 'string', + nullable: true, + }, + abandonedRoomsCloseCustomMessage: { + type: 'string', + nullable: true, + }, + waitingQueueMessage: { + type: 'string', + nullable: true, + }, }, required: ['name', 'email', 'enabled', 'showOnRegistration', 'showOnOfflineForm'], additionalProperties: true, @@ -3673,9 +3686,13 @@ export type OmnichannelEndpoints = { GET: (params?: LivechatDepartmentProps) => PaginatedResult<{ departments: ILivechatDepartment[]; }>; - POST: (params: { department: Partial; agents: string[] }) => { + POST: (params: { + department: LivechatDepartmentDTO; + agents: Pick[]; + departmentUnit?: { _id?: string }; + }) => { department: ILivechatDepartment; - agents: any[]; + agents: ILivechatDepartmentAgents[]; }; }; '/v1/livechat/department/:_id': { @@ -3686,6 +3703,7 @@ export type OmnichannelEndpoints = { PUT: (params: { department: LivechatDepartmentDTO; agents: Pick[]; + departmentUnit?: { _id?: string }; }) => { department: ILivechatDepartment | null; agents: ILivechatDepartmentAgents[];