Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[NEW] Omnichannel Contact Center (Directory) #19931

Merged
merged 9 commits into from
Dec 22, 2020
1 change: 1 addition & 0 deletions app/livechat/server/api/lib/visitors.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export async function findVisitorsByEmailOrPhoneOrNameOrUsername({ userId, term,
phone: 1,
livechatData: 1,
visitorEmails: 1,
lastChat: 1,
},
});

Expand Down
1 change: 1 addition & 0 deletions app/livechat/server/api/rest.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ import './v1/customField.js';
import './v1/room.js';
import './v1/videoCall.js';
import './v1/transfer.js';
import './v1/contact.js';
42 changes: 42 additions & 0 deletions app/livechat/server/api/v1/contact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Match, check } from 'meteor/check';

import { API } from '../../../../api/server';
import { Livechat } from '../../lib/Livechat';
import {
LivechatVisitors,
} from '../../../../models';

API.v1.addRoute('omnichannel/contact', { authRequired: true }, {
post() {
try {
check(this.bodyParams, {
_id: Match.Maybe(String),
token: String,
name: String,
email: Match.Maybe(String),
phone: Match.Maybe(String),
livechatData: Match.Maybe(Object),
contactManager: Match.Maybe(Object),
});

const contactParams = this.bodyParams;
if (this.bodyParams.phone) {
contactParams.phone = { number: this.bodyParams.phone };
}
const contact = Livechat.registerGuest(contactParams);

return API.v1.success({ contact });
} catch (e) {
return API.v1.failure(e);
}
},
get() {
check(this.queryParams, {
contactId: String,
});

const contact = Promise.await(LivechatVisitors.findOneById(this.queryParams.contactId));

return API.v1.success({ contact });
},
});
2 changes: 2 additions & 0 deletions app/livechat/server/api/v1/room.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Livechat } from '../../lib/Livechat';
import { normalizeTransferredByData } from '../../lib/Helper';
import { findVisitorInfo } from '../lib/visitors';


API.v1.addRoute('livechat/room', {
get() {
const defaultCheckParams = {
Expand Down Expand Up @@ -40,6 +41,7 @@ API.v1.addRoute('livechat/room', {

const rid = roomId || Random.id();
const room = Promise.await(getRoom({ guest, rid, agent, extraParams }));

return API.v1.success(room);
} catch (e) {
return API.v1.failure(e);
Expand Down
12 changes: 12 additions & 0 deletions app/livechat/server/hooks/saveContactLastChat.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { callbacks } from '../../../callbacks';
import { Livechat } from '../lib/Livechat';

callbacks.add('livechat.newRoom', (room) => {
const { _id, v: { _id: guestId } } = room;

const lastChat = {
_id,
ts: new Date(),
};
Livechat.updateLastChat(guestId, lastChat);
}, callbacks.priority.MEDIUM, 'livechat-save-last-chat');
1 change: 1 addition & 0 deletions app/livechat/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import './hooks/processRoomAbandonment';
import './hooks/saveLastVisitorMessageTs';
import './hooks/markRoomNotResponded';
import './hooks/sendTranscriptOnClose';
import './hooks/saveContactLastChat';
import './methods/addAgent';
import './methods/addManager';
import './methods/changeLivechatStatus';
Expand Down
37 changes: 31 additions & 6 deletions app/livechat/server/lib/Livechat.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,6 @@ export const Livechat = {
if (guest.name) {
message.alias = guest.name;
}
// return messages;
return _.extend(sendMessage(guest, message, room), { newRoom, showConnecting: this.showConnecting() });
},

Expand Down Expand Up @@ -193,14 +192,15 @@ export const Livechat = {
return true;
},

registerGuest({ token, name, email, department, phone, username, connectionData } = {}) {
registerGuest({ token, name, email, department, phone, username, livechatData, contactManager, connectionData } = {}) {
check(token, String);

let userId;
const updateUser = {
$set: {
token,
},
$unset: { },
};

const user = LivechatVisitors.getVisitorByToken(token, { fields: { _id: 1 } });
Expand All @@ -219,6 +219,7 @@ export const Livechat = {
} else {
const userData = {
username,
ts: new Date(),
};

if (settings.get('Livechat_Allow_collect_and_store_HTTP_header_informations')) {
Expand All @@ -234,29 +235,45 @@ export const Livechat = {
}
}

if (name) {
updateUser.$set.name = name;
}

if (phone) {
updateUser.$set.phone = [
{ phoneNumber: phone.number },
];
} else {
updateUser.$unset.phone = 1;
}

if (email && email.trim() !== '') {
updateUser.$set.visitorEmails = [
{ address: email },
];
} else {
updateUser.$unset.visitorEmails = 1;
}

if (name) {
updateUser.$set.name = name;
if (livechatData) {
updateUser.$set.livechatData = livechatData;
} else {
updateUser.$unset.livechatData = 1;
}

if (contactManager) {
updateUser.$set.contactManager = contactManager;
} else {
updateUser.$unset.contactManager = 1;
}

if (!department) {
Object.assign(updateUser, { $unset: { department: 1 } });
updateUser.$unset.department = 1;
} else {
const dep = LivechatDepartment.findOneByIdOrName(department);
updateUser.$set.department = dep && dep._id;
}

if (_.isEmpty(updateUser.$unset)) { delete updateUser.$unset; }
LivechatVisitors.updateById(userId, updateUser);

return userId;
Expand Down Expand Up @@ -1155,6 +1172,14 @@ export const Livechat = {

return LivechatRooms.findOneById(roomId);
},
updateLastChat(contactId, lastChat) {
const updateUser = {
$set: {
lastChat,
},
};
LivechatVisitors.updateById(contactId, updateUser);
},
};

settings.get('Livechat_history_monitor_type', (key, value) => {
Expand Down
10 changes: 5 additions & 5 deletions client/components/CustomFieldsForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const CustomTextInput = ({ name, required, minLength, maxLength, setState, state
}, [state, required, minLength, t]);

return useMemo(() => <Field className={className}>
<Field.Label>{t(name)}</Field.Label>
<Field.Label>{t(name)}{required && '*'}</Field.Label>
<Field.Row>
<TextInput name={name} error={verify} maxLength={maxLength} flexGrow={1} value={state} required={required} onChange={(e) => setState(e.currentTarget.value)}/>
</Field.Row>
Expand All @@ -38,7 +38,7 @@ const CustomSelect = ({ name, required, options = {}, setState, state, className
const verify = useMemo(() => (!state.length && required ? t('Field_required') : ''), [required, state.length, t]);

return useMemo(() => <Field className={className}>
<Field.Label>{t(name)}</Field.Label>
<Field.Label>{t(name)}{required && '*'}</Field.Label>
<Field.Row>
<Select name={name} error={verify} flexGrow={1} value={state} options={mappedOptions} required={required} onChange={(val) => setState(val)}/>
</Field.Row>
Expand Down Expand Up @@ -66,12 +66,12 @@ const CustomFieldsAssembler = ({ formValues, formHandlers, customFields, ...prop
return null;
});

export default function CustomFieldsForm({ customFieldsData, setCustomFieldsData, onLoadFields = () => {}, ...props }) {
const customFieldsJson = useSetting('Accounts_CustomFields');
export default function CustomFieldsForm({ jsonCustomFields, customFieldsData, setCustomFieldsData, onLoadFields = () => {}, ...props }) {
const accountsCustomFieldsJson = useSetting('Accounts_CustomFields');

const [customFields] = useState(() => {
try {
return JSON.parse(customFieldsJson || '{}');
return jsonCustomFields || JSON.parse(accountsCustomFieldsJson || '{}');
} catch {
return {};
}
Expand Down
11 changes: 9 additions & 2 deletions client/components/FilterByText.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { Box, Icon, TextInput } from '@rocket.chat/fuselage';
import { Box, Icon, TextInput, Button } from '@rocket.chat/fuselage';
import React, { FC, ChangeEvent, FormEvent, memo, useCallback, useEffect, useState } from 'react';

import { useTranslation } from '../contexts/TranslationContext';

type FilterByTextProps = {
placeholder?: string;
onChange: (filter: { text: string }) => void;
displayButton: boolean;
textButton: string;
onButtonClick: () => void;
};

const FilterByText: FC<FilterByTextProps> = ({
placeholder,
onChange: setFilter,
displayButton: display = false,
textButton = '',
onButtonClick,
...props
}) => {
const t = useTranslation();
Expand All @@ -29,8 +35,9 @@ const FilterByText: FC<FilterByTextProps> = ({
event.preventDefault();
}, []);

return <Box mb='x16' is='form' onSubmit={handleFormSubmit} display='flex' flexDirection='column' {...props}>
return <Box mb='x16' is='form' onSubmit={handleFormSubmit} display='flex' flexDirection='row' {...props}>
<TextInput placeholder={placeholder ?? t('Search')} addon={<Icon name='magnifier' size='x20'/>} onChange={handleInputChange} value={text} />
<Button onClick={onButtonClick} display={display ? 'block' : 'none'} mis='x8' primary>{textButton}</Button>
</Box>;
};

Expand Down
2 changes: 1 addition & 1 deletion client/components/UserCard.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const Info = (props) => (
/>
);

export const Username = ({ name, status = <Status.Offline/>, title }) => <Box display='flex' title={title} flexShrink={0} alignItems='center' fontScale='s2' color='default' withTruncatedText>
export const Username = ({ name, status = <Status.Offline/>, title, ...props }) => <Box {...props} display='flex' title={title} flexShrink={0} alignItems='center' fontScale='s2' color='default' withTruncatedText>
{status} <Box mis='x8' flexGrow={1} withTruncatedText>{name}</Box>
</Box>;

Expand Down
1 change: 1 addition & 0 deletions client/components/helpers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const createToken = () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
1 change: 1 addition & 0 deletions client/contexts/OmnichannelContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export const useOmnichannelRouteConfig = (): OmichannelRoutingConfig | undefined
export const useOmnichannelAgentAvailable = (): boolean => useOmnichannel().agentAvailable;
export const useQueuedInquiries = (): Inquiries => useOmnichannel().inquiries;
export const useOmnichannelQueueLink = (): string => '/livechat-queue';
export const useOmnichannelDirectoryLink = (): string => '/omnichannel-directory';
export const useOmnichannelEnabled = (): boolean => useOmnichannel().enabled;
97 changes: 97 additions & 0 deletions client/omnichannel/directory/ChatTab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React, { useState, useMemo, useCallback } from 'react';
import { useDebouncedValue, useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { Table, Tag, Box } from '@rocket.chat/fuselage';
import moment from 'moment';
import { Meteor } from 'meteor/meteor';
import { FlowRouter } from 'meteor/kadira:flow-router';

import { useTranslation } from '../../contexts/TranslationContext';
import { useEndpointData } from '../../hooks/useEndpointData';
import GenericTable from '../../components/GenericTable';
import FilterByText from '../../components/FilterByText';
import { usePermission } from '../../contexts/AuthorizationContext';
import NotAuthorizedPage from '../../components/NotAuthorizedPage';


const useQuery = ({ text, itemsPerPage, current }, [column, direction], userIdLoggedIn) => useMemo(() => ({
sort: JSON.stringify({ [column]: direction === 'asc' ? 1 : -1 }),
open: false,
roomName: text,
agents: [userIdLoggedIn],
...itemsPerPage && { count: itemsPerPage },
...current && { offset: current },
}), [column, current, direction, itemsPerPage, userIdLoggedIn, text]);

const ChatTable = () => {
const [params, setParams] = useState({ text: '', current: 0, itemsPerPage: 25 });
const [sort, setSort] = useState(['closedAt', 'desc']);
const t = useTranslation();
const debouncedParams = useDebouncedValue(params, 500);
const debouncedSort = useDebouncedValue(sort, 500);
const userIdLoggedIn = Meteor.userId();
const query = useQuery(debouncedParams, debouncedSort, userIdLoggedIn);

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) => {
FlowRouter.go('live', { id: _id });
});

const { value: data } = useEndpointData('livechat/rooms', query) || {};

const header = useMemo(() => [
<GenericTable.HeaderCell key={'fname'} direction={sort[1]} active={sort[0] === 'fname'} onClick={onHeaderClick} sort='fname' w='x400'>{t('Contact_Name')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'department'} direction={sort[1]} active={sort[0] === 'department'} onClick={onHeaderClick} sort='department' w='x200'>{t('Department')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'ts'} direction={sort[1]} active={sort[0] === 'ts'} onClick={onHeaderClick} sort='ts' w='x200'>{t('Started_At')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'chatDuration'} direction={sort[1]} active={sort[0] === 'chatDuration'} onClick={onHeaderClick} sort='chatDuration' w='x120'>{t('Chat_Duration')}</GenericTable.HeaderCell>,
<GenericTable.HeaderCell key={'closedAt'} direction={sort[1]} active={sort[0] === 'closedAt'} onClick={onHeaderClick} sort='closedAt' w='x200'>{t('Closed_At')}</GenericTable.HeaderCell>,
].filter(Boolean), [sort, onHeaderClick, t]);

const renderRow = useCallback(({ _id, fname, ts, closedAt, department, tags }) => <Table.Row key={_id} tabIndex={0} role='link' onClick={() => onRowClick(_id)} action qa-user-id={_id}>
<Table.Cell withTruncatedText>
<Box display='flex' flexDirection='column'>
<Box color='default' withTruncatedText>{fname}</Box>
{tags && <Box color='hint' display='flex' flex-direction='row'>
{tags.map((tag) => (
<Box style={{ marginTop: 4, whiteSpace: 'nowrap', overflow: tag.length > 10 ? 'hidden' : 'visible', textOverflow: 'ellipsis' }} key={tag} mie='x4'>
<Tag style={{ display: 'inline' }} disabled>{tag}</Tag>
</Box>
))}
</Box>}
</Box>
</Table.Cell>
<Table.Cell withTruncatedText>{department ? department.name : ''}</Table.Cell>
<Table.Cell withTruncatedText>{moment(ts).format('L LTS')}</Table.Cell>
<Table.Cell withTruncatedText>{moment(closedAt).from(moment(ts), true)}</Table.Cell>
<Table.Cell withTruncatedText>{moment(closedAt).format('L LTS')}</Table.Cell>
</Table.Row>, [onRowClick]);

return <GenericTable
header={header}
renderRow={renderRow}
results={data && data.rooms}
total={data && data.total}
setParams={setParams}
params={params}
renderFilter={({ onChange, ...props }) => <FilterByText onChange={onChange} {...props} />}
/>;
};


export default function ChatTab(props) {
const hasAccess = usePermission('view-l-room');

if (hasAccess) {
return <ChatTable {...props} />;
}

return <NotAuthorizedPage />;
}
Loading