refactor: Omnichannel Department re-write (#28948)

pull/29250/head^2
Aleksander Nicacio da Silva 3 years ago committed by GitHub
parent 6e2f78feea
commit 6a474ff952
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      .changeset/mean-keys-rhyme.md
  2. 23
      apps/meteor/client/hooks/useRoomsList.ts
  3. 41
      apps/meteor/client/views/omnichannel/departments/AgentRow.js
  4. 2
      apps/meteor/client/views/omnichannel/departments/ArchivedDepartmentsPageWithData.tsx
  5. 30
      apps/meteor/client/views/omnichannel/departments/Count.js
  6. 29
      apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AddAgent.tsx
  7. 30
      apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AgentAvatar.tsx
  8. 37
      apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/AgentRow.tsx
  9. 43
      apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/DepartmentAgentsTable.tsx
  10. 13
      apps/meteor/client/views/omnichannel/departments/DepartmentAgentsTable/RemoveAgentButton.tsx
  11. 63
      apps/meteor/client/views/omnichannel/departments/DepartmentTags/index.tsx
  12. 56
      apps/meteor/client/views/omnichannel/departments/DepartmentsAgentsTable.js
  13. 2
      apps/meteor/client/views/omnichannel/departments/DepartmentsPageWithData.tsx
  14. 498
      apps/meteor/client/views/omnichannel/departments/EditDepartment.js
  15. 487
      apps/meteor/client/views/omnichannel/departments/EditDepartment.tsx
  16. 36
      apps/meteor/client/views/omnichannel/departments/EditDepartmentWithAllowedForwardData.js
  17. 35
      apps/meteor/client/views/omnichannel/departments/EditDepartmentWithAllowedForwardData.tsx
  18. 39
      apps/meteor/client/views/omnichannel/departments/EditDepartmentWithData.js
  19. 45
      apps/meteor/client/views/omnichannel/departments/EditDepartmentWithData.tsx
  20. 10
      apps/meteor/client/views/omnichannel/departments/NewDepartment.tsx
  21. 30
      apps/meteor/client/views/omnichannel/departments/Order.js
  22. 23
      apps/meteor/ee/client/omnichannel/additionalForms/DepartmentBusinessHours.js
  23. 23
      apps/meteor/ee/client/omnichannel/additionalForms/DepartmentBusinessHours.tsx
  24. 53
      apps/meteor/tests/e2e/omnichannel-departaments.spec.ts
  25. 12
      apps/meteor/tests/e2e/page-objects/omnichannel-departments.ts
  26. 4
      packages/rest-typings/src/v1/omnichannel.ts

@ -0,0 +1,6 @@
---
'@rocket.chat/meteor': minor
'@rocket.chat/rest-typings': patch
---
Refactored Omnichannel department pages to use best practices, also fixed existing bugs

@ -10,16 +10,21 @@ type RoomListOptions = {
text: string;
};
type IRoomClient = Pick<IRoom, '_updatedAt' | '_id'> & {
label: string;
value: string;
};
export const useRoomsList = (
options: RoomListOptions,
): {
itemsList: RecordList<IRoom>;
itemsList: RecordList<IRoomClient>;
initialItemCount: number;
reload: () => void;
loadMoreItems: (start: number, end: number) => void;
} => {
const [itemsList, setItemsList] = useState(() => new RecordList<IRoom>());
const reload = useCallback(() => setItemsList(new RecordList<IRoom>()), []);
const [itemsList, setItemsList] = useState(() => new RecordList<IRoomClient>());
const reload = useCallback(() => setItemsList(new RecordList<IRoomClient>()), []);
const getRooms = useEndpoint('GET', '/v1/rooms.autocomplete.channelAndPrivate.withPagination');
@ -36,12 +41,12 @@ export const useRoomsList = (
sort: JSON.stringify({ name: 1 }),
});
const items = rooms.map((room: any) => {
room._updatedAt = new Date(room._updatedAt);
room.label = room.name;
room.value = room.name;
return room;
});
const items = rooms.map((room: any) => ({
_id: room._id,
_updatedAt: new Date(room._updatedAt),
label: room.name ?? '',
value: room.name ?? '',
}));
return {
items,

@ -1,41 +0,0 @@
import { Box, Table } from '@rocket.chat/fuselage';
import React, { memo } from 'react';
import UserAvatar from '../../../components/avatar/UserAvatar';
import Count from './Count';
import Order from './Order';
import RemoveAgentButton from './RemoveAgentButton';
const AgentRow = ({ agentId, username, name, avatarETag, mediaQuery, agentList, setAgentList, setAgentsRemoved }) => (
<Table.Row key={agentId} tabIndex={0} role='link' action qa-user-id={agentId}>
<Table.Cell withTruncatedText>
<Box display='flex' alignItems='center'>
<UserAvatar size={mediaQuery ? 'x28' : 'x40'} title={username} username={username} etag={avatarETag} />
<Box display='flex' withTruncatedText mi='x8'>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' withTruncatedText color='default'>
{name || username}
</Box>
{!mediaQuery && name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{' '}
{`@${username}`}{' '}
</Box>
)}
</Box>
</Box>
</Box>
</Table.Cell>
<Table.Cell fontScale='p2' color='hint' withTruncatedText>
<Count agentId={agentId} agentList={agentList} setAgentList={setAgentList} />
</Table.Cell>
<Table.Cell fontScale='p2' color='hint' withTruncatedText>
<Order agentId={agentId} agentList={agentList} setAgentList={setAgentList} />
</Table.Cell>
<Table.Cell fontScale='p2' color='hint'>
<RemoveAgentButton agentId={agentId} agentList={agentList} setAgentList={setAgentList} setAgentsRemoved={setAgentsRemoved} />
</Table.Cell>
</Table.Row>
);
export default memo(AgentRow);

@ -13,7 +13,7 @@ import DepartmentsTable from './DepartmentsTable';
const ArchivedDepartmentsPageWithData = (): ReactElement => {
const [text, setText] = useState('');
const [debouncedText = ''] = useDebouncedValue(text, 500);
const debouncedText = useDebouncedValue(text, 500) || '';
const pagination = usePagination();
const sort = useSort<'name' | 'email' | 'active'>('name');

@ -1,30 +0,0 @@
import { Box, NumberInput } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useState } from 'react';
function Count({ agentId, setAgentList, agentList }) {
const t = useTranslation();
const [agentCount, setAgentCount] = useState(agentList.find((agent) => agent.agentId === agentId).count || 0);
const handleCount = useMutableCallback(async (e) => {
const countValue = Number(e.currentTarget.value);
setAgentCount(countValue);
setAgentList(
agentList.map((agent) => {
if (agent.agentId === agentId) {
agent.count = countValue;
}
return agent;
}),
);
});
return (
<Box display='flex'>
<NumberInput flexShrink={1} key={`${agentId}-count`} title={t('Count')} value={agentCount} onChange={handleCount} />
</Box>
);
}
export default Count;

@ -3,13 +3,17 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useState } from 'react';
import AutoCompleteAgent from '../../../components/AutoCompleteAgent';
import { useEndpointAction } from '../../../hooks/useEndpointAction';
import AutoCompleteAgent from '../../../../components/AutoCompleteAgent';
import { useEndpointAction } from '../../../../hooks/useEndpointAction';
import type { IDepartmentAgent } from '../EditDepartment';
function AddAgent({ agentList, setAgentsAdded, setAgentList, ...props }) {
function AddAgent({ agentList, onAdd }: { agentList: IDepartmentAgent[]; onAdd: (agent: IDepartmentAgent) => void }) {
const t = useTranslation();
const [userId, setUserId] = useState();
const [userId, setUserId] = useState('');
const getAgent = useEndpointAction('GET', '/v1/livechat/users/agent/:_id', { keys: { _id: userId } });
const dispatchToastMessage = useToastMessageDispatch();
const handleAgent = useMutableCallback((e) => setUserId(e));
@ -18,19 +22,22 @@ function AddAgent({ agentList, setAgentsAdded, setAgentList, ...props }) {
if (!userId) {
return;
}
const { user } = await getAgent();
if (agentList.filter((e) => e.agentId === user._id).length === 0) {
setAgentList([{ ...user, agentId: user._id }, ...agentList]);
setUserId();
setAgentsAdded((agents) => [...agents, { agentId: user._id }]);
const {
user: { _id, username, name },
} = await getAgent();
if (!agentList.some(({ agentId }) => agentId === _id)) {
setUserId('');
onAdd({ agentId: _id, username: username ?? '', name, count: 0, order: 0 });
} else {
dispatchToastMessage({ type: 'error', message: t('This_agent_was_already_selected') });
}
});
return (
<Box display='flex' alignItems='center' {...props}>
<AutoCompleteAgent empty value={userId} onChange={handleAgent} />
<Box display='flex' alignItems='center'>
<AutoCompleteAgent value={userId} onChange={handleAgent} />
<Button disabled={!userId} onClick={handleSave} mis='x8' primary>
{t('Add')}
</Button>

@ -0,0 +1,30 @@
import { Box } from '@rocket.chat/fuselage';
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import React, { memo } from 'react';
import UserAvatar from '../../../../components/avatar/UserAvatar';
const AgentAvatar = ({ name, username, eTag }: { name: string; username: string; eTag?: string }) => {
const mediaQuery = useMediaQuery('(min-width: 1024px)');
return (
<Box display='flex' alignItems='center'>
<UserAvatar size={mediaQuery ? 'x28' : 'x40'} title={username} username={username} etag={eTag} />
<Box display='flex' withTruncatedText mi='x8'>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' withTruncatedText color='default'>
{name || username}
</Box>
{!mediaQuery && name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{' '}
{`@${username}`}{' '}
</Box>
)}
</Box>
</Box>
</Box>
);
};
export default memo(AgentAvatar);

@ -0,0 +1,37 @@
import { NumberInput, TableCell, TableRow } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { memo } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import type { FormValues, IDepartmentAgent } from '../EditDepartment';
import AgentAvatar from './AgentAvatar';
import RemoveAgentButton from './RemoveAgentButton';
type AgentRowProps = {
agent: IDepartmentAgent;
index: number;
register: UseFormRegister<FormValues>;
onRemove: (agentId: string) => void;
};
const AgentRow = ({ index, agent, register, onRemove }: AgentRowProps) => {
const t = useTranslation();
return (
<TableRow key={agent.agentId} tabIndex={0} role='link' action qa-user-id={agent.agentId}>
<TableCell withTruncatedText>
<AgentAvatar name={agent.name || ''} username={agent.username || ''} />
</TableCell>
<TableCell fontScale='p2' color='hint' withTruncatedText>
<NumberInput title={t('Count')} maxWidth='100%' {...register(`agentList.${index}.count`, { valueAsNumber: true })} />
</TableCell>
<TableCell fontScale='p2' color='hint' withTruncatedText>
<NumberInput title={t('Order')} maxWidth='100%' {...register(`agentList.${index}.order`, { valueAsNumber: true })} />
</TableCell>
<TableCell fontScale='p2' color='hint'>
<RemoveAgentButton agentId={agent.agentId} onRemove={onRemove} />
</TableCell>
</TableRow>
);
};
export default memo(AgentRow);

@ -0,0 +1,43 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import type { Control, UseFormRegister } from 'react-hook-form';
import { useWatch, useFieldArray } from 'react-hook-form';
import { GenericTable, GenericTableBody, GenericTableHeader, GenericTableHeaderCell } from '../../../../components/GenericTable';
import type { FormValues } from '../EditDepartment';
import AddAgent from './AddAgent';
import AgentRow from './AgentRow';
type DepartmentAgentsTableProps = {
control: Control<FormValues>;
register: UseFormRegister<FormValues>;
};
function DepartmentAgentsTable({ control, register }: DepartmentAgentsTableProps) {
const t = useTranslation();
const { fields, append, remove } = useFieldArray({ control, name: 'agentList' });
const agentList = useWatch({ control, name: 'agentList' });
return (
<>
<AddAgent agentList={agentList} data-qa='DepartmentSelect-AgentsTable' onAdd={append} />
<GenericTable>
<GenericTableHeader>
<GenericTableHeaderCell w='x200'>{t('Name')}</GenericTableHeaderCell>
<GenericTableHeaderCell w='x140'>{t('Count')}</GenericTableHeaderCell>
<GenericTableHeaderCell w='x120'>{t('Order')}</GenericTableHeaderCell>
<GenericTableHeaderCell w='x40'>{t('Remove')}</GenericTableHeaderCell>
</GenericTableHeader>
<GenericTableBody>
{fields.map((agent, index) => (
<AgentRow key={agent.id} index={index} agent={agent} register={register} onRemove={() => remove(index)} />
))}
</GenericTableBody>
</GenericTable>
</>
);
}
export default DepartmentAgentsTable;

@ -3,24 +3,23 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import GenericModal from '../../../components/GenericModal';
import GenericModal from '../../../../components/GenericModal';
function RemoveAgentButton({ agentId, setAgentList, agentList, setAgentsRemoved }) {
function RemoveAgentButton({ agentId, onRemove }: { agentId: string; onRemove: (agentId: string) => void }) {
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const t = useTranslation();
const handleDelete = useMutableCallback((e) => {
e.stopPropagation();
const onDeleteAgent = async () => {
const newList = agentList.filter((listItem) => listItem.agentId !== agentId);
setAgentList(newList);
const onRemoveAgent = async () => {
onRemove(agentId);
dispatchToastMessage({ type: 'success', message: t('Agent_removed') });
setModal();
setAgentsRemoved((agents) => [...agents, { agentId }]);
};
setModal(<GenericModal variant='danger' onConfirm={onDeleteAgent} onCancel={() => setModal()} confirmText={t('Delete')} />);
setModal(<GenericModal variant='danger' onConfirm={onRemoveAgent} onCancel={() => setModal()} confirmText={t('Delete')} />);
});
return <IconButton icon='trash' mini title={t('Remove')} onClick={handleDelete} />;

@ -0,0 +1,63 @@
import { Button, Chip, Field, TextInput } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import type { FormEvent } from 'react';
import React, { useCallback, useState } from 'react';
type DepartmentTagsProps = {
error: string;
value: string[];
onChange: (tags: string[]) => void;
};
export const DepartmentTags = ({ error, value: tags, onChange }: DepartmentTagsProps) => {
const t = useTranslation();
const [tagText, setTagText] = useState('');
const handleAddTag = useCallback(() => {
if (tags.includes(tagText)) {
return;
}
setTagText('');
onChange([...tags, tagText]);
}, [onChange, tagText, tags]);
const handleTagChipClick = (tag: string) => () => {
onChange(tags.filter((_tag) => _tag !== tag));
};
return (
<>
<Field.Row>
<TextInput
data-qa='DepartmentEditTextInput-ConversationClosingTags'
error={error}
placeholder={t('Enter_a_tag')}
value={tagText}
onChange={(e: FormEvent<HTMLInputElement>) => setTagText(e.currentTarget.value)}
/>
<Button
disabled={Boolean(!tagText.trim()) || tags.includes(tagText)}
data-qa='DepartmentEditAddButton-ConversationClosingTags'
mis='x8'
title={t('Add')}
onClick={handleAddTag}
>
{t('Add')}
</Button>
</Field.Row>
<Field.Hint>{t('Conversation_closing_tags_description')}</Field.Hint>
{tags?.length > 0 && (
<Field.Row justifyContent='flex-start'>
{tags.map((tag, i) => (
<Chip key={i} onClick={handleTagChipClick(tag)} mie='x8'>
{tag}
</Chip>
))}
</Field.Row>
)}
</>
);
};

@ -1,56 +0,0 @@
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useState, useEffect } from 'react';
import GenericTable from '../../../components/GenericTable';
import AddAgent from './AddAgent';
import AgentRow from './AgentRow';
function DepartmentsAgentsTable({ agents, setAgentListFinal, setAgentsAdded, setAgentsRemoved }) {
const t = useTranslation();
const [agentList, setAgentList] = useState((agents && JSON.parse(JSON.stringify(agents))) || []);
useEffect(() => setAgentListFinal(agentList), [agentList, setAgentListFinal]);
const mediaQuery = useMediaQuery('(min-width: 1024px)');
return (
<>
<AddAgent agentList={agentList} data-qa='DepartmentSelect-AgentsTable' setAgentList={setAgentList} setAgentsAdded={setAgentsAdded} />
<GenericTable
header={
<>
<GenericTable.HeaderCell key={'name'} w='x200'>
{t('Name')}
</GenericTable.HeaderCell>
<GenericTable.HeaderCell key={'Count'} w='x140'>
{t('Count')}
</GenericTable.HeaderCell>
<GenericTable.HeaderCell key={'Order'} w='x120'>
{t('Order')}
</GenericTable.HeaderCell>
<GenericTable.HeaderCell key={'remove'} w='x40'>
{t('Remove')}
</GenericTable.HeaderCell>
</>
}
results={agentList}
total={agentList?.length}
pi='x24'
>
{(props) => (
<AgentRow
key={props._id}
mediaQuery={mediaQuery}
agentList={agentList}
setAgentList={setAgentList}
setAgentsRemoved={setAgentsRemoved}
{...props}
/>
)}
</GenericTable>
</>
);
}
export default DepartmentsAgentsTable;

@ -13,7 +13,7 @@ import DepartmentsTable from './DepartmentsTable';
const DepartmentsPageWithData = (): ReactElement => {
const [text, setText] = useState('');
const [debouncedText = ''] = useDebouncedValue(text, 500);
const debouncedText = useDebouncedValue(text, 500) || '';
const pagination = usePagination();
const sort = useSort<'name' | 'email' | 'active'>('name');

@ -1,498 +0,0 @@
import {
FieldGroup,
Field,
TextInput,
Chip,
Box,
Icon,
Divider,
ToggleSwitch,
TextAreaInput,
ButtonGroup,
Button,
PaginatedSelectFiltered,
} from '@rocket.chat/fuselage';
import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo, useState, useRef, useCallback } from 'react';
import { validateEmail } from '../../../../lib/emailValidator';
import Page from '../../../components/Page';
import { useRecordList } from '../../../hooks/lists/useRecordList';
import { useComponentDidUpdate } from '../../../hooks/useComponentDidUpdate';
import { useForm } from '../../../hooks/useForm';
import { useRoomsList } from '../../../hooks/useRoomsList';
import { AsyncStatePhase } from '../../../lib/asyncState';
import { useFormsSubscription } from '../additionalForms';
import DepartmentsAgentsTable from './DepartmentsAgentsTable';
function withDefault(key, defaultValue) {
return key || defaultValue;
}
function EditDepartment({ data, id, title, allowedToForwardData }) {
const t = useTranslation();
const departmentsRoute = useRoute('omnichannel-departments');
const {
useEeNumberInput = () => {},
useEeTextInput = () => {},
useEeTextAreaInput = () => {},
useDepartmentForwarding = () => {},
useDepartmentBusinessHours = () => {},
useSelectForwardDepartment = () => {},
} = useFormsSubscription();
const { agents } = data || { agents: [] };
const initialAgents = useRef(agents);
const MaxChats = useEeNumberInput();
const VisitorInactivity = useEeNumberInput();
const WaitingQueueMessageInput = useEeTextAreaInput();
const AbandonedMessageInput = useEeTextInput();
const DepartmentForwarding = useDepartmentForwarding();
const DepartmentBusinessHours = useDepartmentBusinessHours();
const AutoCompleteDepartment = useSelectForwardDepartment();
const [agentList, setAgentList] = useState([]);
const [agentsRemoved, setAgentsRemoved] = useState([]);
const [agentsAdded, setAgentsAdded] = useState([]);
const { department } = data || { department: {} };
const [initialTags] = useState(() => department?.chatClosingTags ?? []);
const [[tags, tagsText], setTagsState] = useState(() => [initialTags, '']);
const hasTagChanges = useMemo(() => tags.toString() !== initialTags.toString(), [tags, initialTags]);
const { values, handlers, hasUnsavedChanges } = useForm({
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, ''),
});
const {
handleName,
handleEmail,
handleDescription,
handleEnabled,
handleMaxNumberSimultaneousChat,
handleShowOnRegistration,
handleShowOnOfflineForm,
handleAbandonedRoomsCloseCustomMessage,
handleRequestTagBeforeClosingChat,
handleOfflineMessageChannelName,
handleVisitorInactivityTimeoutInSeconds,
handleWaitingQueueMessage,
handleDepartmentsAllowedToForward,
handleFallbackForwardDepartment,
} = handlers;
const {
name,
email,
description,
enabled,
maxNumberSimultaneousChat,
showOnRegistration,
showOnOfflineForm,
abandonedRoomsCloseCustomMessage,
requestTagBeforeClosingChat,
offlineMessageChannelName,
visitorInactivityTimeoutInSeconds,
waitingQueueMessage,
departmentsAllowedToForward,
fallbackForwardDepartment,
} = values;
const { itemsList: RoomsList, loadMoreItems: loadMoreRooms } = useRoomsList(
useMemo(() => ({ text: offlineMessageChannelName }), [offlineMessageChannelName]),
);
const { phase: roomsPhase, items: roomsItems, itemCount: roomsTotal } = useRecordList(RoomsList);
const handleTagChipClick = (tag) => () => {
setTagsState(([tags, tagsText]) => [tags.filter((_tag) => _tag !== tag), tagsText]);
};
const handleTagTextSubmit = useCallback(() => {
setTagsState((state) => {
const [tags, tagsText] = state;
if (tags.includes(tagsText)) {
return state;
}
return [[...tags, tagsText], ''];
});
}, []);
const handleTagTextChange = (e) => {
setTagsState(([tags]) => [tags, e.target.value]);
};
const saveDepartmentInfo = useMethod('livechat:saveDepartment');
const saveDepartmentAgentsInfoOnEdit = useEndpoint('POST', `/v1/livechat/department/${id}/agents`);
const dispatchToastMessage = useToastMessageDispatch();
const [nameError, setNameError] = useState();
const [emailError, setEmailError] = useState();
const [tagError, setTagError] = useState();
useComponentDidUpdate(() => {
setNameError(!name ? t('The_field_is_required', 'name') : '');
}, [t, name]);
useComponentDidUpdate(() => {
setEmailError(!email ? t('The_field_is_required', 'email') : '');
}, [t, email]);
useComponentDidUpdate(() => {
setEmailError(!validateEmail(email) ? t('Validate_email_address') : '');
}, [t, email]);
useComponentDidUpdate(() => {
setTagError(requestTagBeforeClosingChat && (!tags || tags.length === 0) ? t('The_field_is_required', 'name') : '');
}, [requestTagBeforeClosingChat, t, tags]);
const handleSubmit = useMutableCallback(async (e) => {
e.preventDefault();
let error = false;
if (!name) {
setNameError(t('The_field_is_required', 'name'));
error = true;
}
if (!email) {
setEmailError(t('The_field_is_required', 'email'));
error = true;
}
if (!validateEmail(email)) {
setEmailError(t('Validate_email_address'));
error = true;
}
if (requestTagBeforeClosingChat && (!tags || tags.length === 0)) {
setTagError(t('The_field_is_required', 'tags'));
error = true;
}
if (error) {
return;
}
const payload = {
enabled,
name,
description,
showOnRegistration,
showOnOfflineForm,
requestTagBeforeClosingChat,
email,
chatClosingTags: tags,
offlineMessageChannelName,
maxNumberSimultaneousChat,
visitorInactivityTimeoutInSeconds,
abandonedRoomsCloseCustomMessage,
waitingQueueMessage,
departmentsAllowedToForward: departmentsAllowedToForward?.map((dep) => dep.value),
fallbackForwardDepartment,
};
const agentListPayload = {
upsert: agentList.filter(
(agent) =>
!initialAgents.current.some(
(initialAgent) => initialAgent._id === agent._id && agent.count === initialAgent.count && agent.order === initialAgent.order,
),
),
remove: initialAgents.current.filter((initialAgent) => !agentList.some((agent) => initialAgent._id === agent._id)),
};
try {
if (id) {
await saveDepartmentInfo(id, payload, []);
if (agentListPayload.upsert.length > 0 || agentListPayload.remove.length > 0) {
await saveDepartmentAgentsInfoOnEdit(agentListPayload);
}
} else {
await saveDepartmentInfo(id, payload, agentList);
}
dispatchToastMessage({ type: 'success', message: t('Saved') });
departmentsRoute.push({});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const handleReturn = useMutableCallback(() => {
departmentsRoute.push({});
});
const invalidForm =
!name ||
!email ||
!validateEmail(email) ||
!(hasUnsavedChanges || hasTagChanges) ||
(requestTagBeforeClosingChat && (!tags || tags.length === 0));
const formId = useUniqueId();
const hasNewAgent = useMemo(() => agents.length === agentList.length, [agents, agentList]);
const agentsHaveChanged = () => {
let hasChanges = false;
if (agentList.length !== initialAgents.current.length) {
hasChanges = true;
}
if (agentsAdded.length > 0 && agentsRemoved.length > 0) {
hasChanges = true;
}
agentList.forEach((agent) => {
const existingAgent = initialAgents.current.find((initial) => initial.agentId === agent.agentId);
if (existingAgent) {
if (agent.count !== existingAgent.count) {
hasChanges = true;
}
if (agent.order !== existingAgent.order) {
hasChanges = true;
}
}
});
return hasChanges;
};
return (
<Page flexDirection='row'>
<Page>
<Page.Header title={title}>
<ButtonGroup>
<Button onClick={handleReturn}>
<Icon name='back' /> {t('Back')}
</Button>
<Button type='submit' form={formId} primary disabled={invalidForm && hasNewAgent && !(id && agentsHaveChanged())}>
{t('Save')}
</Button>
</ButtonGroup>
</Page.Header>
<Page.ScrollableContentWithShadow>
<FieldGroup w='full' alignSelf='center' maxWidth='x600' id={formId} is='form' autoComplete='off' onSubmit={handleSubmit}>
<Field>
<Box display='flex' data-qa='DepartmentEditToggle-Enabled' flexDirection='row'>
<Field.Label>{t('Enabled')}</Field.Label>
<Field.Row>
<ToggleSwitch flexGrow={1} checked={enabled} onChange={handleEnabled} />
</Field.Row>
</Box>
</Field>
<Field>
<Field.Label>{t('Name')}*</Field.Label>
<Field.Row>
<TextInput
data-qa='DepartmentEditTextInput-Name'
flexGrow={1}
error={nameError}
value={name}
onChange={handleName}
placeholder={t('Name')}
/>
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Description')}</Field.Label>
<Field.Row>
<TextAreaInput
data-qa='DepartmentEditTextInput-Description'
flexGrow={1}
value={description}
onChange={handleDescription}
placeholder={t('Description')}
/>
</Field.Row>
</Field>
<Field>
<Box data-qa='DepartmentEditToggle-ShowOnRegistrationPage' display='flex' flexDirection='row'>
<Field.Label>{t('Show_on_registration_page')}</Field.Label>
<Field.Row>
<ToggleSwitch flexGrow={1} checked={showOnRegistration} onChange={handleShowOnRegistration} />
</Field.Row>
</Box>
</Field>
<Field>
<Field.Label>{t('Email')}*</Field.Label>
<Field.Row>
<TextInput
data-qa='DepartmentEditTextInput-Email'
flexGrow={1}
error={emailError}
value={email}
addon={<Icon name='mail' size='x20' />}
onChange={handleEmail}
placeholder={t('Email')}
/>
</Field.Row>
</Field>
<Field>
<Box display='flex' data-qa='DepartmentEditToggle-ShowOnOfflinePage' flexDirection='row'>
<Field.Label>{t('Show_on_offline_page')}</Field.Label>
<Field.Row>
<ToggleSwitch flexGrow={1} checked={showOnOfflineForm} onChange={handleShowOnOfflineForm} />
</Field.Row>
</Box>
</Field>
<Field>
<Field.Label>{t('Livechat_DepartmentOfflineMessageToChannel')}</Field.Label>
<Field.Row>
<PaginatedSelectFiltered
data-qa='DepartmentSelect-LivechatDepartmentOfflineMessageToChannel'
value={offlineMessageChannelName}
onChange={handleOfflineMessageChannelName}
flexShrink={0}
filter={offlineMessageChannelName}
setFilter={handleOfflineMessageChannelName}
options={roomsItems}
placeholder={t('Channel_name')}
endReached={roomsPhase === AsyncStatePhase.LOADING ? () => {} : (start) => loadMoreRooms(start, Math.min(50, roomsTotal))}
/>
</Field.Row>
</Field>
{MaxChats && (
<Field>
<MaxChats
value={maxNumberSimultaneousChat}
handler={handleMaxNumberSimultaneousChat}
label={'Max_number_of_chats_per_agent'}
placeholder='Max_number_of_chats_per_agent_description'
/>
</Field>
)}
{VisitorInactivity && (
<Field>
<VisitorInactivity
value={visitorInactivityTimeoutInSeconds}
handler={handleVisitorInactivityTimeoutInSeconds}
label={'How_long_to_wait_to_consider_visitor_abandonment_in_seconds'}
placeholder='Number_in_seconds'
/>
</Field>
)}
{AbandonedMessageInput && (
<Field>
<AbandonedMessageInput
value={abandonedRoomsCloseCustomMessage}
handler={handleAbandonedRoomsCloseCustomMessage}
label={'Livechat_abandoned_rooms_closed_custom_message'}
placeholder='Enter_a_custom_message'
/>
</Field>
)}
{WaitingQueueMessageInput && (
<Field>
<WaitingQueueMessageInput value={waitingQueueMessage} handler={handleWaitingQueueMessage} label={'Waiting_queue_message'} />
</Field>
)}
{DepartmentForwarding && (
<Field>
<DepartmentForwarding
departmentId={id}
value={departmentsAllowedToForward}
handler={handleDepartmentsAllowedToForward}
label={'List_of_departments_for_forward'}
placeholder='Enter_a_department_name'
/>
</Field>
)}
{AutoCompleteDepartment && (
<Field>
<Field.Label>{t('Fallback_forward_department')}</Field.Label>
<AutoCompleteDepartment
haveNone
excludeDepartmentId={department?._id}
value={fallbackForwardDepartment}
onChange={handleFallbackForwardDepartment}
placeholder={t('Fallback_forward_department')}
label={t('Fallback_forward_department')}
onlyMyDepartments
showArchived
/>
</Field>
)}
<Field>
<Box display='flex' data-qa='DiscussionToggle-RequestTagBeforeCLosingChat' flexDirection='row'>
<Field.Label>{t('Request_tag_before_closing_chat')}</Field.Label>
<Field.Row>
<ToggleSwitch
data-qa='DiscussionToggle-RequestTagBeforeCLosingChat'
flexGrow={1}
checked={requestTagBeforeClosingChat}
onChange={handleRequestTagBeforeClosingChat}
/>
</Field.Row>
</Box>
</Field>
{requestTagBeforeClosingChat && (
<Field>
<Field.Label alignSelf='stretch'>{t('Conversation_closing_tags')}*</Field.Label>
<Field.Row>
<TextInput
data-qa='DepartmentEditTextInput-ConversationClosingTags'
error={tagError}
value={tagsText}
onChange={handleTagTextChange}
placeholder={t('Enter_a_tag')}
/>
<Button
disabled={Boolean(!tagsText.trim()) || tags.includes(tagsText)}
data-qa='DepartmentEditAddButton-ConversationClosingTags'
mis='x8'
title={t('add')}
onClick={handleTagTextSubmit}
>
{t('Add')}
</Button>
</Field.Row>
<Field.Hint>{t('Conversation_closing_tags_description')}</Field.Hint>
{tags?.length > 0 && (
<Field.Row justifyContent='flex-start'>
{tags.map((tag, i) => (
<Chip key={i} onClick={handleTagChipClick(tag)} mie='x8'>
{tag}
</Chip>
))}
</Field.Row>
)}
</Field>
)}
{DepartmentBusinessHours && (
<Field>
<DepartmentBusinessHours bhId={department?.businessHourId} />
</Field>
)}
<Divider mb='x16' />
<Field>
<Field.Label mb='x4'>{t('Agents')}:</Field.Label>
<Box display='flex' flexDirection='column' height='50vh'>
<DepartmentsAgentsTable
agents={agents}
setAgentListFinal={setAgentList}
setAgentsAdded={setAgentsAdded}
setAgentsRemoved={setAgentsRemoved}
/>
</Box>
</Field>
</FieldGroup>
</Page.ScrollableContentWithShadow>
</Page>
</Page>
);
}
export default EditDepartment;

@ -0,0 +1,487 @@
import type { ILivechatDepartment, ILivechatDepartmentAgents, Serialized } from '@rocket.chat/core-typings';
import {
FieldGroup,
Field,
TextInput,
Box,
Icon,
Divider,
ToggleSwitch,
TextAreaInput,
ButtonGroup,
Button,
PaginatedSelectFiltered,
} from '@rocket.chat/fuselage';
import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useRoute, useMethod, useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { validateEmail } from '../../../../lib/emailValidator';
import Page from '../../../components/Page';
import { useRecordList } from '../../../hooks/lists/useRecordList';
import { useRoomsList } from '../../../hooks/useRoomsList';
import { AsyncStatePhase } from '../../../lib/asyncState';
import { useFormsSubscription } from '../additionalForms';
import DepartmentsAgentsTable from './DepartmentAgentsTable/DepartmentAgentsTable';
import { DepartmentTags } from './DepartmentTags';
export type EditDepartmentProps = {
id?: string;
title: string;
data?: Serialized<{
department: ILivechatDepartment | null;
agents?: ILivechatDepartmentAgents[];
}>;
allowedToForwardData?: Serialized<{
departments: ILivechatDepartment[];
}>;
};
type InitialValueParams = {
department?: Serialized<ILivechatDepartment> | null;
agents?: Serialized<ILivechatDepartmentAgents>[];
allowedToForwardData?: EditDepartmentProps['allowedToForwardData'];
};
export type IDepartmentAgent = Pick<ILivechatDepartmentAgents, 'agentId' | 'username' | 'count' | 'order'> & {
_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[];
};
function withDefault<T>(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 || [],
});
function EditDepartment({ data, id, title, allowedToForwardData }: EditDepartmentProps) {
const t = useTranslation();
const departmentsRoute = useRoute('omnichannel-departments');
const {
useEeNumberInput = () => null,
useEeTextInput = () => null,
useEeTextAreaInput = () => null,
useDepartmentForwarding = () => null,
useDepartmentBusinessHours = () => null,
useSelectForwardDepartment = () => null,
} = useFormsSubscription();
const { department, agents = [] } = data || {};
const MaxChats = useEeNumberInput();
const VisitorInactivity = useEeNumberInput();
const WaitingQueueMessageInput = useEeTextAreaInput();
const AbandonedMessageInput = useEeTextInput();
const DepartmentForwarding = useDepartmentForwarding();
const DepartmentBusinessHours = useDepartmentBusinessHours();
const AutoCompleteDepartment = useSelectForwardDepartment();
const initialValues = getInitialValues({ department, agents, allowedToForwardData });
const {
register,
control,
handleSubmit,
watch,
formState: { errors, isValid, isDirty },
} = useForm<FormValues>({ mode: 'onChange', defaultValues: initialValues });
const requestTagBeforeClosingChat = watch('requestTagBeforeClosingChat');
const offlineMessageChannelName = watch('offlineMessageChannelName');
const { itemsList: RoomsList, loadMoreItems: loadMoreRooms } = useRoomsList(
useMemo(() => ({ text: offlineMessageChannelName }), [offlineMessageChannelName]),
);
const { phase: roomsPhase, items: roomsItems, itemCount: roomsTotal } = useRecordList(RoomsList);
const saveDepartmentInfo = useMethod('livechat:saveDepartment');
const saveDepartmentAgentsInfoOnEdit = useEndpoint('POST', `/v1/livechat/department/:_id/agents`, { _id: id || '' });
const dispatchToastMessage = useToastMessageDispatch();
const handleSave = useMutableCallback(async (data: FormValues) => {
const {
agentList,
enabled,
name,
description,
showOnRegistration,
showOnOfflineForm,
email,
chatClosingTags,
offlineMessageChannelName,
maxNumberSimultaneousChat,
visitorInactivityTimeoutInSeconds,
abandonedRoomsCloseCustomMessage,
waitingQueueMessage,
departmentsAllowedToForward,
fallbackForwardDepartment,
} = data;
const payload = {
enabled,
name,
description,
showOnRegistration,
showOnOfflineForm,
requestTagBeforeClosingChat,
email,
chatClosingTags,
offlineMessageChannelName,
maxNumberSimultaneousChat,
visitorInactivityTimeoutInSeconds,
abandonedRoomsCloseCustomMessage,
waitingQueueMessage,
departmentsAllowedToForward: departmentsAllowedToForward?.map((dep) => dep.value),
fallbackForwardDepartment,
};
try {
if (id) {
const { agentList: initialAgentList } = initialValues;
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);
}
dispatchToastMessage({ type: 'success', message: t('Saved') });
departmentsRoute.push({});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const handleReturn = useMutableCallback(() => {
departmentsRoute.push({});
});
const isFormValid = isValid && isDirty;
const formId = useUniqueId();
return (
<Page flexDirection='row'>
<Page>
<Page.Header title={title}>
<ButtonGroup>
<Button onClick={handleReturn}>
<Icon name='back' /> {t('Back')}
</Button>
<Button type='submit' form={formId} primary disabled={!isFormValid}>
{t('Save')}
</Button>
</ButtonGroup>
</Page.Header>
<Page.ScrollableContentWithShadow>
<FieldGroup
w='full'
alignSelf='center'
maxWidth='x600'
id={formId}
is='form'
autoComplete='off'
onSubmit={handleSubmit(handleSave)}
>
<Field>
<Box display='flex' data-qa='DepartmentEditToggle-Enabled' flexDirection='row'>
<Field.Label>{t('Enabled')}</Field.Label>
<Field.Row>
<ToggleSwitch flexGrow={1} {...register('enabled')} />
</Field.Row>
</Box>
</Field>
<Field>
<Field.Label>{t('Name')}*</Field.Label>
<Field.Row>
<TextInput
data-qa='DepartmentEditTextInput-Name'
flexGrow={1}
error={errors.name?.message as string}
placeholder={t('Name')}
{...register('name', { required: t('The_field_is_required', 'name') })}
/>
</Field.Row>
{errors.name && <Field.Error>{errors.name?.message}</Field.Error>}
</Field>
<Field>
<Field.Label>{t('Description')}</Field.Label>
<Field.Row>
<TextAreaInput
data-qa='DepartmentEditTextInput-Description'
flexGrow={1}
placeholder={t('Description')}
{...register('description')}
/>
</Field.Row>
</Field>
<Field>
<Box data-qa='DepartmentEditToggle-ShowOnRegistrationPage' display='flex' flexDirection='row'>
<Field.Label>{t('Show_on_registration_page')}</Field.Label>
<Field.Row>
<ToggleSwitch flexGrow={1} {...register('showOnRegistration')} />
</Field.Row>
</Box>
</Field>
<Field>
<Field.Label>{t('Email')}*</Field.Label>
<Field.Row>
<TextInput
data-qa='DepartmentEditTextInput-Email'
flexGrow={1}
error={errors.email?.message as string}
addon={<Icon name='mail' size='x20' />}
placeholder={t('Email')}
{...register('email', {
required: t('The_field_is_required', 'email'),
validate: (email) => validateEmail(email) || t('error-invalid-email-address'),
})}
/>
</Field.Row>
{errors.email && <Field.Error>{errors.email?.message}</Field.Error>}
</Field>
<Field>
<Box display='flex' data-qa='DepartmentEditToggle-ShowOnOfflinePage' flexDirection='row'>
<Field.Label>{t('Show_on_offline_page')}</Field.Label>
<Field.Row>
<ToggleSwitch flexGrow={1} {...register('showOnOfflineForm')} />
</Field.Row>
</Box>
</Field>
<Field>
<Field.Label>{t('Livechat_DepartmentOfflineMessageToChannel')}</Field.Label>
<Field.Row>
<Controller
control={control}
name='offlineMessageChannelName'
render={({ field: { value, onChange } }) => (
<PaginatedSelectFiltered
data-qa='DepartmentSelect-LivechatDepartmentOfflineMessageToChannel'
value={value}
onChange={onChange}
flexShrink={0}
filter={value}
setFilter={onChange}
options={roomsItems}
placeholder={t('Channel_name')}
endReached={
roomsPhase === AsyncStatePhase.LOADING ? () => undefined : (start) => loadMoreRooms(start, Math.min(50, roomsTotal))
}
/>
)}
/>
</Field.Row>
</Field>
{MaxChats && (
<Field>
<Controller
control={control}
name='maxNumberSimultaneousChat'
render={({ field: { value, onChange } }) => (
<MaxChats
value={value}
handler={onChange}
label={'Max_number_of_chats_per_agent'}
placeholder='Max_number_of_chats_per_agent_description'
/>
)}
/>
</Field>
)}
{VisitorInactivity && (
<Field>
<Controller
control={control}
name='visitorInactivityTimeoutInSeconds'
render={({ field: { value, onChange } }) => (
<VisitorInactivity
value={value}
handler={onChange}
label={'How_long_to_wait_to_consider_visitor_abandonment_in_seconds'}
placeholder='Number_in_seconds'
/>
)}
/>
</Field>
)}
{AbandonedMessageInput && (
<Field>
<Controller
control={control}
name='abandonedRoomsCloseCustomMessage'
render={({ field: { value, onChange } }) => (
<AbandonedMessageInput
value={value}
handler={onChange}
label={'Livechat_abandoned_rooms_closed_custom_message'}
placeholder='Enter_a_custom_message'
/>
)}
/>
</Field>
)}
{WaitingQueueMessageInput && (
<Field>
<Controller
control={control}
name='waitingQueueMessage'
render={({ field: { value, onChange } }) => (
<WaitingQueueMessageInput
value={value}
handler={onChange}
label={'Waiting_queue_message'}
placeholder={'Waiting_queue_message'}
/>
)}
/>
</Field>
)}
{DepartmentForwarding && (
<Field>
<Controller
control={control}
name='departmentsAllowedToForward'
render={({ field: { value, onChange } }) => (
<DepartmentForwarding
departmentId={id ?? ''}
value={value}
handler={onChange}
label={'List_of_departments_for_forward'}
/>
)}
/>
</Field>
)}
{AutoCompleteDepartment && (
<Field>
<Field.Label>{t('Fallback_forward_department')}</Field.Label>
<Controller
control={control}
name='fallbackForwardDepartment'
render={({ field: { value, onChange } }) => (
<AutoCompleteDepartment
haveNone
excludeDepartmentId={department?._id}
value={value}
onChange={onChange}
onlyMyDepartments
showArchived
/>
)}
/>
</Field>
)}
<Field>
<Box display='flex' data-qa='DiscussionToggle-RequestTagBeforeCLosingChat' flexDirection='row'>
<Field.Label>{t('Request_tag_before_closing_chat')}</Field.Label>
<Field.Row>
<ToggleSwitch
data-qa='DiscussionToggle-RequestTagBeforeCLosingChat'
flexGrow={1}
{...register('requestTagBeforeClosingChat')}
/>
</Field.Row>
</Box>
</Field>
{requestTagBeforeClosingChat && (
<Field>
<Field.Label alignSelf='stretch'>{t('Conversation_closing_tags')}*</Field.Label>
<Controller
control={control}
name='chatClosingTags'
rules={{ required: t('The_field_is_required', 'tags') }}
render={({ field: { value, onChange } }) => (
<DepartmentTags value={value} onChange={onChange} error={errors.chatClosingTags?.message as string} />
)}
/>
{errors.chatClosingTags && <Field.Error>{errors.chatClosingTags?.message}</Field.Error>}
</Field>
)}
{DepartmentBusinessHours && (
<Field>
<DepartmentBusinessHours bhId={department?.businessHourId} />
</Field>
)}
<Divider mb='x16' />
<Field>
<Field.Label mb='x4'>{t('Agents')}:</Field.Label>
<Box display='flex' flexDirection='column' height='50vh'>
<DepartmentsAgentsTable control={control} register={register} />
</Box>
</Field>
</FieldGroup>
</Page.ScrollableContentWithShadow>
</Page>
</Page>
);
}
export default EditDepartment;

@ -1,36 +0,0 @@
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo } from 'react';
import { FormSkeleton } from '../../../components/Skeleton';
import { AsyncStatePhase } from '../../../hooks/useAsyncState';
import { useEndpointData } from '../../../hooks/useEndpointData';
import EditDepartment from './EditDepartment';
function EditDepartmentWithAllowedForwardData({ data, ...props }) {
const t = useTranslation();
const {
value: allowedToForwardData,
phase: allowedToForwardState,
error: allowedToForwardError,
} = useEndpointData('/v1/livechat/department.listByIds', {
params: useMemo(
() => ({
ids: data?.department?.departmentsAllowedToForward ?? [],
}),
[data],
),
});
if ([allowedToForwardState].includes(AsyncStatePhase.LOADING)) {
return <FormSkeleton />;
}
if (allowedToForwardError) {
return <Box mbs='x16'>{t('Not_Available')}</Box>;
}
return <EditDepartment data={data} allowedToForwardData={allowedToForwardData} {...props} />;
}
export default EditDepartmentWithAllowedForwardData;

@ -0,0 +1,35 @@
import { Box } from '@rocket.chat/fuselage';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import { FormSkeleton } from '../../../components/Skeleton';
import type { EditDepartmentProps } from './EditDepartment';
import EditDepartment from './EditDepartment';
const EditDepartmentWithAllowedForwardData = ({ data, ...props }: Omit<EditDepartmentProps, 'allowedToForwardData'>) => {
const t = useTranslation();
const getDepartmentListByIds = useEndpoint('GET', '/v1/livechat/department.listByIds');
const {
data: allowedToForwardData,
isInitialLoading,
isError,
} = useQuery(['/v1/livechat/department.listByIds', data?.department?.departmentsAllowedToForward], () =>
getDepartmentListByIds({
ids: data?.department?.departmentsAllowedToForward ?? [],
}),
);
if (isInitialLoading) {
return <FormSkeleton />;
}
if (isError) {
return <Box mbs='x16'>{t('Not_Available')}</Box>;
}
return <EditDepartment data={data} allowedToForwardData={allowedToForwardData} {...props} />;
};
export default EditDepartmentWithAllowedForwardData;

@ -1,39 +0,0 @@
import { Box } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React from 'react';
import { FormSkeleton } from '../../../components/Skeleton';
import { AsyncStatePhase } from '../../../hooks/useAsyncState';
import { useEndpointData } from '../../../hooks/useEndpointData';
import EditDepartment from './EditDepartment';
import EditDepartmentWithAllowedForwardData from './EditDepartmentWithAllowedForwardData';
const params = { onlyMyDepartments: true };
function EditDepartmentWithData({ id, title }) {
const t = useTranslation();
const { value: data, phase: state, error } = useEndpointData('/v1/livechat/department/:_id', { keys: { _id: id }, params });
if ([state].includes(AsyncStatePhase.LOADING)) {
return <FormSkeleton />;
}
if (error || (id && !data?.department)) {
return <Box mbs={16}>{t('Department_not_found')}</Box>;
}
if (data.department.archived === true) {
return <Box mbs={16}>{t('Department_archived')}</Box>;
}
return (
<>
{data && data.department && data.department.departmentsAllowedToForward && data.department.departmentsAllowedToForward.length > 0 ? (
<EditDepartmentWithAllowedForwardData id={id} data={data} title={title} />
) : (
<EditDepartment id={id} data={data} title={title} />
)}
</>
);
}
export default EditDepartmentWithData;

@ -0,0 +1,45 @@
import { Box } from '@rocket.chat/fuselage';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import { FormSkeleton } from '../../../components/Skeleton';
import EditDepartment from './EditDepartment';
import EditDepartmentWithAllowedForwardData from './EditDepartmentWithAllowedForwardData';
const params = { onlyMyDepartments: 'true' } as const;
type EditDepartmentWithDataProps = {
id?: string;
title: string;
};
const EditDepartmentWithData = ({ id, title }: EditDepartmentWithDataProps) => {
const t = useTranslation();
const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: id ?? '' });
const { data, isInitialLoading, isError } = useQuery(['/v1/livechat/department/:_id'], () => getDepartment(params), { enabled: !!id });
if (isInitialLoading) {
return <FormSkeleton />;
}
if (isError || (id && !data?.department)) {
return <Box mbs={16}>{t('Department_not_found')}</Box>;
}
if (data?.department?.archived === true) {
return <Box mbs={16}>{t('Department_archived')}</Box>;
}
return (
<>
{data?.department?.departmentsAllowedToForward && data.department.departmentsAllowedToForward.length > 0 ? (
<EditDepartmentWithAllowedForwardData id={id} data={data} title={title} />
) : (
<EditDepartment id={id} data={data} title={title} />
)}
</>
);
};
export default EditDepartmentWithData;

@ -12,9 +12,10 @@ type NewDepartmentProps = {
};
const NewDepartment = ({ id }: NewDepartmentProps) => {
const t = useTranslation();
const setModal = useSetModal();
const getDepartmentCreationAvailable = useEndpoint('GET', '/v1/livechat/department/isDepartmentCreationAvailable');
const { data, isLoading, error } = useQuery(['getDepartments'], () => getDepartmentCreationAvailable(), {
const { data, isLoading, isError } = useQuery(['getDepartments'], () => getDepartmentCreationAvailable(), {
onSuccess: (data) => {
if (data.isDepartmentCreationAvailable === false) {
setModal(<EnterpriseDepartmentsModal closeModal={(): void => setModal(null)} />);
@ -22,9 +23,7 @@ const NewDepartment = ({ id }: NewDepartmentProps) => {
},
});
const t = useTranslation();
if (error) {
if (isError) {
return <Callout type='danger'>{t('Unavailable')}</Callout>;
}
@ -32,8 +31,7 @@ const NewDepartment = ({ id }: NewDepartmentProps) => {
return <PageSkeleton />;
}
// TODO: remove allowedToForwardData and data props once the EditDepartment component is migrated to TS
return <EditDepartment id={id} title={t('New_Department')} allowedToForwardData={undefined} data={undefined} />;
return <EditDepartment id={id} title={t('New_Department')} />;
};
export default NewDepartment;

@ -1,30 +0,0 @@
import { Box, NumberInput } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useState } from 'react';
function Order({ agentId, setAgentList, agentList }) {
const t = useTranslation();
const [agentOrder, setAgentOrder] = useState(agentList.find((agent) => agent.agentId === agentId).order || 0);
const handleOrder = useMutableCallback(async (e) => {
const orderValue = Number(e.currentTarget.value);
setAgentOrder(orderValue);
setAgentList(
agentList.map((agent) => {
if (agent.agentId === agentId) {
agent.order = orderValue;
}
return agent;
}),
);
});
return (
<Box display='flex'>
<NumberInput flexShrink={1} key={`${agentId}-order`} title={t('Order')} value={agentOrder} onChange={handleOrder} />
</Box>
);
}
export default Order;

@ -1,23 +0,0 @@
import { Field, TextInput } from '@rocket.chat/fuselage';
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useMemo } from 'react';
import { useEndpointData } from '../../../../client/hooks/useEndpointData';
export const DepartmentBusinessHours = ({ bhId }) => {
const t = useTranslation();
const { value: data } = useEndpointData('/v1/livechat/business-hour', { params: useMemo(() => ({ _id: bhId, type: 'custom' }), [bhId]) });
const name = data && data.businessHour && data.businessHour.name;
return (
<Field>
<Field.Label>{t('Business_Hour')}</Field.Label>
<Field.Row>
<TextInput disabled value={name || ''} />
</Field.Row>
</Field>
);
};
export default DepartmentBusinessHours;

@ -0,0 +1,23 @@
import { Field, TextInput } from '@rocket.chat/fuselage';
import { useEndpoint, useTranslation } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
export const DepartmentBusinessHours = ({ bhId }: { bhId: string | undefined }) => {
const t = useTranslation();
const getBusinessHour = useEndpoint('GET', '/v1/livechat/business-hour');
const { data } = useQuery(['/v1/livechat/business-hour', bhId], () => getBusinessHour({ _id: bhId, type: 'custom' }));
const name = data?.businessHour?.name;
return (
<Field>
<Field.Label>{t('Business_Hour')}</Field.Label>
<Field.Row>
<TextInput disabled value={name || ''} />
</Field.Row>
</Field>
);
};
export default DepartmentBusinessHours;

@ -6,6 +6,12 @@ import { Users } from './fixtures/userStates';
import { OmnichannelDepartments } from './page-objects';
import { test, expect } from './utils/test';
const ERROR = {
requiredName: 'The field name is required.',
requiredEmail: 'The field email is required.',
invalidEmail: 'Invalid email address',
};
test.use({ storageState: Users.admin.state });
test.describe.serial('omnichannel-departments', () => {
@ -35,8 +41,32 @@ test.describe.serial('omnichannel-departments', () => {
await expect(poOmnichannelDepartments.btnEnabled).not.toBeVisible();
await page.goBack();
});
await test.step('expect create new department', async () => {
await test.step('expect name and email to be required', async () => {
await poOmnichannelDepartments.btnNew.click();
await expect(poOmnichannelDepartments.invalidInputEmail).not.toBeVisible();
await poOmnichannelDepartments.inputName.fill('any_text');
await poOmnichannelDepartments.inputName.fill('');
await expect(poOmnichannelDepartments.invalidInputName).toBeVisible();
await expect(poOmnichannelDepartments.errorMessage(ERROR.requiredName)).toBeVisible();
await poOmnichannelDepartments.inputName.fill('any_text');
await expect(poOmnichannelDepartments.invalidInputName).not.toBeVisible();
await poOmnichannelDepartments.inputEmail.fill('any_text');
await expect(poOmnichannelDepartments.invalidInputEmail).toBeVisible();
await expect(poOmnichannelDepartments.errorMessage(ERROR.invalidEmail)).toBeVisible();
await poOmnichannelDepartments.inputEmail.fill('');
await expect(poOmnichannelDepartments.invalidInputEmail).toBeVisible();
await expect(poOmnichannelDepartments.errorMessage(ERROR.requiredEmail)).toBeVisible();
await poOmnichannelDepartments.inputEmail.fill(faker.internet.email());
await expect(poOmnichannelDepartments.invalidInputEmail).not.toBeVisible();
await expect(poOmnichannelDepartments.errorMessage(ERROR.requiredEmail)).not.toBeVisible();
});
await test.step('expect create new department', async () => {
await poOmnichannelDepartments.btnEnabled.click();
await poOmnichannelDepartments.inputName.fill(departmentName);
await poOmnichannelDepartments.inputEmail.fill(faker.internet.email());
@ -154,6 +184,8 @@ test.describe.serial('omnichannel-departments', () => {
});
await test.step('Enabled tags state', async () => {
const tagName = faker.datatype.string(5);
await poOmnichannelDepartments.inputSearch.fill(tagsDepartmentName);
await poOmnichannelDepartments.firstRowInTableMenu.click();
await poOmnichannelDepartments.menuEditOption.click();
@ -167,29 +199,26 @@ test.describe.serial('omnichannel-departments', () => {
await expect(poOmnichannelDepartments.inputTags).toBeVisible();
await expect(poOmnichannelDepartments.btnTagsAdd).toBeVisible();
});
await test.step('expect to be invalid if there is no tag added', async () => {
await expect(poOmnichannelDepartments.btnSave).toBeDisabled();
await expect(poOmnichannelDepartments.invalidInputTags).toBeVisible();
});
await test.step('expect to be not possible adding empty tags', async () => {
await poOmnichannelDepartments.inputTags.fill('');
await expect(poOmnichannelDepartments.btnTagsAdd).toBeDisabled();
});
await test.step('expect to have add and remove one tag properly tags', async () => {
const tagName = faker.datatype.string(5);
await poOmnichannelDepartments.inputTags.fill(tagName);
await poOmnichannelDepartments.btnTagsAdd.click();
await expect(poOmnichannelDepartments.btnTag(tagName)).toBeVisible();
await expect(poOmnichannelDepartments.btnSave).toBeEnabled();
});
await test.step('expect to be invalid if there is no tag added', async () => {
await poOmnichannelDepartments.btnTag(tagName).click();
await expect(poOmnichannelDepartments.invalidInputTags).toBeVisible();
await expect(poOmnichannelDepartments.btnSave).toBeDisabled();
});
await test.step('expect to be not possible adding empty tags', async () => {
await poOmnichannelDepartments.inputTags.fill('');
await expect(poOmnichannelDepartments.btnTagsAdd).toBeDisabled();
});
await test.step('expect to not be possible adding same tag twice', async () => {
const tagName = faker.datatype.string(5);
await poOmnichannelDepartments.inputTags.fill(tagName);

@ -44,6 +44,14 @@ export class OmnichannelDepartments {
return this.page.locator('[data-qa="DepartmentEditTextInput-ConversationClosingTags"]:invalid');
}
get invalidInputName() {
return this.page.locator('[data-qa="DepartmentEditTextInput-Name"]:invalid');
}
get invalidInputEmail() {
return this.page.locator('[data-qa="DepartmentEditTextInput-Email"]:invalid');
}
get btnTagsAdd() {
return this.page.locator('[data-qa="DepartmentEditAddButton-ConversationClosingTags"]');
}
@ -131,4 +139,8 @@ export class OmnichannelDepartments {
btnTag(tagName: string) {
return this.page.locator('button', { hasText: tagName });
}
errorMessage(message: string): Locator {
return this.page.locator(`.rcx-field__error >> text="${message}"`);
}
}

@ -138,8 +138,8 @@ export const isLivechatDepartmentDepartmentIdAgentsGETProps = ajv.compile<Livech
);
type LivechatDepartmentDepartmentIdAgentsPOST = {
upsert: string[];
remove: string[];
upsert: { agentId: string; username: string; count: number; order: number }[];
remove: { agentId: string; username: string; count: number; order: number }[];
};
const LivechatDepartmentDepartmentIdAgentsPOSTSchema = {

Loading…
Cancel
Save