Skip to content

Commit

Permalink
fix: Canned Response custom tags break the GUI on enterprise (#29694)
Browse files Browse the repository at this point in the history
Co-authored-by: Kevin Aleman <kaleman960@gmail.com>
  • Loading branch information
aleksandernsilva and KevLehman authored Jul 7, 2023
1 parent 7f54572 commit 28b41fb
Show file tree
Hide file tree
Showing 20 changed files with 191 additions and 55 deletions.
5 changes: 5 additions & 0 deletions .changeset/kind-coats-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': patch
---

Fixed Canned Response custom tags breaking the GUI on enterprise
51 changes: 26 additions & 25 deletions apps/meteor/client/components/Omnichannel/Tags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,21 @@ import { Field, TextInput, Chip, Button } from '@rocket.chat/fuselage';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useTranslation } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, ReactElement } from 'react';
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';

import { useFormsSubscription } from '../../views/omnichannel/additionalForms';
import { FormSkeleton } from './Skeleton';
import { useLivechatTags } from './hooks/useLivechatTags';

const Tags = ({
tags = [],
handler,
error,
tagRequired,
department,
}: {
type TagsProps = {
tags?: string[];
handler: (value: string[]) => void;
error?: string;
tagRequired?: boolean;
department?: string;
}): ReactElement => {
};

const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps): ReactElement => {
const t = useTranslation();
const forms = useFormsSubscription() as any;

Expand All @@ -33,16 +29,20 @@ const Tags = ({
department,
});

const customTags = useMemo(() => {
return tags.filter((tag) => !tagsResult?.tags.find((rtag) => rtag._id === tag));
}, [tags, tagsResult?.tags]);

const dispatchToastMessage = useToastMessageDispatch();

const [tagValue, handleTagValue] = useState('');
const [paginatedTagValue, handlePaginatedTagValue] = useState<{ label: string; value: string }[]>();

const paginatedTagValue = useMemo(() => tags.map((tag) => ({ label: tag, value: tag })), [tags]);

const removeTag = (tagToRemove: string): void => {
if (tags) {
const tagsFiltered = tags.filter((tag: string) => tag !== tagToRemove);
handler(tagsFiltered);
}
if (!tags) return;

handler(tags.filter((tag) => tag !== tagToRemove));
};

const handleTagTextSubmit = useMutableCallback(() => {
Expand All @@ -56,7 +56,7 @@ const Tags = ({
return;
}

if (tags.includes(tagValue)) {
if (tags.some((tag) => tag === tagValue)) {
dispatchToastMessage({ type: 'error', message: t('Tag_already_exists') });
return;
}
Expand All @@ -79,8 +79,7 @@ const Tags = ({
<EETagsComponent
value={paginatedTagValue}
handler={(tags: { label: string; value: string }[]): void => {
handler(tags.map((tag) => tag.label));
handlePaginatedTagValue(tags);
handler(tags.map((tag) => tag.value));
}}
department={department}
/>
Expand All @@ -99,16 +98,18 @@ const Tags = ({
{t('Add')}
</Button>
</Field.Row>

<Field.Row justifyContent='flex-start'>
{tags?.map((tag, i) => (
<Chip key={i} onClick={(): void => removeTag(tag)} mie='x8'>
{tag}
</Chip>
))}
</Field.Row>
</>
)}

{customTags.length > 0 && (
<Field.Row justifyContent='flex-start'>
{customTags?.map((tag, i) => (
<Chip key={i} onClick={(): void => removeTag(tag)} mie='x8'>
{tag}
</Chip>
))}
</Field.Row>
)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Label from '../../../components/Label';
import { AgentField, SlaField, ContactField, SourceField } from '../../components';
import PriorityField from '../../components/PriorityField';
import { useOmnichannelRoomInfo } from '../../hooks/useOmnichannelRoomInfo';
import { useTagsLabels } from '../hooks/useTagsLabels';
import DepartmentField from './DepartmentField';
import VisitorClientInfo from './VisitorClientInfo';

Expand All @@ -33,7 +34,6 @@ function ChatInfo({ id, route }) {

const {
ts,
tags,
closedAt,
departmentId,
v,
Expand All @@ -49,6 +49,7 @@ function ChatInfo({ id, route }) {
queuedAt,
} = room || { room: { v: {} } };

const tags = useTagsLabels(room?.tags);
const routePath = useRoute(route || 'omnichannel-directory');
const canViewCustomFields = usePermission('view-livechat-room-customfields');
const subscription = useUserSubscription(id);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';

// TODO: Remove this hook when the endpoint is changed
// to return labels instead of tag id's
export const useTagsLabels = (initialTags: string[] = []) => {
const getTags = useEndpoint('GET', '/v1/livechat/tags');
const { data: tagsData, isInitialLoading } = useQuery(['/v1/livechat/tags'], () => getTags({ text: '' }), {
enabled: Boolean(initialTags.length),
});

const labels = useMemo(() => {
const { tags = [] } = tagsData || {};
return tags.reduce<Record<string, string>>((acc, tag) => ({ ...acc, [tag._id]: tag.name }), {});
}, [tagsData]);

return useMemo(() => {
return isInitialLoading ? initialTags : initialTags.map((tag) => labels[tag] || tag);
}, [initialTags, isInitialLoading, labels]);
};
13 changes: 9 additions & 4 deletions apps/meteor/ee/app/api-enterprise/server/lib/canned-responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { escapeRegExp } from '@rocket.chat/string-helpers';
import { CannedResponse } from '@rocket.chat/models';

import { hasPermissionAsync } from '../../../../../app/authorization/server/functions/hasPermission';
import { getTagsInformation } from './tags';
import { getDepartmentsWhichUserCanAccess } from '../../../livechat-enterprise/server/api/lib/departments';

export async function findAllCannedResponses({ userId }) {
// If the user is an admin or livechat manager, get his own responses and all responses from all departments
if (await hasPermissionAsync(userId, 'view-all-canned-responses')) {
return CannedResponse.find({
const cannedResponses = await CannedResponse.find({
$or: [
{
scope: 'user',
Expand All @@ -21,20 +22,22 @@ export async function findAllCannedResponses({ userId }) {
},
],
}).toArray();
return getTagsInformation(cannedResponses);
}

// If the user it not any of the previous roles nor an agent, then get only his own responses
if (!(await hasPermissionAsync(userId, 'view-agent-canned-responses'))) {
return CannedResponse.find({
const cannedResponses = await CannedResponse.find({
scope: 'user',
userId,
}).toArray();
return getTagsInformation(cannedResponses);
}

// Last scenario: user is an agent, so get his own responses and those from the departments he is in
const accessibleDepartments = await getDepartmentsWhichUserCanAccess(userId);

return CannedResponse.find({
const cannedResponses = await CannedResponse.find({
$or: [
{
scope: 'user',
Expand All @@ -51,6 +54,8 @@ export async function findAllCannedResponses({ userId }) {
},
],
}).toArray();

return getTagsInformation(cannedResponses);
}

export async function findAllCannedResponsesFilter({ userId, shortcut, text, departmentId, scope, createdBy, tags = [], options = {} }) {
Expand Down Expand Up @@ -124,7 +129,7 @@ export async function findAllCannedResponsesFilter({ userId, shortcut, text, dep
});
const [cannedResponses, total] = await Promise.all([cursor.toArray(), totalCount]);
return {
cannedResponses,
cannedResponses: await getTagsInformation(cannedResponses),
total,
};
}
Expand Down
33 changes: 33 additions & 0 deletions apps/meteor/ee/app/api-enterprise/server/lib/tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { ILivechatTag, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { LivechatTag } from '@rocket.chat/models';

const filterTags = (tags: string[], serverTags: ILivechatTag[]) => {
return tags.reduce((acc, tag) => {
const found = serverTags.find((serverTag) => serverTag._id === tag);
if (found) {
acc.push(found.name);
} else {
acc.push(tag);
}
return acc;
}, [] as string[]);
};

export const getTagsInformation = async (cannedResponses: IOmnichannelCannedResponse[]) => {
return Promise.all(
cannedResponses.map(async (cannedResponse) => {
const { tags } = cannedResponse;

if (!Array.isArray(tags) || !tags.length) {
return cannedResponse;
}

const serverTags = await LivechatTag.findInIds(tags, { projection: { _id: 1, name: 1 } }).toArray();

// Known limitation: if a tag was added and removed before this, it will return the tag id instead of the name
cannedResponse.tags = filterTags(tags, serverTags);

return cannedResponse;
}),
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { CannedResponse } from '@rocket.chat/models';

import { callbacks } from '../../../../../lib/callbacks';

callbacks.add(
'livechat.afterTagRemoved',
async (tag) => {
const { _id } = tag;

await CannedResponse.removeTagFromCannedResponses(_id);
},
callbacks.priority.MEDIUM,
'on-tag-removed-remove-references',
);
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ import './applySimultaneousChatsRestrictions';
import './afterInquiryQueued';
import './sendPdfTranscriptOnClose';
import './applyRoomRestrictions';
import './afterTagRemoved';
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export const LivechatEnterprise = {
throw new Meteor.Error('tag-not-found', 'Tag not found', { method: 'livechat:removeTag' });
}

await callbacks.run('livechat.afterTagRemoved', tag);
return LivechatTag.removeById(_id);
},

Expand Down
13 changes: 7 additions & 6 deletions apps/meteor/ee/client/hooks/useTagsList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,14 @@ export const useTagsList = (
count: end + start,
...(options.department && { department: options.department }),
});

return {
items: tags.map((tag: any) => {
tag._updatedAt = new Date(tag._updatedAt);
tag.label = tag.name;
tag.value = { value: tag._id, label: tag.name };
return tag;
}),
items: tags.map<any>((tag: any) => ({
_id: tag._id,
label: tag.name,
value: tag._id,
_updatedAt: new Date(tag._updatedAt),
})),
itemCount: total,
};
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,7 @@ const CannedResponseEdit: FC<{
_id: data?.cannedResponse ? data.cannedResponse._id : '',
shortcut: data ? data.cannedResponse.shortcut : '',
text: data ? data.cannedResponse.text : '',
tags:
data?.cannedResponse?.tags && Array.isArray(data.cannedResponse.tags)
? data.cannedResponse.tags.map((tag) => ({ label: tag, value: tag }))
: [],
tags: data?.cannedResponse?.tags ?? [],
scope: data ? data.cannedResponse.scope : 'user',
departmentId: data?.cannedResponse?.departmentId ? data.cannedResponse.departmentId : '',
});
Expand Down Expand Up @@ -100,13 +97,12 @@ const CannedResponseEdit: FC<{
tags: any;
departmentId: string;
};
const mappedTags = tags.map((tag: string | { value: string; label: string }) => (typeof tag === 'object' ? tag?.value : tag));
await saveCannedResponse({
...(_id && { _id }),
shortcut,
text,
scope,
...(mappedTags.length > 0 && { tags: mappedTags }),
tags,
...(departmentId && { departmentId }),
});
dispatchToastMessage({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,12 @@ const WrapCreateCannedResponseModal: FC<{ data?: any; reloadCannedList?: any }>
tags: any;
departmentId: string;
};
const mappedTags = tags.map((tag: string | { value: string; label: string }) => (typeof tag === 'object' ? tag?.value : tag));
await saveCannedResponse({
...(_id && { _id }),
shortcut,
text,
scope,
...(tags.length > 0 && { tags: mappedTags }),
tags,
...(departmentId && { departmentId }),
});
dispatchToastMessage({
Expand Down
12 changes: 11 additions & 1 deletion apps/meteor/ee/server/models/raw/CannedResponse.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import type { ICannedResponseModel } from '@rocket.chat/model-typings';
import type { Db, DeleteResult, FindCursor, FindOptions, IndexDescription } from 'mongodb';
import type { Db, DeleteResult, FindCursor, FindOptions, IndexDescription, UpdateFilter } from 'mongodb';

import { BaseRaw } from '../../../../server/models/raw/BaseRaw';

Expand Down Expand Up @@ -106,4 +106,14 @@ export class CannedResponseRaw extends BaseRaw<IOmnichannelCannedResponse> imple

return this.deleteOne(query);
}

removeTagFromCannedResponses(tagId: string) {
const update: UpdateFilter<IOmnichannelCannedResponse> = {
$pull: {
tags: tagId,
},
};

return this.updateMany({}, update);
}
}
8 changes: 7 additions & 1 deletion apps/meteor/ee/server/models/raw/LivechatTag.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ILivechatTag } from '@rocket.chat/core-typings';
import type { ILivechatTagModel } from '@rocket.chat/model-typings';
import type { Db, DeleteResult, FindOptions, IndexDescription } from 'mongodb';
import type { Db, DeleteResult, FindCursor, FindOptions, IndexDescription } from 'mongodb';

import { BaseRaw } from '../../../../server/models/raw/BaseRaw';

Expand All @@ -26,6 +26,12 @@ export class LivechatTagRaw extends BaseRaw<ILivechatTag> implements ILivechatTa
return this.findOne(query, options);
}

findInIds(ids: string[], options?: FindOptions<ILivechatTag>): FindCursor<ILivechatTag> {
const query = { _id: { $in: ids } };

return this.find(query, options);
}

async createOrUpdateTag(
_id: string | undefined,
{ name, description }: { name: string; description?: string },
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/lib/callbacks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
ILivechatTag,
SelectedAgent,
InquiryWithAgentInfo,
ILivechatTagRecord,
} from '@rocket.chat/core-typings';
import { Random } from '@rocket.chat/random';

Expand Down Expand Up @@ -95,6 +96,7 @@ interface EventLikeCallbackSignatures {
'livechat.afterDepartmentDisabled': (department: ILivechatDepartmentRecord) => void;
'livechat.afterDepartmentArchived': (department: Pick<ILivechatDepartmentRecord, '_id'>) => void;
'afterSaveUser': ({ user, oldUser }: { user: IUser; oldUser: IUser | null }) => void;
'livechat.afterTagRemoved': (tag: ILivechatTagRecord) => void;
}

/**
Expand Down
Loading

0 comments on commit 28b41fb

Please sign in to comment.