Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/good-bobcats-argue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@rocket.chat/meteor": patch
---

Fixes a GUI crash when editing a canned response with tags via room contextual bar.
7 changes: 5 additions & 2 deletions apps/meteor/client/components/Omnichannel/Tags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { TextInput, Chip, Button, FieldLabel, FieldRow } from '@rocket.chat/fuse
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, ReactElement } from 'react';
import { useMemo, useState } from 'react';
import { useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';

import { FormSkeleton } from './Skeleton';
Expand All @@ -19,6 +19,7 @@ type TagsProps = {

const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps): ReactElement => {
const { t } = useTranslation();
const tagsFieldId = useId();

const { data: tagsResult, isLoading } = useLivechatTags({
department,
Expand Down Expand Up @@ -66,13 +67,14 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps)

return (
<>
<FieldLabel required={tagRequired} mb={4}>
<FieldLabel htmlFor={tagsFieldId} required={tagRequired} mb={4}>
{t('Tags')}
</FieldLabel>

{tagsResult?.tags && tagsResult?.tags.length ? (
<FieldRow>
<CurrentChatTags
id={tagsFieldId}
value={paginatedTagValue}
handler={(tags: { label: string; value: string }[]): void => {
handler(tags.map((tag) => tag.label));
Expand All @@ -87,6 +89,7 @@ const Tags = ({ tags = [], handler, error, tagRequired, department }: TagsProps)
<TextInput
error={error}
value={tagValue}
id={tagsFieldId}
onChange={({ currentTarget }: ChangeEvent<HTMLInputElement>): void => handleTagValue(currentTarget.value)}
flexGrow={1}
placeholder={t('Enter_a_tag')}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { useHasLicenseModule } from '../../hooks/useHasLicenseModule';
import AutoCompleteTagsMultiple from '../tags/AutoCompleteTagsMultiple';

type CurrentChatTagsProps = {
id?: string;
value: Array<{ value: string; label: string }>;
handler: (value: { label: string; value: string }[]) => void;
department?: string;
viewAll?: boolean;
};

const CurrentChatTags = ({ value, handler, department, viewAll }: CurrentChatTagsProps) => {
const CurrentChatTags = ({ id, value, handler, department, viewAll }: CurrentChatTagsProps) => {
const hasLicense = useHasLicenseModule('livechat-enterprise');

if (!hasLicense) {
Expand All @@ -17,6 +18,7 @@ const CurrentChatTags = ({ value, handler, department, viewAll }: CurrentChatTag

return (
<AutoCompleteTagsMultiple
id={id}
onChange={handler as any} // FIXME: any
value={value}
department={department}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,22 @@ import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.ch
import { Box } from '@rocket.chat/fuselage';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { memo, useCallback } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';

import GenericError from '../../../../components/GenericError';
import GenericModal from '../../../../components/GenericModal';
import type { CannedResponseEditFormData } from '../../CannedResponseEdit';
import CannedResponseForm from '../../components/CannedResponseForm';

export type CreateCannedResponseModalFormData = {
_id: string;
shortcut: string;
text: string;
tags: {
label: string;
value: string;
}[];
scope: string;
departmentId: string;
};

const getInitialData = (
cannedResponseData: (IOmnichannelCannedResponse & { departmentName: ILivechatDepartment['name'] }) | undefined,
) => ({
_id: cannedResponseData?._id || '',
shortcut: cannedResponseData?.shortcut || '',
text: cannedResponseData?.text || '',
tags:
cannedResponseData?.tags && Array.isArray(cannedResponseData.tags)
? cannedResponseData.tags.map((tag: string) => ({ label: tag, value: tag }))
: [],
tags: cannedResponseData?.tags || [],
scope: cannedResponseData?.scope || 'user',
departmentId: cannedResponseData?.departmentId || '',
});
Expand All @@ -44,7 +32,7 @@ const CreateCannedResponseModal = ({ cannedResponseData, onClose, reloadCannedLi
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();

const methods = useForm<CreateCannedResponseModalFormData>({ defaultValues: getInitialData(cannedResponseData) });
const methods = useForm<CannedResponseEditFormData>({ defaultValues: getInitialData(cannedResponseData) });
const {
handleSubmit,
formState: { isDirty },
Expand All @@ -53,7 +41,7 @@ const CreateCannedResponseModal = ({ cannedResponseData, onClose, reloadCannedLi
const saveCannedResponse = useEndpoint('POST', '/v1/canned-responses');

const handleCreate = useCallback(
async ({ departmentId, ...data }: CreateCannedResponseModalFormData) => {
async ({ departmentId, ...data }: CannedResponseEditFormData) => {
try {
await saveCannedResponse({
...data,
Expand Down Expand Up @@ -83,9 +71,11 @@ const CreateCannedResponseModal = ({ cannedResponseData, onClose, reloadCannedLi
title={cannedResponseData?._id ? t('Edit_Canned_Response') : t('Create_canned_response')}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleCreate)} {...props} />}
>
<FormProvider {...methods}>
<CannedResponseForm />
</FormProvider>
<ErrorBoundary fallbackRender={() => <GenericError icon='circle-exclamation' />}>
<FormProvider {...methods}>
<CannedResponseForm />
</FormProvider>
</ErrorBoundary>
</GenericModal>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AsyncStatePhase } from '../../hooks/useAsyncState';
import { useTagsList } from '../../hooks/useTagsList';

type AutoCompleteTagsMultipleProps = {
id?: string;
value?: PaginatedMultiSelectOption[];
onlyMyTags?: boolean;
onChange?: (value: PaginatedMultiSelectOption[]) => void;
Expand All @@ -17,6 +18,7 @@ type AutoCompleteTagsMultipleProps = {
};

const AutoCompleteTagsMultiple = ({
id,
value = [],
onlyMyTags = false,
onChange = () => undefined,
Expand Down Expand Up @@ -44,6 +46,7 @@ const AutoCompleteTagsMultiple = ({

return (
<PaginatedMultiSelectFiltered
id={id}
withTitle
value={value}
onChange={onChange}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';

import { createFakeVisitor } from '../../mocks/data';
import { IS_EE } from '../config/constants';
import { createAuxContext } from '../fixtures/createAuxContext';
import { Users } from '../fixtures/userStates';
import { OmnichannelLiveChat, HomeChannel } from '../page-objects';
import { OmnichannelLiveChat, HomeOmnichannel } from '../page-objects';
import { test } from '../utils/test';

test.describe('Omnichannel Canned Responses Sidebar', () => {
test.describe.serial('OC - Canned Responses Sidebar', () => {
test.skip(!IS_EE, 'Enterprise Only');

let poLiveChat: OmnichannelLiveChat;
let newVisitor: { email: string; name: string };

let agent: { page: Page; poHomeChannel: HomeChannel };
let agent: { page: Page; poHomeChannel: HomeOmnichannel };

const cannedResponseName = faker.string.uuid();

test.beforeAll(async ({ api, browser }) => {
newVisitor = createFakeVisitor();
Expand All @@ -23,34 +26,65 @@ test.describe('Omnichannel Canned Responses Sidebar', () => {
await api.post('/livechat/users/manager', { username: 'user1' });

const { page } = await createAuxContext(browser, Users.user1);
agent = { page, poHomeChannel: new HomeChannel(page) };
agent = { page, poHomeChannel: new HomeOmnichannel(page) };
});

test.beforeEach(async ({ page, api }) => {
poLiveChat = new OmnichannelLiveChat(page, api);
});

test.afterAll('close livechat conversation', async () => {
await agent.poHomeChannel.content.closeChat();
});

test.afterAll(async ({ api }) => {
await api.delete('/livechat/users/agent/user1');
await api.delete('/livechat/users/manager/user1');
await poLiveChat.page.close();
await agent.page.close();
});

test('Receiving a message from visitor', async ({ page }) => {
await test.step('Expect send a message as a visitor', async () => {
test('OC - Canned Responses Sidebar - Create', async ({ page }) => {
await test.step('expect send a message as a visitor', async () => {
await page.goto('/livechat');
await poLiveChat.openLiveChat();
await poLiveChat.sendMessage(newVisitor, false);
await poLiveChat.onlineAgentMessage.type('this_a_test_message_from_visitor');
await poLiveChat.onlineAgentMessage.fill('this_a_test_message_from_visitor');
await poLiveChat.btnSendMessageToOnlineAgent.click();
});

await test.step('Expect to have 1 omnichannel assigned to agent 1', async () => {
await test.step('expect to have 1 omnichannel assigned to agent 1', async () => {
await agent.poHomeChannel.sidenav.openChat(newVisitor.name);
});

await test.step('Expect to be able to open canned responses sidebar and creation', async () => {
await test.step('expect to be able to open canned responses sidebar and creation', async () => {
await agent.poHomeChannel.content.btnCannedResponses.click();
});

await test.step('expect to create new canned response', async () => {
await agent.poHomeChannel.content.btnNewCannedResponse.click();
await agent.poHomeChannel.cannedResponses.inputShortcut.fill(cannedResponseName);
await agent.poHomeChannel.cannedResponses.inputMessage.fill(faker.lorem.paragraph());
await agent.poHomeChannel.cannedResponses.addTag(faker.commerce.department());
await agent.poHomeChannel.cannedResponses.radioPublic.click();
await agent.poHomeChannel.cannedResponses.btnSave.click();
});
});

test('OC - Canned Responses Sidebar - Edit', async () => {
await test.step('expect to have 1 omnichannel assigned to agent 1', async () => {
await agent.poHomeChannel.sidenav.openChat(newVisitor.name);
});

await test.step('expect to be able to open canned responses sidebar and creation', async () => {
await agent.poHomeChannel.content.btnCannedResponses.click();
});

await test.step('expect to edit canned response', async () => {
await agent.poHomeChannel.cannedResponses.listItem(cannedResponseName).click();
await agent.poHomeChannel.cannedResponses.btnEdit.click();
await agent.poHomeChannel.cannedResponses.radioPrivate.click();
await agent.poHomeChannel.cannedResponses.btnSave.click();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -82,4 +82,10 @@ export class HomeOmnichannelContent extends HomeContent {
get infoHeaderName(): Locator {
return this.page.locator('.rcx-room-header').getByRole('heading');
}

async closeChat() {
await this.btnCloseChat.click();
await this.closeChatModal.inputComment.fill('any_comment');
await this.closeChatModal.btnConfirm.click();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,52 @@ import type { Locator } from '@playwright/test';
import { OmnichannelAdministration } from './omnichannel-administration';

export class OmnichannelCannedResponses extends OmnichannelAdministration {
get radioPublic(): Locator {
return this.page.locator('[data-qa-id="canned-response-public-radio"]').first();
get inputShortcut() {
return this.page.getByRole('textbox', { name: 'Shortcut', exact: true });
}

get inputMessage() {
return this.page.getByRole('textbox', { name: 'Message', exact: true });
}

get radioPublic() {
// NOTE: Playwright struggles to click the 1x1 px radio button, so we select the label instead
return this.page.locator('label', { hasText: /^Public$/ });
}

get radioDepartment() {
// NOTE: Playwright struggles to click the 1x1 px radio button, so we select the label instead
return this.page.locator('label', { hasText: /^Department$/ });
}

get radioPrivate() {
// NOTE: Playwright struggles to click the 1x1 px radio button, so we select the label instead
return this.page.locator('label', { hasText: /^Private$/ });
}

get inputTags() {
return this.page.getByRole('textbox', { name: 'Tags', exact: true });
}

get btnAddTag() {
return this.page.getByRole('button', { name: 'Add', exact: true });
}

listItem(name: string) {
return this.page.getByText(`!${name}`, { exact: true });
}

async addTag(tag: string) {
await this.inputTags.fill(tag);
await this.btnAddTag.click();
}

get btnEdit() {
return this.page.getByRole('button', { name: 'Edit', exact: true });
}

get btnSave(): Locator {
return this.page.getByRole('button', { name: 'Save', exact: true });
}

get btnNew(): Locator {
Expand Down