Refactor: Omnichannel departments (#18920)

Co-authored-by: Guilherme Gazzo <guilherme@gazzo.xyz>
Co-authored-by: Gabriel Henriques <gabriel.henriques@rocket.chat>
Co-authored-by: Guilherme Gazzo <guilhermegazzo@gmail.com>
pull/19181/head
Martin Schoeler 5 years ago committed by GitHub
parent f9b88c6217
commit 379048074a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      app/livechat/client/views/admin.js
  2. 223
      app/livechat/client/views/app/livechatDepartmentForm.html
  3. 367
      app/livechat/client/views/app/livechatDepartmentForm.js
  4. 59
      app/livechat/client/views/app/livechatDepartments.html
  5. 111
      app/livechat/client/views/app/livechatDepartments.js
  6. 17
      client/channel/UserCard/index.js
  7. 2
      client/components/GenericTable.js
  8. 5
      client/components/basic/AutoCompleteAgent.js
  9. 15
      client/hooks/useComponentDidUpdate.js
  10. 310
      client/omnichannel/departments/DepartmentEdit.js
  11. 147
      client/omnichannel/departments/DepartmentsAgentsTable.js
  12. 55
      client/omnichannel/departments/DepartmentsPage.js
  13. 114
      client/omnichannel/departments/DepartmentsRoute.js
  14. 11
      client/omnichannel/departments/Skeleton.js
  15. 5
      client/omnichannel/routes.js
  16. 65
      ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.html
  17. 72
      ee/app/livechat-enterprise/client/views/app/customTemplates/livechatDepartmentCustomFieldsForm.js
  18. 1
      ee/app/livechat-enterprise/client/views/app/registerCustomTemplates.js
  19. 22
      ee/client/omnichannel/additionalForms/DepartmentBusinessHours.js
  20. 23
      ee/client/omnichannel/additionalForms/DepartmentForwarding.js
  21. 18
      ee/client/omnichannel/additionalForms/EeNumberInput.js
  22. 18
      ee/client/omnichannel/additionalForms/EeTextAreaInput.js
  23. 18
      ee/client/omnichannel/additionalForms/EeTextInput.js
  24. 5
      ee/client/omnichannel/additionalForms/register.js
  25. 1
      packages/rocketchat-i18n/i18n/en.i18n.json

@ -1,3 +1 @@
import './app/livechatDashboard.html';
import './app/livechatDepartmentForm';
import './app/livechatDepartments';

@ -1,223 +0,0 @@
<template name="livechatDepartmentForm">
{{#requiresPermission 'view-livechat-departments'}}
<div class="main-content-flex">
<section class="page-container flex-tab-main-content">
{{> header sectionName=i18nPageTitle}}
<div class="content">
<form id="department-form" data-id="{{department._id}}">
<div class="rocket-form">
{{#if Template.subscriptionsReady}}
<fieldset>
{{#requiresPermission 'manage-livechat-departments'}}
<div class="input-line">
<label>{{_ "Enabled"}}</label>
<div>
<label><input type="radio" name="enabled" value="1" checked="{{$eq department.enabled true}}" /> {{_ "Yes"}}</label>
<label><input type="radio" name="enabled" value="0" checked="{{$eq department.enabled false}}" /> {{_ "No"}}</label>
</div>
</div>
<div class="input-line">
<label>{{_ "Name"}}</label>
<div>
<input type="text" class="rc-input__element" name="name" value="{{department.name}}" placeholder="{{_ "Name"}}" />
</div>
</div>
<div class="input-line">
<label>{{_ "Description"}}</label>
<div>
<textarea name="description" class="rc-input__element" rows="6">{{department.description}}</textarea>
</div>
</div>
<div class="input-line">
<label>{{_ "Show_on_registration_page"}}</label>
<div>
<label><input type="radio" name="showOnRegistration" value="1" checked="{{showOnRegistration true}}" /> {{_ "Yes"}}</label>
<label><input type="radio" name="showOnRegistration" value="0" checked="{{showOnRegistration false}}" /> {{_ "No"}}</label>
</div>
</div>
<div class="input-line">
<label>{{_ "Email"}}</label>
<div>
<input type="email" class="rc-input__element" name="email" value="{{department.email}}" placeholder="{{_ "Email"}}" />
</div>
</div>
<div class="input-line">
<label>{{_ "Show_on_offline_page"}}</label>
<div>
<label><input type="radio" name="showOnOfflineForm" value="1" checked="{{showOnOfflineForm true}}" /> {{_ "Yes"}}</label>
<label><input type="radio" name="showOnOfflineForm" value="0" checked="{{showOnOfflineForm false}}" /> {{_ "No"}}</label>
</div>
</div>
<div class="input-line">
{{> livechatAutocompleteUser
onClickTag=onClickTagOfflineMessageChannel
list=selectedOfflineMessageChannel
onSelect=onSelectOfflineMessageChannel
collection='CachedChannelList'
endpoint='rooms.autocomplete.channelAndPrivate'
field='name'
sort='name'
label="Livechat_DepartmentOfflineMessageToChannel"
placeholder="Channel_name"
name="offlineMessageChannelName"
noMatchTemplate="roomSearchEmpty"
templateItem="popupList_item_channel"
modifier=offlineMessageChannelModifier
showLabel=true
selector=channelSelector
}}
</div>
<div class="input-line">
<label>{{_ "Request_tag_before_closing_chat"}}</label>
<div>
<label><input type="radio" name="requestTagBeforeClosingChat" value="1" checked="{{$eq requestTagBeforeClosingChat true}}" /> {{_ "Yes"}}</label>
<label><input type="radio" name="requestTagBeforeClosingChat" value="0" checked="{{$eq requestTagBeforeClosingChat false}}" /> {{_ "No"}}</label>
</div>
</div>
<label>{{_ "Conversation_closing_tags"}}</label>
<div class="input-line form-inline">
<div class="form-group">
{{#if hasAvailableTags}}
<div class="rc-input__wrapper">
<select id="tagSelect" class="rc-input rc-input__element rc-input--small rc-form-item-inline">
<option value="placeholder" disabled selected>{{_ "Select_tag"}}</option>
{{#each availableDepartmentTags}}
<option value="{{_id}}">{{this}}</option>
{{/each}}
</select>
</div>
{{else}}
<div class="rc-input" id="add-tag-input">
<label class="rc-input__label">
<div class="rc-input__wrapper">
<div class="rc-input__icon">
{{> icon icon='edit' }}
</div>
<input id="tagInput" class="rc-input__element" type="text" name="tags" autocomplete="off" placeholder="{{_"Enter_a_tag"}}">
</div>
</label>
</div>
{{/if}}
</div>
<div class="form-group">
<button id="addTag" name="addTag" class="rc-button rc-button--primary add-tag">{{_ "Add"}}</button>
</div>
<div>
<small class="secondary-font-color">{{{_ "Conversation_closing_tags_description"}}}</small>
</div>
</div>
{{#if hasChatClosingTags}}
<div class="input-line">
<ul id="tags" class="chip-container department-fallback-tags">
{{#each chatClosingTags}}
<li class="remove-tag" title="{{this}}">
<i class="icon icon-cancel-circled"></i>
{{this}}
</li>
{{/each}}
</ul>
</div>
{{/if}}
{{#if customFieldsTemplate}}
{{> Template.dynamic template=customFieldsTemplate data=data }}
{{/if}}
{{#requiresPermission 'add-livechat-department-agents'}}
<hr />
{{else}}
{{/requiresPermission}}
{{else}}
<legend>{{department.name}}</legend>
{{/requiresPermission}}
{{#requiresPermission 'add-livechat-department-agents'}}
<h2>{{_ "Agents"}}</h2>
<fieldset>
<label>{{_ "Add_agent"}}</label>
<div class="input-line form-inline">
<div class="form-group">
{{> livechatAutocompleteUser
onClickTag=onClickTagAgents
list=selectedAgents
onSelect=onSelectAgents
collection='UserAndRoom'
endpoint='users.autocomplete'
field='username'
sort='username'
label="Search_by_username"
placeholder="Search_by_username"
name="username"
exceptions=exceptionsAgents
icon="at"
noMatchTemplate="userSearchEmpty"
templateItem="popupList_item_default"
modifier=agentModifier
conditions=agentConditions
}}
</div>
<div class="form-group">
<button name="add" class="rc-button rc-button--primary add-agent">{{_ "Add"}}</button>
</div>
</div>
</fieldset>
<fieldset>
<legend>{{_ "Selected_agents"}}</legend>
<div class="rc-table-content">
{{#table fixed='true' onScroll=onTableScroll }}
<thead>
<tr>
<th width="25%"><div class="table-fake-th">{{_ "Username"}}</div></th>
<th><div class="table-fake-th">{{_ "Count"}}</div></th>
<th><div class="table-fake-th">{{_ "Order"}}</div></th>
<th width="40px">&nbsp;</th>
</tr>
</thead>
<tbody>
{{#each departmentAgents}}
<tr class="agent-info">
<td>
<div class="rc-table-wrapper">
<div class="rc-table-info">
<span class="rc-table-title">{{username}}</span>
</div>
</div>
</td>
<td><input type="text" class="count" name="count" value="{{count}}" size="3"></td>
<td><input type="text" class="order" name="order" value="{{order}}" size="3"></td>
<td><a href="#remove" class="remove-agent"><i class="icon-trash"></i></a></td>
</tr>
{{else}}
<tr>
<td colspan="4">{{_ "There_are_no_agents_added_to_this_department_yet"}}</td>
</tr>
{{/each}}
</tbody>
{{/table}}
</div>
</fieldset>
{{else}}
{{/requiresPermission}}
</fieldset>
<div class="rc-button__group">
<button class="rc-button back" type="button"><i class="icon-left-big"></i><span>{{_ "Back"}}</span></button>
<button class="rc-button rc-button--primary save"><i class="icon-floppy"></i><span>{{_ "Save"}}</span></button>
</div>
{{else}}
{{> loading}}
{{/if}}
</div>
</form>
</div>
</section>
{{#if tabBarVisible}}
{{#with flexData}}
{{> flexTabBar}}
{{/with}}
{{/if}}
</div>
{{/requiresPermission}}
</template>

@ -1,367 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveVar } from 'meteor/reactive-var';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Template } from 'meteor/templating';
import _ from 'underscore';
import toastr from 'toastr';
import { TabBar, RocketChatTabBar } from '../../../../ui-utils';
import { t, handleError } from '../../../../utils';
import { hasPermission } from '../../../../authorization';
import { getCustomFormTemplate } from './customTemplates/register';
import './livechatDepartmentForm.html';
import { APIClient, roomTypes } from '../../../../utils/client';
const LIST_SIZE = 50;
const saveDepartmentsAgents = async (_id, instance) => {
const upsert = [...instance.agentsToUpsert.values()];
const remove = [...instance.agentsToRemove.values()];
if (!upsert.length && !remove.length) {
return;
}
return APIClient.v1.post(`livechat/department/${ _id }/agents`, {
upsert,
remove,
});
};
Template.livechatDepartmentForm.helpers({
department() {
return Template.instance().department.get();
},
agents() {
return Template.instance().department && !_.isEmpty(Template.instance().department.get()) ? Template.instance().department.get().agents : [];
},
departmentAgents() {
return _.sortBy(Template.instance().departmentAgents.get(), 'username');
},
showOnRegistration(value) {
const department = Template.instance().department.get();
return department.showOnRegistration === value || (department.showOnRegistration === undefined && value === true);
},
showOnOfflineForm(value) {
const department = Template.instance().department.get();
return department.showOnOfflineForm === value || (department.showOnOfflineForm === undefined && value === true);
},
requestTagBeforeClosingChat() {
const department = Template.instance().department.get();
return !!(department && department.requestTagBeforeClosingChat);
},
customFieldsTemplate() {
return getCustomFormTemplate('livechatDepartmentForm');
},
data() {
return { id: FlowRouter.getParam('_id') };
},
exceptionsAgents() {
return _.pluck(Template.instance().departmentAgents.get(), 'username');
},
agentModifier() {
return (filter, text = '') => {
const f = filter.get();
return `@${
f.length === 0
? text
: text.replace(
new RegExp(filter.get()),
(part) => `<strong>${ part }</strong>`,
)
}`;
};
},
agentConditions() {
return { roles: 'livechat-agent' };
},
onSelectAgents() {
return Template.instance().onSelectAgents;
},
selectedAgents() {
return Template.instance().selectedAgents.get();
},
onClickTagAgents() {
return Template.instance().onClickTagAgents;
},
flexData() {
return {
tabBar: Template.instance().tabBar,
data: Template.instance().tabBarData.get(),
};
},
tabBarVisible() {
return Object.values(TabBar.buttons.get())
.some((button) => button.groups
.some((group) => group.startsWith('livechat-department')));
},
chatClosingTags() {
return Template.instance().chatClosingTags.get();
},
availableDepartmentTags() {
return Template.instance().availableDepartmentTags.get();
},
hasAvailableTags() {
return [...Template.instance().availableTags.get()].length > 0;
},
hasChatClosingTags() {
return [...Template.instance().chatClosingTags.get()].length > 0;
},
onTableScroll() {
const instance = Template.instance();
return function(currentTarget) {
if (currentTarget.offsetHeight + currentTarget.scrollTop < currentTarget.scrollHeight - 100) {
return;
}
const agents = instance.departmentAgents.get();
if (instance.total.get() > agents.length) {
instance.offset.set(instance.offset.get() + LIST_SIZE);
}
};
},
onClickTagOfflineMessageChannel() {
return Template.instance().onClickTagOfflineMessageChannel;
},
selectedOfflineMessageChannel() {
return Template.instance().offlineMessageChannel.get();
},
onSelectOfflineMessageChannel() {
return Template.instance().onSelectOfflineMessageChannel;
},
offlineMessageChannelModifier() {
return (filter, text = '') => {
const f = filter.get();
return `#${ f.length === 0 ? text : text.replace(new RegExp(filter.get()), (part) => `<strong>${ part }</strong>`) }`;
};
},
channelSelector() {
return (expression) => ({ name: expression });
},
});
Template.livechatDepartmentForm.events({
'submit #department-form'(e, instance) {
e.preventDefault();
const $btn = instance.$('button.save');
let departmentData;
const _id = $(e.currentTarget).data('id');
if (hasPermission('manage-livechat-departments')) {
const enabled = instance.$('input[name=enabled]:checked').val();
const name = instance.$('input[name=name]').val();
const description = instance.$('textarea[name=description]').val();
const showOnRegistration = instance.$('input[name=showOnRegistration]:checked').val();
const email = instance.$('input[name=email]').val();
const showOnOfflineForm = instance.$('input[name=showOnOfflineForm]:checked').val();
const requestTagBeforeClosingChat = instance.$('input[name=requestTagBeforeClosingChat]:checked').val();
const chatClosingTags = instance.chatClosingTags.get();
const [offlineMessageChannel] = instance.offlineMessageChannel.get();
const offlineMessageChannelName = (offlineMessageChannel && roomTypes.getRoomName(offlineMessageChannel.t, offlineMessageChannel)) || '';
if (enabled !== '1' && enabled !== '0') {
return toastr.error(t('Please_select_enabled_yes_or_no'));
}
if (name.trim() === '') {
return toastr.error(t('Please_fill_a_name'));
}
if (email.trim() === '' && showOnOfflineForm === '1') {
return toastr.error(t('Please_fill_an_email'));
}
departmentData = {
enabled: enabled === '1',
name: name.trim(),
description: description.trim(),
showOnRegistration: showOnRegistration === '1',
showOnOfflineForm: showOnOfflineForm === '1',
requestTagBeforeClosingChat: requestTagBeforeClosingChat === '1',
email: email.trim(),
chatClosingTags,
offlineMessageChannelName,
};
}
const oldBtnValue = $btn.html();
$btn.html(t('Saving'));
instance.$('.customFormField').each((i, el) => {
const elField = instance.$(el);
const name = elField.attr('name');
departmentData[name] = elField.val();
});
if (hasPermission('manage-livechat-departments')) {
Meteor.call('livechat:saveDepartment', _id, departmentData, [], async function(err, result) {
$btn.html(oldBtnValue);
if (err) {
return handleError(err);
}
await saveDepartmentsAgents(result._id, instance);
toastr.success(t('Saved'));
FlowRouter.go('livechat-departments');
});
} else if (hasPermission('add-livechat-department-agents')) {
saveDepartmentsAgents(_id, instance);
} else {
throw new Error(t('error-not-authorized'));
}
},
'click .add-agent'(e, instance) {
e.preventDefault();
const users = instance.selectedAgents.get();
users.forEach(async (user) => {
const { _id, username } = user;
const departmentAgents = instance.departmentAgents.get();
if (departmentAgents.find(({ agentId }) => agentId === _id)) {
return toastr.error(t('This_agent_was_already_selected'));
}
const newAgent = _.clone(user);
newAgent.agentId = _id;
delete newAgent._id;
if (instance.agentsToRemove.has(newAgent.agentId)) {
instance.agentsToRemove.delete(newAgent.agentId);
}
instance.agentsToUpsert.set(newAgent.agentId, { ...newAgent, count: 0, order: 0 });
departmentAgents.push(newAgent);
instance.departmentAgents.set(departmentAgents);
instance.selectedAgents.set(instance.selectedAgents.get().filter((user) => user.username !== username));
});
},
'click button.back'(e/* , instance*/) {
e.preventDefault();
FlowRouter.go('livechat-departments');
},
'click .remove-agent'(e, instance) {
e.preventDefault();
if (instance.agentsToUpsert.has(this.agentId)) {
instance.agentsToUpsert.delete(this.agentId);
}
instance.agentsToRemove.set(this.agentId, this);
instance.departmentAgents.set(instance.departmentAgents.get().filter((agent) => agent.agentId !== this.agentId));
},
'keyup .count'(event, instance) {
const agent = instance.agentsToUpsert.get(this.agentId) || this;
instance.agentsToUpsert.set(this.agentId, { ...agent, count: parseInt(event.currentTarget.value) || 0 });
},
'keyup .order'(event, instance) {
const agent = instance.agentsToUpsert.get(this.agentId) || this;
instance.agentsToUpsert.set(this.agentId, { ...agent, order: parseInt(event.currentTarget.value) || 0 });
},
'click #addTag'(e, instance) {
e.stopPropagation();
e.preventDefault();
const isSelect = [...instance.availableTags.get()].length > 0;
const elId = isSelect ? '#tagSelect' : '#tagInput';
const elDefault = isSelect ? 'placeholder' : '';
const tag = $(elId).val();
const chatClosingTags = [...instance.chatClosingTags.get()];
if (tag === '' || chatClosingTags.indexOf(tag) > -1) {
return;
}
chatClosingTags.push(tag);
instance.chatClosingTags.set(chatClosingTags);
$(elId).val(elDefault);
},
'click .remove-tag'(e, instance) {
e.stopPropagation();
e.preventDefault();
const chatClosingTags = [...instance.chatClosingTags.get()].filter((el) => el !== this.valueOf());
instance.chatClosingTags.set(chatClosingTags);
},
});
Template.livechatDepartmentForm.onCreated(async function() {
this.agentsToUpsert = new Map();
this.agentsToRemove = new Map();
this.department = new ReactiveVar({ enabled: true });
this.departmentAgents = new ReactiveVar([]);
this.selectedAgents = new ReactiveVar([]);
this.tabBar = new RocketChatTabBar();
this.tabBar.showGroup(FlowRouter.current().route.name);
this.tabBarData = new ReactiveVar();
this.chatClosingTags = new ReactiveVar([]);
this.availableTags = new ReactiveVar([]);
this.availableDepartmentTags = new ReactiveVar([]);
this.offset = new ReactiveVar(0);
this.total = new ReactiveVar(0);
this.offlineMessageChannel = new ReactiveVar([]);
this.onClickTagOfflineMessageChannel = () => {
this.offlineMessageChannel.set([]);
};
this.onSelectOfflineMessageChannel = async ({ item }) => {
const { room } = await APIClient.v1.get(`rooms.info?roomId=${ item._id }`);
room.text = room.name;
this.offlineMessageChannel.set([room]);
};
this.onSelectAgents = ({ item: agent }) => {
this.selectedAgents.set([agent]);
};
this.onClickTagAgents = ({ username }) => {
this.selectedAgents.set(this.selectedAgents.get().filter((user) => user.username !== username));
};
this.loadAvailableTags = (departmentId) => {
Meteor.call('livechat:getTagsList', (err, tagsList) => {
this.availableTags.set(tagsList || []);
const tags = this.availableTags.get();
const availableTags = tags
.filter(({ departments }) => departments.length === 0 || departments.indexOf(departmentId) > -1)
.map(({ name }) => name);
this.availableDepartmentTags.set(availableTags);
});
};
this.autorun(async () => {
const offset = this.offset.get();
const { agents, total } = await APIClient.v1.get(`livechat/department/${ FlowRouter.getParam('_id') }/agents?count=${ LIST_SIZE }&offset=${ offset }`);
this.total.set(total);
if (offset === 0) {
this.departmentAgents.set(agents);
} else {
this.departmentAgents.set(this.departmentAgents.get().concat(agents));
}
});
this.autorun(async () => {
const id = FlowRouter.getParam('_id');
if (id) {
const { department } = await APIClient.v1.get(`livechat/department/${ FlowRouter.getParam('_id') }?includeAgents=false`);
this.department.set(department);
this.chatClosingTags.set((department && department.chatClosingTags) || []);
this.loadAvailableTags(id);
}
});
this.autorun(async () => {
const department = this.department.get();
let offlineChannel = [];
if (department?.offlineMessageChannelName) {
const { room } = await APIClient.v1.get(`rooms.info?roomName=${ department?.offlineMessageChannelName }`);
if (room) {
room.text = room.name;
offlineChannel = [{ ...room }];
}
}
this.offlineMessageChannel.set(offlineChannel);
});
});

@ -1,59 +0,0 @@
<template name="livechatDepartments">
{{#requiresPermission 'view-livechat-departments'}}
<div class="rc-table-content">
<form class="search-form" role="form">
<div class="rc-input__wrapper">
<div class="rc-input__icon">
{{#if isReady}}
{{> icon block="rc-input__icon-svg" icon="magnifier" }}
{{else}}
{{> loading }}
{{/if}}
</div>
<input id="departments-filter" type="text" class="rc-input__element"
placeholder="{{_ "Search"}}" autofocus dir="auto">
</div>
</form>
<div class="results">
{{{_ "Showing_results" departments.length}}}
</div>
{{#table fixed='true' onScroll=onTableScroll}}
<thead>
<tr>
<th><div class="table-fake-th">{{_ "Name"}}</div></th>
<th width="30%"><div class="table-fake-th">{{_ "Description"}}</div></th>
<th><div class="table-fake-th">{{_ "Num_Agents"}}</div></th>
<th><div class="table-fake-th">{{_ "Enabled"}}</div></th>
<th width="20%"><div class="table-fake-th">{{_ "Show_on_registration_page"}}</div></th>
<th width='40px'><div class="table-fake-th">&nbsp;</div></th>
</tr>
</thead>
<tbody>
{{#each departments}}
<tr class="department-info row-link" data-id="{{_id}}">
<td><div class="rc-table-wrapper">
<div class="rc-table-info"><span class="rc-table-title">{{name}}</span></div></div></td>
<td>{{description}}</td>
<td>{{numAgents}}</td>
<td>{{#if enabled}}{{_ "Yes"}}{{else}}{{_ "No"}}{{/if}}</td>
<td>{{#if showOnRegistration}}{{_ "Yes"}}{{else}}{{_ "No"}}{{/if}}</td>
<td>
{{#requiresPermission 'manage-livechat-departments'}}
<a href="#remove" class="remove-department"><i class="icon-trash"></i></a>
{{else}}
{{/requiresPermission}}
</td>
</tr>
{{/each}}
</tbody>
{{/table}}
</div>
{{#requiresPermission 'manage-livechat-departments'}}
<div class="rc-button__group">
<a href="{{pathFor 'livechat-department-new'}}" class="rc-button rc-button--primary">{{_ "New_Department"}}</a>
</div>
{{else}}
{{/requiresPermission}}
{{/requiresPermission}}
</template>

@ -1,111 +0,0 @@
import { Meteor } from 'meteor/meteor';
import { FlowRouter } from 'meteor/kadira:flow-router';
import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import { ReactiveDict } from 'meteor/reactive-dict';
import _ from 'underscore';
import { modal } from '../../../../ui-utils';
import { t, handleError } from '../../../../utils';
import './livechatDepartments.html';
import { APIClient } from '../../../../utils/client';
Template.livechatDepartments.helpers({
departments() {
return Template.instance().departments.get();
},
isLoading() {
return Template.instance().state.get('loading');
},
isReady() {
const instance = Template.instance();
return instance.ready && instance.ready.get();
},
onTableScroll() {
const instance = Template.instance();
return function(currentTarget) {
if (
currentTarget.offsetHeight + currentTarget.scrollTop
>= currentTarget.scrollHeight - 100
) {
return instance.limit.set(instance.limit.get() + 50);
}
};
},
});
const DEBOUNCE_TIME_FOR_SEARCH_DEPARTMENTS_IN_MS = 300;
Template.livechatDepartments.events({
'click .remove-department'(e, instance) {
e.preventDefault();
e.stopPropagation();
modal.open({
title: t('Are_you_sure'),
type: 'warning',
showCancelButton: true,
confirmButtonColor: '#DD6B55',
confirmButtonText: t('Yes'),
cancelButtonText: t('Cancel'),
closeOnConfirm: false,
html: false,
}, () => {
Meteor.call('livechat:removeDepartment', this._id, (error/* , result*/) => {
if (error) {
return handleError(error);
}
instance.departments.set(instance.departments.curValue.filter((department) => department._id !== this._id));
modal.open({
title: t('Removed'),
text: t('Department_removed'),
type: 'success',
timer: 1000,
showConfirmButton: false,
});
});
});
},
'click .department-info'(e/* , instance*/) {
e.preventDefault();
FlowRouter.go('livechat-department-edit', { _id: this._id });
},
'keydown #departments-filter'(e) {
if (e.which === 13) {
e.stopPropagation();
e.preventDefault();
}
},
'keyup #departments-filter': _.debounce((e, t) => {
e.stopPropagation();
e.preventDefault();
t.filter.set(e.currentTarget.value);
}, DEBOUNCE_TIME_FOR_SEARCH_DEPARTMENTS_IN_MS),
});
Template.livechatDepartments.onCreated(function() {
const instance = this;
this.limit = new ReactiveVar(50);
this.filter = new ReactiveVar('');
this.state = new ReactiveDict({
loading: false,
});
this.ready = new ReactiveVar(true);
this.departments = new ReactiveVar([]);
this.autorun(async function() {
const limit = instance.limit.get();
const filter = instance.filter.get();
let baseUrl = `livechat/department?count=${ limit }`;
if (filter) {
baseUrl += `&text=${ encodeURIComponent(filter) }`;
}
const { departments } = await APIClient.v1.get(baseUrl);
instance.departments.set(departments);
instance.ready.set(true);
});
});

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useMemo, useRef } from 'react';
import { PositionAnimated, AnimatedVisibility, Menu, Option } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
@ -10,22 +10,9 @@ import { Backdrop } from '../../components/basic/Backdrop';
import * as UserStatus from '../../components/basic/UserStatus';
import { LocalTime } from '../../components/basic/UTCClock';
import { useUserInfoActions, useUserInfoActionsSpread } from '../hooks/useUserInfoActions';
import { useComponentDidUpdate } from '../../hooks/useComponentDidUpdate';
import { useCurrentRoute } from '../../contexts/RouterContext';
export const useComponentDidUpdate = (
effect,
dependencies = [],
) => {
const hasMounted = useRef(false);
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
effect();
}, dependencies);
};
const UserCardWithData = ({ username, onClose, target, open, rid }) => {
const ref = useRef(target);

@ -90,7 +90,7 @@ export const GenericTable = forwardRef(function GenericTable({
{RenderRow && (
results
? results.map((props, index) => <RenderRow key={props._id || index} { ...props }/>)
: <Loading/>
: <Loading/>
)}
{children && (results ? results.map(children) : <Loading />)}
</Table.Body>

@ -9,7 +9,8 @@ export const AutoCompleteAgent = React.memo((props) => {
const [filter, setFilter] = useState('');
const { data } = useEndpointDataExperimental('livechat/users/agent', useMemo(() => ({ text: filter }), [filter]));
const options = useMemo(() => (data && [{ value: 'all', label: t('All') }, ...data.users.map((user) => ({ value: user._id, label: user.name }))]) || [{ value: 'all', label: t('All') }], [data, t]);
const options = useMemo(() => (data && [...data.users.map((user) => ({ value: user._id, label: user.name }))]) || [], [data]);
const optionsWithAll = useMemo(() => (data && [{ value: 'all', label: t('All') }, ...data.users.map((user) => ({ value: user._id, label: user.name }))]) || [{ value: 'all', label: t('All') }], [data, t]);
return <AutoComplete
{...props}
@ -17,6 +18,6 @@ export const AutoCompleteAgent = React.memo((props) => {
setFilter={setFilter}
renderSelected={({ label }) => <>{label}</>}
renderItem={({ value, ...props }) => <Option key={value} {...props} />}
options={ options }
options={ props.empty ? options : optionsWithAll }
/>;
});

@ -0,0 +1,15 @@
import { useEffect, useRef } from 'react';
export const useComponentDidUpdate = (
effect,
dependencies = [],
) => {
const hasMounted = useRef(false);
useEffect(() => {
if (!hasMounted.current) {
hasMounted.current = true;
return;
}
effect();
}, dependencies);
};

@ -0,0 +1,310 @@
/* eslint-disable complexity */
import React, { useMemo, useState, useRef } from 'react';
import { FieldGroup, Field, TextInput, Chip, SelectFiltered, Box, Icon, Divider, ToggleSwitch, TextAreaInput, ButtonGroup, Button } from '@rocket.chat/fuselage';
import { useMutableCallback, useUniqueId } from '@rocket.chat/fuselage-hooks';
import { useSubscription } from 'use-subscription';
import { useMethod } from '../../contexts/ServerContext';
import { useEndpointAction } from '../../hooks/useEndpointAction';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointDataExperimental, ENDPOINT_STATES } from '../../hooks/useEndpointDataExperimental';
import { FormSkeleton } from './Skeleton';
import { useForm } from '../../hooks/useForm';
import { useRoute } from '../../contexts/RouterContext';
import Page from '../../components/basic/Page';
import DepartmentsAgentsTable from './DepartmentsAgentsTable';
import { formsSubscription } from '../additionalForms';
import { useComponentDidUpdate } from '../../hooks/useComponentDidUpdate';
export default function EditDepartmentWithData({ id, reload, title }) {
const t = useTranslation();
const { data, state, error } = useEndpointDataExperimental(`livechat/department/${ id }`) || {};
if ([state].includes(ENDPOINT_STATES.LOADING)) {
return <FormSkeleton/>;
}
if (error) {
return <Box mbs='x16'>{t('User_not_found')}</Box>;
}
return <EditDepartment id={id} data={data} reload={reload} title={title}/>;
}
const useQuery = ({ name }) => useMemo(() => ({ selector: JSON.stringify({ name }) }), [name]);
export function EditDepartment({ data, id, title, reload }) {
const t = useTranslation();
const agentsRoute = useRoute('omnichannel-departments');
const eeForms = useSubscription(formsSubscription);
const initialAgents = useRef((data && data.agents) || []);
const router = useRoute('omnichannel-departments');
const {
useEeNumberInput = () => {},
useEeTextInput = () => {},
useEeTextAreaInput = () => {},
useDepartmentForwarding = () => {},
useDepartmentBusinessHours = () => {},
} = eeForms;
const MaxChats = useEeNumberInput();
const VisitorInactivity = useEeNumberInput();
const WaitingQueueMessageInput = useEeTextAreaInput();
const AbandonedMessageInput = useEeTextInput();
const DepartmentForwarding = useDepartmentForwarding();
const DepartmentBusinessHours = useDepartmentBusinessHours();
const [agentList, setAgentList] = useState([]);
const { department } = data || { department: {} };
const [tags, setTags] = useState((department && department.chatClosingTags) || []);
const [tagsText, setTagsText] = useState();
const { values, handlers } = useForm({
name: (department && department.name) || '',
email: (department && department.email) || '',
description: (department && department.description) || '',
enabled: !!(department && department.enabled),
maxNumberSimultaneousChat: (department && department.maxNumberSimultaneousChat) || undefined,
showOnRegistration: !!(department && department.showOnRegistration),
showOnOfflineForm: !!(department && department.showOnOfflineForm),
abandonedRoomsCloseCustomMessage: (department && department.abandonedRoomsCloseCustomMessage) || '',
requestTagBeforeClosingChat: (department && department.requestTagBeforeClosingChat) || false,
offlineMessageChannelName: (department && department.offlineMessageChannelName) || '',
visitorInactivityTimeoutInSeconds: (department && department.visitorInactivityTimeoutInSeconds) || undefined,
waitingQueueMessage: (department && department.waitingQueueMessage) || '',
departmentsAllowedToForward: (department && department.departmentsAllowedToForward) || [],
});
const {
handleName,
handleEmail,
handleDescription,
handleEnabled,
handleMaxNumberSimultaneousChat,
handleShowOnRegistration,
handleShowOnOfflineForm,
handleAbandonedRoomsCloseCustomMessage,
handleRequestTagBeforeClosingChat,
handleOfflineMessageChannelName,
handleVisitorInactivityTimeoutInSeconds,
handleWaitingQueueMessage,
handleDepartmentsAllowedToForward,
} = handlers;
const {
name,
email,
description,
enabled,
maxNumberSimultaneousChat,
showOnRegistration,
showOnOfflineForm,
abandonedRoomsCloseCustomMessage,
requestTagBeforeClosingChat,
offlineMessageChannelName,
visitorInactivityTimeoutInSeconds,
waitingQueueMessage,
departmentsAllowedToForward,
} = values;
const handleTagChipClick = (tag) => () => {
setTags((tags) => tags.filter((_tag) => _tag !== tag));
};
const handleTagTextSubmit = useMutableCallback(() => {
if (!tags.includes(tagsText)) {
setTags([...tags, tagsText]);
setTagsText('');
}
});
const handleTagTextChange = useMutableCallback((e) => {
setTagsText(e.target.value);
});
const query = useQuery({ offlineMessageChannelName });
const { data: autoCompleteChannels } = useEndpointDataExperimental('rooms.autocomplete.channelAndPrivate', query) || {};
const channelOpts = useMemo(() => (autoCompleteChannels && autoCompleteChannels.items ? autoCompleteChannels.items.map(({ name }) => [name, name]) : []), [autoCompleteChannels]);
const saveDepartmentInfo = useMethod('livechat:saveDepartment');
const saveDepartmentAgentsInfoOnEdit = useEndpointAction('POST', `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(() => setTagError(requestTagBeforeClosingChat && (!tags || tags.length === 0) ? t('The_field_is_required', 'name') : ''), [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 (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 && departmentsAllowedToForward[0],
};
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, []);
await saveDepartmentAgentsInfoOnEdit(agentListPayload);
} else {
await saveDepartmentInfo(id, payload, agentList);
}
dispatchToastMessage({ type: 'success', message: t('saved') });
reload();
agentsRoute.push({});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const handleReturn = useMutableCallback(() => {
router.push({});
});
const invalidForm = !name || !email || (requestTagBeforeClosingChat && (!tags || tags.length === 0));
const formId = useUniqueId();
return <Page flexDirection='row'>
<Page>
<Page.Header title={title}>
<ButtonGroup>
<Button onClick={handleReturn}>{t('Back')}</Button>
<Button type='submit' form={formId} primary disabled={invalidForm}>{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' 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 flexGrow={1} error={nameError} value={name} onChange={handleName} placeholder={t('Name')} />
</Field.Row>
</Field>
<Field>
<Field.Label>{t('Description')}</Field.Label>
<Field.Row>
<TextAreaInput flexGrow={1} value={description} onChange={handleDescription} placeholder={t('Description')} />
</Field.Row>
</Field>
<Field>
<Box 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 flexGrow={1} error={emailError} value={email} addon={<Icon name='mail' size='x20'/>} onChange={handleEmail} placeholder={t('Email')} />
</Field.Row>
</Field>
<Field>
<Box display='flex' 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>
<SelectFiltered flexGrow={1} options={channelOpts} value={offlineMessageChannelName} onChange={handleOfflineMessageChannelName} placeholder={t('Channel_name')}/>
</Field.Row>
</Field>
{MaxChats && <MaxChats value={maxNumberSimultaneousChat} handler={handleMaxNumberSimultaneousChat} label={'Max_number_of_chats_per_agent'} placeholder='Max_number_of_chats_per_agent_description' />}
{VisitorInactivity && <VisitorInactivity value={visitorInactivityTimeoutInSeconds} handler={handleVisitorInactivityTimeoutInSeconds} label={'How_long_to_wait_to_consider_visitor_abandonment_in_seconds'} placeholder='Number_in_seconds' />}
{AbandonedMessageInput && <AbandonedMessageInput value={abandonedRoomsCloseCustomMessage} handler={handleAbandonedRoomsCloseCustomMessage} label={'Livechat_abandoned_rooms_closed_custom_message'} placeholder='Enter_a_custom_message' />}
{WaitingQueueMessageInput && <WaitingQueueMessageInput value={waitingQueueMessage} handler={handleWaitingQueueMessage} label={'Waiting_queue_message'} />}
{DepartmentForwarding && <DepartmentForwarding value={departmentsAllowedToForward} handler={handleDepartmentsAllowedToForward} label={'List_of_departments_for_forward_description'} placeholder='Enter_a_department_name' />}
<Field>
<Box display='flex' flexDirection='row'>
<Field.Label>{t('Request_tag_before_closing_chat')}</Field.Label>
<Field.Row>
<ToggleSwitch 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 error={tagError} value={tagsText} onChange={handleTagTextChange} placeholder={t('Enter_a_tag')} />
<Button mis='x8' title={t('add')} onClick={handleTagTextSubmit}>
{t('Add')}
</Button>
</Field.Row>
<Field.Hint>{t('Conversation_closing_tags_description')}</Field.Hint>
{tags && 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 && <DepartmentBusinessHours bhId={department && department.businessHourId}/>}
<Divider mb='x16' />
<Field>
<Field.Label mb='x4'>{t('Agents')}:</Field.Label>
<DepartmentsAgentsTable agents={data && data.agents} setAgentListFinal={setAgentList}/>
</Field>
</FieldGroup>
</Page.ScrollableContentWithShadow>
</Page>
</Page>;
}

@ -0,0 +1,147 @@
import { useMediaQuery, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useState, useEffect } from 'react';
import { Box, Table, Icon, Button, NumberInput } from '@rocket.chat/fuselage';
import { Th, GenericTable } from '../../components/GenericTable';
import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointAction } from '../../hooks/useEndpointAction';
import UserAvatar from '../../components/basic/avatar/UserAvatar';
import DeleteWarningModal from '../../components/DeleteWarningModal';
import { useSetModal } from '../../contexts/ModalContext';
import { useToastMessageDispatch } from '../../contexts/ToastMessagesContext';
import { AutoCompleteAgent } from '../../components/basic/AutoCompleteAgent';
function AddAgent({ agentList, setAgentList, ...props }) {
const t = useTranslation();
const [userId, setUserId] = useState();
const getAgent = useEndpointAction('GET', `livechat/users/agent/${ userId }`);
const dispatchToastMessage = useToastMessageDispatch();
const handleAgent = useMutableCallback((e) => setUserId(e));
const handleSave = useMutableCallback(async () => {
if (!userId) {
return;
}
const { user } = await getAgent();
if (agentList.filter((e) => e.agentId === user._id).length === 0) {
setAgentList([{ ...user, agentId: user._id }, ...agentList]);
setUserId();
} else {
dispatchToastMessage({ type: 'error', message: t('This_agent_was_already_selected') });
}
});
return <Box display='flex' alignItems='center' {...props}>
<AutoCompleteAgent empty value={userId} onChange={handleAgent}/>
<Button disabled={!userId} onClick={handleSave} mis='x8' primary>{t('Add')}</Button>
</Box>;
}
export function RemoveAgentButton({ agentId, setAgentList, agentList }) {
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);
dispatchToastMessage({ type: 'success', message: t('Agent_removed') });
setModal();
};
setModal(<DeleteWarningModal onDelete={onDeleteAgent} onCancel={() => setModal()}/>);
});
return <Button small ghost title={t('Remove')} onClick={handleDelete}><Icon name='trash' size='x16'/></Button>;
}
export 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 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>;
}
const AgentRow = React.memo(({ agentId, username, name, avatarETag, mediaQuery, agentList, setAgentList }) => <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='p2' withTruncatedText color='default'>{name || username}</Box>
{!mediaQuery && name && <Box fontScale='p1' color='hint' withTruncatedText> {`@${ username }`} </Box>}
</Box>
</Box>
</Box>
</Table.Cell>
<Table.Cell fontScale='p1' color='hint' withTruncatedText>
<Count agentId={agentId} agentList={agentList} setAgentList={setAgentList}/>
</Table.Cell>
<Table.Cell fontScale='p1' color='hint' withTruncatedText>
<Order agentId={agentId} agentList={agentList} setAgentList={setAgentList}/>
</Table.Cell>
<Table.Cell fontScale='p1' color='hint'>
<RemoveAgentButton agentId={agentId} agentList={agentList} setAgentList={setAgentList}/>
</Table.Cell>
</Table.Row>);
function DepartmentsAgentsTable({ agents, setAgentListFinal }) {
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} setAgentList={setAgentList}/>
<GenericTable
header={<>
<Th key={'name'} w='x200'>{t('Name')}</Th>
<Th key={'Count'} w='x140'>{t('Count')}</Th>
<Th key={'Order'} w='x120'>{t('Order')}</Th>
<Th key={'remove'} w='x40'>{t('Remove')}</Th>
</>}
results={agentList}
total={agentList?.length}
pi='x24'
>
{(props) => <AgentRow key={props._id} mediaQuery={mediaQuery} agentList={agentList} setAgentList={setAgentList} {...props}/>}
</GenericTable>
</>;
}
export default DepartmentsAgentsTable;

@ -0,0 +1,55 @@
import React, { useState, useEffect } from 'react';
import { TextInput, Button, Box, Icon } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import Page from '../../components/basic/Page';
import { useTranslation } from '../../contexts/TranslationContext';
import { GenericTable } from '../../components/GenericTable';
import { useRoute } from '../../contexts/RouterContext';
const FilterByText = ({ setFilter, ...props }) => {
const t = useTranslation();
const [text, setText] = useState('');
const handleChange = useMutableCallback((event) => setText(event.currentTarget.value));
const onSubmit = useMutableCallback((e) => e.preventDefault());
useEffect(() => {
setFilter({ text });
}, [setFilter, text]);
return <Box mb='x16' is='form' onSubmit={onSubmit} display='flex' flexDirection='column' {...props}>
<TextInput flexShrink={0} placeholder={t('Search')} addon={<Icon name='magnifier' size='x20'/>} onChange={handleChange} value={text} />
</Box>;
};
function DepartmentsPage({
data,
header,
setParams,
params,
title,
renderRow,
children,
}) {
const departmentsRoute = useRoute('omnichannel-departments');
const onAddNew = useMutableCallback(() => departmentsRoute.push({
context: 'new',
}));
return <Page flexDirection='row'>
<Page>
<Page.Header title={title}>
<Button small onClick={onAddNew}>
<Icon name='plus' size='x16'/>
</Button>
</Page.Header>
<Page.Content>
<GenericTable FilterComponent={FilterByText} header={header} renderRow={renderRow} results={data && data.departments} total={data && data.total} setParams={setParams} params={params} />
</Page.Content>
</Page>
{children}
</Page>;
}
export default DepartmentsPage;

@ -0,0 +1,114 @@
import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import React, { useMemo, useCallback, useState } from 'react';
import { Table, Icon } from '@rocket.chat/fuselage';
import { Th } from '../../components/GenericTable';
import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointDataExperimental } from '../../hooks/useEndpointDataExperimental';
import { useEndpointAction } from '../../hooks/useEndpointAction';
import { usePermission } from '../../contexts/AuthorizationContext';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';
import DepartmentsPage from './DepartmentsPage';
import EditDepartmentWithData from './DepartmentEdit';
import { useRouteParameter, useRoute } from '../../contexts/RouterContext';
export function RemoveDepartmentButton({ _id, reload }) {
const deleteAction = useEndpointAction('DELETE', `livechat/department/${ _id }`);
const handleRemoveClick = useMutableCallback(async (e) => {
e.preventDefault();
e.stopPropagation();
const result = await deleteAction();
if (result.success === true) {
reload();
}
});
return <Table.Cell fontScale='p1' color='hint' onClick={handleRemoveClick} withTruncatedText><Icon name='trash' size='x20'/></Table.Cell>;
}
const sortDir = (sortDir) => (sortDir === 'asc' ? 1 : -1);
const useQuery = ({ text, itemsPerPage, current }, [column, direction]) => useMemo(() => ({
fields: JSON.stringify({ name: 1, username: 1, emails: 1, avatarETag: 1 }),
text,
sort: JSON.stringify({ [column]: sortDir(direction), usernames: column === 'name' ? sortDir(direction) : undefined }),
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
}), [text, itemsPerPage, current, column, direction]);
function DepartmentsRoute() {
const t = useTranslation();
const canViewDepartments = usePermission('manage-livechat-departments');
const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 });
const [sort, setSort] = useState(['name', 'asc']);
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
const query = useQuery(debouncedParams, debouncedSort);
const departmentsRoute = useRoute('omnichannel-departments');
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const onHeaderClick = useMutableCallback((id) => {
const [sortBy, sortDirection] = sort;
if (sortBy === id) {
setSort([id, sortDirection === 'asc' ? 'desc' : 'asc']);
return;
}
setSort([id, 'asc']);
});
const onRowClick = useMutableCallback((id) => () => departmentsRoute.push({
context: 'edit',
id,
}));
const { data, reload } = useEndpointDataExperimental('livechat/department', query) || {};
const header = useMemo(() => [
<Th key={'name'} direction={sort[1]} active={sort[0] === 'name'} onClick={onHeaderClick} sort='name' w='x200'>{t('Name')}</Th>,
<Th key={'description'} direction={sort[1]} active={sort[0] === 'description'} onClick={onHeaderClick} sort='description' w='x140'>{t('Description')}</Th>,
<Th key={'numAgents'} direction={sort[1]} active={sort[0] === 'numAgents'} onClick={onHeaderClick} sort='numAgents' w='x120'>{t('Num_Agents')}</Th>,
<Th key={'enabled'} direction={sort[1]} active={sort[0] === 'enabled'} onClick={onHeaderClick} sort='enabled' w='x120'>{t('Enabled')}</Th>,
<Th key={'showOnRegistration'} direction={sort[1]} active={sort[0] === 'showOnRegistration'} onClick={onHeaderClick} sort='status' w='x120'>{t('Show_on_registration_page')}</Th>,
<Th key={'remove'} w='x40'>{t('Remove')}</Th>,
].filter(Boolean), [sort, onHeaderClick, t]);
const renderRow = useCallback(({ name, _id, description, numAgents, enabled, showOnRegistration }) => <Table.Row key={_id} tabIndex={0} role='link' onClick={onRowClick(_id)} action qa-user-id={_id}>
<Table.Cell withTruncatedText>{name}</Table.Cell>
<Table.Cell withTruncatedText>{description}</Table.Cell>
<Table.Cell withTruncatedText>{numAgents || '0'}</Table.Cell>
<Table.Cell withTruncatedText>{enabled ? t('Yes') : t('No')}</Table.Cell>
<Table.Cell withTruncatedText>{showOnRegistration ? t('Yes') : t('No')}</Table.Cell>
<RemoveDepartmentButton _id={_id} reload={reload}/>
</Table.Row>, [onRowClick, t, reload]);
if (!canViewDepartments) {
return <NotAuthorizedPage />;
}
if (context === 'edit' || context === 'new') {
return <EditDepartmentWithData
reload={reload}
id={id}
title={context === 'edit' ? t('Edit_Department') : t('New_Department')} />;
}
return <DepartmentsPage
setParams={setParams}
params={params}
onHeaderClick={onHeaderClick}
data={data} useQuery={useQuery}
reload={reload}
header={header}
renderRow={renderRow}
title={'Departments'}>
</DepartmentsPage>;
}
export default DepartmentsRoute;

@ -0,0 +1,11 @@
import React from 'react';
import { Box, Skeleton } from '@rocket.chat/fuselage';
export const FormSkeleton = (props) => <Box w='full' pb='x24' {...props}>
<Skeleton mbe='x8' />
<Skeleton mbe='x4'/>
<Skeleton mbe='x4'/>
<Skeleton mbe='x8'/>
<Skeleton mbe='x4'/>
<Skeleton mbe='x8'/>
</Box>;

@ -78,6 +78,11 @@ registerOmnichannelRoute('/current', {
lazyRouteComponent: () => import('./currentChats/CurrentChatsRoute'),
});
registerOmnichannelRoute('/departments/:context?/:id?', {
name: 'omnichannel-departments',
lazyRouteComponent: () => import('./departments/DepartmentsRoute'),
});
registerOmnichannelRoute('/realtime-monitoring', {
name: 'omnichannel-realTime',
lazyRouteComponent: () => import('./realTimeMonitoring/RealTimeMonitoringPage'),

@ -1,65 +0,0 @@
<template name="livechatDepartmentCustomFieldsForm" args="deptId">
<div class="input-line">
<label>{{_ "Max_number_of_chats_per_agent_description"}}</label>
<div>
<input type="number" class="rc-input__element customFormField" name="maxNumberSimultaneousChat"
value="{{department.maxNumberSimultaneousChat}}"
placeholder="{{_ "Max_number_of_chats_per_agent"}}"/>
</div>
</div>
<div class="input-line">
<label>{{_ "How_long_to_wait_to_consider_visitor_abandonment"}}</label>
<div>
<input type="number" class="rc-input__element customFormField" name="visitorInactivityTimeoutInSeconds"
value="{{department.visitorInactivityTimeoutInSeconds}}" placeholder="{{_ "Number_in_seconds"}}"/>
</div>
</div>
<div class="input-line">
<label>{{_ "Livechat_abandoned_rooms_closed_custom_message"}}</label>
<div>
<input type="text" class="rc-input__element customFormField" name="abandonedRoomsCloseCustomMessage"
value="{{department.abandonedRoomsCloseCustomMessage}}"
placeholder="{{_ "Enter_a_custom_message"}}"/>
</div>
</div>
<div class="input-line">
<label>{{_ "Waiting_queue_message"}}</label>
<div>
<textarea class="rc-input__element customFormField" name="waitingQueueMessage"
rows="4">{{department.waitingQueueMessage}}</textarea>
</div>
</div>
<div class="input-line">
{{> livechatAutocompleteUser
onClickTag=onClickTagDepartment
list=selectedDepartments
onSelect=onSelectDepartments
collection='CachedDepartmentList'
endpoint='livechat/department.autocomplete'
field='name'
sort='name'
label="List_of_departments_for_forward"
placeholder="Enter_a_department_name"
name="department"
icon="queue"
noMatchTemplate="userSearchEmpty"
templateItem="popupList_item_channel"
template="roomSearch"
noMatchTemplate="roomSearchEmpty"
modifier=departmentModifier
showLabel=true
exceptions=exceptionsDepartments
}}
<input type="hidden" class="customFormField" name="departmentsAllowedToForward"
value="{{selectedDepartmentsIds}}"/>
<div>
<small class="secondary-font-color">{{{_ "List_of_departments_for_forward_description"}}}</small>
</div>
<div class="input-line">
<label>{{_ "Business_Hour"}}</label>
<div>
<input type="text" class="rc-input__element" name="businessHour" value="{{businessHourName}}" disabled/>
</div>
</div>
</div>
</template>

@ -1,72 +0,0 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import { APIClient, mountArrayQueryParameters } from '../../../../../../../app/utils/client';
import './livechatDepartmentCustomFieldsForm.html';
import { LivechatBusinessHourTypes } from '../../../../../../../definition/ILivechatBusinessHour';
Template.livechatDepartmentCustomFieldsForm.helpers({
department() {
return Template.instance().department.get();
},
departmentModifier() {
return (filter, text = '') => {
const f = filter.get();
return `${ f.length === 0 ? text : text.replace(new RegExp(filter.get(), 'i'), (part) => `<strong>${ part }</strong>`) }`;
};
},
onClickTagDepartment() {
return Template.instance().onClickTagDepartment;
},
selectedDepartments() {
return Template.instance().selectedDepartments.get();
},
selectedDepartmentsIds() {
return Template.instance().selectedDepartments.get().map((dept) => dept._id);
},
onSelectDepartments() {
return Template.instance().onSelectDepartments;
},
exceptionsDepartments() {
const department = Template.instance().department.get();
return [department && department._id, ...Template.instance().selectedDepartments.get().map((dept) => dept._id)];
},
businessHourName() {
const businessHour = Template.instance().businessHour.get();
return businessHour?.name;
},
});
Template.livechatDepartmentCustomFieldsForm.onCreated(function() {
this.businessHour = new ReactiveVar({});
this.selectedDepartments = new ReactiveVar([]);
const { id: _id, department: contextDepartment } = this.data;
this.department = new ReactiveVar(contextDepartment);
this.onSelectDepartments = ({ item: department }) => {
department.text = department.name;
this.selectedDepartments.set(this.selectedDepartments.get().concat(department));
};
this.onClickTagDepartment = (department) => {
this.selectedDepartments.set(this.selectedDepartments.get().filter((dept) => dept._id !== department._id));
};
if (!contextDepartment && _id) {
this.autorun(async () => {
const { department } = await APIClient.v1.get(`livechat/department/${ _id }?includeAgents=false`);
if (department.departmentsAllowedToForward) {
const { departments } = await APIClient.v1.get(`livechat/department.listByIds?${ mountArrayQueryParameters('ids', department.departmentsAllowedToForward) }&fields=${ JSON.stringify({ fields: { name: 1 } }) }`);
this.selectedDepartments.set(departments.map((dept) => ({
_id: dept._id,
text: dept.name,
})));
}
if (department.businessHourId) {
const { businessHour } = await APIClient.v1.get(`livechat/business-hour?_id=${ department.businessHourId }&type=${ LivechatBusinessHourTypes.CUSTOM }`);
this.businessHour.set(businessHour);
}
this.department.set(department);
});
}
});

@ -1,5 +1,4 @@
import { addCustomFormTemplate } from '../../../../../../app/livechat/client/views/app/customTemplates/register';
import './customTemplates/livechatDepartmentCustomFieldsForm';
import './customTemplates/visitorEditCustomFieldsForm';
import './customTemplates/visitorInfoCustomForm';

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

@ -0,0 +1,23 @@
import React, { useMemo } from 'react';
import { Field, MultiSelectFiltered } from '@rocket.chat/fuselage';
// import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
import { useEndpointDataExperimental } from '../../../../client/hooks/useEndpointDataExperimental';
export const DepartmentForwarding = ({ value, handler, label, placeholder }) => {
const t = useTranslation();
const { data } = useEndpointDataExperimental('livechat/department');
const options = useMemo(() => (data && [...data.departments.map((department) => [department._id, department.name])]) || [], [data]);
return <Field mbe='x16'>
<Field.Label>{t(label)}</Field.Label>
<Field.Row>
<MultiSelectFiltered value={value} options={options} onChange={handler} disabled={!options} placeholder={t(placeholder)} flexGrow={1} />
</Field.Row>
<Field.Hint>{t('List_of_departments_for_forward_description')}</Field.Hint>
</Field>;
};
export default DepartmentForwarding;

@ -0,0 +1,18 @@
import React from 'react';
import { NumberInput, Field } from '@rocket.chat/fuselage';
// import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
export const EeNumberInput = ({ value, handler, label, placeholder }) => {
const t = useTranslation();
return <Field mbe='x16'>
<Field.Label>{t(label)}</Field.Label>
<Field.Row>
<NumberInput value={value} onChange={handler} flexGrow={1} placeholder={t(placeholder)}/>
</Field.Row>
</Field>;
};
export default EeNumberInput;

@ -0,0 +1,18 @@
import React from 'react';
import { TextAreaInput, Field } from '@rocket.chat/fuselage';
// import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
export const EeTextAreaInput = ({ value, handler, label, placeholder }) => {
const t = useTranslation();
return <Field mbe='x16'>
<Field.Label>{t(label)}</Field.Label>
<Field.Row>
<TextAreaInput flexGrow={1} value={value} onChange={handler} placeholder={t(placeholder)} />
</Field.Row>
</Field>;
};
export default EeTextAreaInput;

@ -0,0 +1,18 @@
import React from 'react';
import { TextInput, Field } from '@rocket.chat/fuselage';
// import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useTranslation } from '../../../../client/contexts/TranslationContext';
export const EeTextInput = ({ value, handler, label, placeholder }) => {
const t = useTranslation();
return <Field mbe='x16'>
<Field.Label>{t(label)}</Field.Label>
<Field.Row>
<TextInput flexGrow={1} value={value} onChange={handler} placeholder={t(placeholder)} />
</Field.Row>
</Field>;
};
export default EeTextInput;

@ -11,6 +11,11 @@ hasLicense('livechat-enterprise').then((enabled) => {
registerForm({ useCustomFieldsAdditionalForm: () => useMemo(() => lazy(() => import('./CustomFieldsAdditionalForm')), []) });
registerForm({ useMaxChatsPerAgent: () => useMemo(() => lazy(() => import('./MaxChatsPerAgent')), []) });
registerForm({ useMaxChatsPerAgentDisplay: () => useMemo(() => lazy(() => import('./MaxChatsPerAgentDisplay')), []) });
registerForm({ useEeNumberInput: () => useMemo(() => lazy(() => import('./EeNumberInput')), []) });
registerForm({ useEeTextAreaInput: () => useMemo(() => lazy(() => import('./EeTextAreaInput')), []) });
registerForm({ useEeTextInput: () => useMemo(() => lazy(() => import('./EeTextInput')), []) });
registerForm({ useDepartmentForwarding: () => useMemo(() => lazy(() => import('./DepartmentForwarding')), []) });
registerForm({ useDepartmentBusinessHours: () => useMemo(() => lazy(() => import('./DepartmentBusinessHours')), []) });
registerForm({ useCustomFieldsAdditionalForm: () => useMemo(() => lazy(() => import('./CustomFieldsAdditionalForm')), []) });
registerForm({ useBusinessHoursTimeZone: () => useMemo(() => lazy(() => import('./BusinessHoursTimeZone')), []) });
registerForm({ useBusinessHoursMultiple: () => useMemo(() => lazy(() => import('./BusinessHoursMultiple')), []) });

@ -1830,6 +1830,7 @@
"How_knowledgeable_was_the_chat_agent": "How knowledgeable was the chat agent?",
"How_long_to_wait_after_agent_goes_offline": "How Long to Wait After Agent Goes Offline",
"How_long_to_wait_to_consider_visitor_abandonment": "How Long to Wait to Consider Visitor Abandonment?",
"How_long_to_wait_to_consider_visitor_abandonment_in_seconds": "How Long to Wait to Consider Visitor Abandonment?",
"How_responsive_was_the_chat_agent": "How responsive was the chat agent?",
"How_satisfied_were_you_with_this_chat": "How satisfied were you with this chat?",
"How_to_handle_open_sessions_when_agent_goes_offline": "How to Handle Open Sessions When Agent Goes Offline",

Loading…
Cancel
Save