From 27f924967d0abcc6a0c2f4f239fc9bb659d7573c Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Fri, 23 Aug 2024 08:58:55 -0300 Subject: [PATCH 01/95] chore: remove left streaming code (#33039) --- .../server/functions/saveStreamingOptions.ts | 22 ------------------- .../server/methods/saveRoomSettings.ts | 6 ----- .../models/client/models/CachedChatRoom.ts | 1 - .../client/models/CachedChatSubscription.ts | 2 -- .../app/statistics/server/lib/statistics.ts | 1 - .../roomActions/useStartCallRoomAction.ts | 5 ++--- apps/meteor/lib/publishFields.ts | 1 - apps/meteor/server/models/raw/Rooms.ts | 18 --------------- packages/core-typings/src/IRoom.ts | 9 -------- packages/core-typings/src/IStats.ts | 1 - .../model-typings/src/models/IRoomsModel.ts | 3 --- .../src/types/SubscriptionWithRoom.ts | 1 - 12 files changed, 2 insertions(+), 68 deletions(-) delete mode 100644 apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts diff --git a/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts b/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts deleted file mode 100644 index aee596402cc6..000000000000 --- a/apps/meteor/app/channel-settings/server/functions/saveStreamingOptions.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Rooms } from '@rocket.chat/models'; -import { Match, check } from 'meteor/check'; -import { Meteor } from 'meteor/meteor'; - -export const saveStreamingOptions = async function (rid: string, options: Record): Promise { - if (!Match.test(rid, String)) { - throw new Meteor.Error('invalid-room', 'Invalid room', { - function: 'RocketChat.saveStreamingOptions', - }); - } - - check(options, { - id: Match.Optional(String), - type: Match.Optional(String), - url: Match.Optional(String), - thumbnail: Match.Optional(String), - isAudioOnly: Match.Optional(Boolean), - message: Match.Optional(String), - }); - - await Rooms.setStreamingOptionsById(rid, options); -}; diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 44ad253d83ef..04e8fdbaf186 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -21,7 +21,6 @@ import { saveRoomReadOnly } from '../functions/saveRoomReadOnly'; import { saveRoomSystemMessages } from '../functions/saveRoomSystemMessages'; import { saveRoomTopic } from '../functions/saveRoomTopic'; import { saveRoomType } from '../functions/saveRoomType'; -import { saveStreamingOptions } from '../functions/saveStreamingOptions'; type RoomSettings = { roomAvatar: string; @@ -37,7 +36,6 @@ type RoomSettings = { systemMessages: MessageTypesValues[]; default: boolean; joinCode: string; - streamingOptions: NonNullable; retentionEnabled: boolean; retentionMaxAge: number; retentionExcludePinned: boolean; @@ -272,9 +270,6 @@ const settingSavers: RoomSettingsSavers = { void Team.update(user._id, room.teamId, { type, updateRoom: false }); } }, - async streamingOptions({ value, rid }) { - await saveStreamingOptions(rid, value); - }, async readOnly({ value, room, rid, user }) { if (value !== room.ro) { await saveRoomReadOnly(rid, value, user); @@ -354,7 +349,6 @@ const fields: (keyof RoomSettings)[] = [ 'systemMessages', 'default', 'joinCode', - 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', diff --git a/apps/meteor/app/models/client/models/CachedChatRoom.ts b/apps/meteor/app/models/client/models/CachedChatRoom.ts index f66e5b447432..852bed5a6067 100644 --- a/apps/meteor/app/models/client/models/CachedChatRoom.ts +++ b/apps/meteor/app/models/client/models/CachedChatRoom.ts @@ -46,7 +46,6 @@ class CachedChatRoom extends CachedCollection { usernames: room.usernames, usersCount: room.usersCount, lastMessage: room.lastMessage, - streamingOptions: room.streamingOptions, teamId: room.teamId, teamMain: room.teamMain, v: (room as IOmnichannelRoom | undefined)?.v, diff --git a/apps/meteor/app/models/client/models/CachedChatSubscription.ts b/apps/meteor/app/models/client/models/CachedChatSubscription.ts index 0e325453539a..7c0e84800c77 100644 --- a/apps/meteor/app/models/client/models/CachedChatSubscription.ts +++ b/apps/meteor/app/models/client/models/CachedChatSubscription.ts @@ -35,7 +35,6 @@ class CachedChatSubscription extends CachedCollection { const federated = isRoomFederated(room); const ownUser = room.uids?.length === 1 ?? false; - const live = room?.streamingOptions?.type === 'call' ?? false; const permittedToPostReadonly = usePermission('post-readonly', room._id); const permittedToCallManagement = usePermission('call-management', room._id); @@ -81,8 +80,8 @@ export const useStartCallRoomAction = () => { disabled: true, }), full: true, - order: live ? -1 : 4, + order: 4, featured: true, }; - }, [allowed, disabled, groups, handleOpenVideoConf, live, t]); + }, [allowed, disabled, groups, handleOpenVideoConf, t]); }; diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 1e9274526ef5..c1483ea86cd1 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -86,7 +86,6 @@ export const roomFields = { reactWhenReadOnly: 1, sysMes: 1, sentiment: 1, - streamingOptions: 1, broadcast: 1, encrypted: 1, e2eKeyId: 1, diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 8f0c71623a96..96cd5a3a3acf 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -642,15 +642,6 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { ); } - findByActiveLivestream(options: FindOptions = {}): FindCursor { - return this.find( - { - 'streamingOptions.type': 'livestream', - }, - options, - ); - } - setAsFederated(roomId: IRoom['_id']): Promise { return this.updateOne({ _id: roomId }, { $set: { federated: true } }); } @@ -1030,15 +1021,6 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } - setStreamingOptionsById(_id: IRoom['_id'], streamingOptions: IRoom['streamingOptions']): Promise { - const update: UpdateFilter = { - $set: { - streamingOptions, - }, - }; - return this.updateOne({ _id }, update); - } - setReadOnlyById(_id: IRoom['_id'], readOnly: NonNullable): Promise { const query: Filter = { _id, diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 4a2124e98b98..1cfb672d1573 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -53,15 +53,6 @@ export interface IRoom extends IRocketChatRecord { _id: string; }; - streamingOptions?: { - id?: string; - type?: string; - url?: string; - thumbnail?: string; - isAudioOnly?: boolean; - message?: string; - }; - prid?: string; avatarETag?: string; diff --git a/packages/core-typings/src/IStats.ts b/packages/core-typings/src/IStats.ts index 826396bbb9c7..671024f7aa02 100644 --- a/packages/core-typings/src/IStats.ts +++ b/packages/core-typings/src/IStats.ts @@ -177,7 +177,6 @@ export interface IStats { dashboardCount: number; joinJitsiButton: number; totalBroadcastRooms: number; - totalRoomsWithActiveLivestream: number; totalTriggeredEmails: number; totalRoomsWithStarred: number; totalRoomsWithPinned: number; diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index fab810ccd30c..498a3c6b4bbc 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -112,8 +112,6 @@ export interface IRoomsModel extends IBaseModel { findByBroadcast(options?: FindOptions): FindCursor; - findByActiveLivestream(options?: FindOptions): FindCursor; - setAsFederated(roomId: IRoom['_id']): Promise; setRoomTypeById(roomId: IRoom['_id'], roomType: IRoom['t']): Promise; @@ -176,7 +174,6 @@ export interface IRoomsModel extends IBaseModel { setLastMessagePinned(roomId: string, pinnedBy: unknown, pinned?: boolean, pinnedAt?: Date): Promise; setLastMessageAsRead(roomId: string): Promise; setDescriptionById(roomId: string, description: string): Promise; - setStreamingOptionsById(roomId: string, streamingOptions: IRoom['streamingOptions']): Promise; setReadOnlyById(roomId: string, readOnly: NonNullable): Promise; setDmReadOnlyByUserId( roomId: string, diff --git a/packages/ui-contexts/src/types/SubscriptionWithRoom.ts b/packages/ui-contexts/src/types/SubscriptionWithRoom.ts index 908df94f0202..09f37886846c 100644 --- a/packages/ui-contexts/src/types/SubscriptionWithRoom.ts +++ b/packages/ui-contexts/src/types/SubscriptionWithRoom.ts @@ -9,7 +9,6 @@ export type SubscriptionWithRoom = ISubscription & | 'announcement' | 'avatarETag' | 'lastMessage' - | 'streamingOptions' | 'uids' | 'usernames' | 'usersCount' From 927710d778373dfa592e61a7bf35da87a496fffa Mon Sep 17 00:00:00 2001 From: Rafael Tapia Date: Fri, 23 Aug 2024 10:49:22 -0300 Subject: [PATCH 02/95] feat: create contact endpoint (#32693) --- .changeset/sixty-spoons-own.md | 9 + .../server/constant/permissions.ts | 4 + .../app/livechat/server/api/v1/contact.ts | 23 +- .../app/livechat/server/lib/Contacts.ts | 85 ++++- apps/meteor/server/models/LivechatContacts.ts | 6 + .../server/models/raw/LivechatContacts.ts | 11 + apps/meteor/server/models/startup.ts | 1 + .../tests/end-to-end/api/livechat/contacts.ts | 299 ++++++++++++++++++ .../app/livechat/server/lib/Contacts.spec.ts | 40 +++ packages/core-typings/src/ILivechatContact.ts | 25 ++ packages/core-typings/src/index.ts | 1 + packages/model-typings/src/index.ts | 1 + .../src/models/ILivechatContactsModel.ts | 5 + packages/models/src/index.ts | 2 + packages/rest-typings/src/v1/omnichannel.ts | 47 +++ 15 files changed, 555 insertions(+), 4 deletions(-) create mode 100644 .changeset/sixty-spoons-own.md create mode 100644 apps/meteor/server/models/LivechatContacts.ts create mode 100644 apps/meteor/server/models/raw/LivechatContacts.ts create mode 100644 apps/meteor/tests/end-to-end/api/livechat/contacts.ts create mode 100644 apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts create mode 100644 packages/core-typings/src/ILivechatContact.ts create mode 100644 packages/model-typings/src/models/ILivechatContactsModel.ts diff --git a/.changeset/sixty-spoons-own.md b/.changeset/sixty-spoons-own.md new file mode 100644 index 000000000000..0b717c3965ef --- /dev/null +++ b/.changeset/sixty-spoons-own.md @@ -0,0 +1,9 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/core-typings": minor +"@rocket.chat/model-typings": minor +"@rocket.chat/models": minor +"@rocket.chat/rest-typings": minor +--- + +Introduced "create contacts" endpoint to omnichannel diff --git a/apps/meteor/app/authorization/server/constant/permissions.ts b/apps/meteor/app/authorization/server/constant/permissions.ts index 6efe99e14d0e..d9ae4133e49e 100644 --- a/apps/meteor/app/authorization/server/constant/permissions.ts +++ b/apps/meteor/app/authorization/server/constant/permissions.ts @@ -93,6 +93,10 @@ export const permissions = [ _id: 'view-l-room', roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], }, + { + _id: 'create-livechat-contact', + roles: ['livechat-manager', 'livechat-monitor', 'livechat-agent', 'admin'], + }, { _id: 'view-livechat-manager', roles: ['livechat-manager', 'livechat-monitor', 'admin'] }, { _id: 'view-omnichannel-contact-center', diff --git a/apps/meteor/app/livechat/server/api/v1/contact.ts b/apps/meteor/app/livechat/server/api/v1/contact.ts index 57c1d117f1b0..91b18a6b21af 100644 --- a/apps/meteor/app/livechat/server/api/v1/contact.ts +++ b/apps/meteor/app/livechat/server/api/v1/contact.ts @@ -1,14 +1,18 @@ import { LivechatCustomField, LivechatVisitors } from '@rocket.chat/models'; +import { isPOSTOmnichannelContactsProps } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import { API } from '../../../../api/server'; -import { Contacts } from '../../lib/Contacts'; +import { Contacts, createContact } from '../../lib/Contacts'; API.v1.addRoute( 'omnichannel/contact', - { authRequired: true, permissionsRequired: ['view-l-room'] }, + { + authRequired: true, + permissionsRequired: ['view-l-room'], + }, { async post() { check(this.bodyParams, { @@ -82,3 +86,18 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'omnichannel/contacts', + { authRequired: true, permissionsRequired: ['create-livechat-contact'], validateParams: isPOSTOmnichannelContactsProps }, + { + async post() { + if (!process.env.TEST_MODE) { + throw new Meteor.Error('error-not-allowed', 'This endpoint is only allowed in test mode'); + } + const contactId = await createContact({ ...this.bodyParams, unknown: false }); + + return API.v1.success({ contactId }); + }, + }, +); diff --git a/apps/meteor/app/livechat/server/lib/Contacts.ts b/apps/meteor/app/livechat/server/lib/Contacts.ts index c20b5dbdb661..4f4a33ee61b2 100644 --- a/apps/meteor/app/livechat/server/lib/Contacts.ts +++ b/apps/meteor/app/livechat/server/lib/Contacts.ts @@ -1,5 +1,14 @@ -import type { ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom } from '@rocket.chat/core-typings'; -import { LivechatVisitors, Users, LivechatRooms, LivechatCustomField, LivechatInquiry, Rooms, Subscriptions } from '@rocket.chat/models'; +import type { ILivechatContactChannel, ILivechatCustomField, ILivechatVisitor, IOmnichannelRoom, IUser } from '@rocket.chat/core-typings'; +import { + LivechatVisitors, + Users, + LivechatRooms, + LivechatCustomField, + LivechatInquiry, + Rooms, + Subscriptions, + LivechatContacts, +} from '@rocket.chat/models'; import { check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; import type { MatchKeysAndValues, OnlyFieldsOfType } from 'mongodb'; @@ -26,6 +35,16 @@ type RegisterContactProps = { }; }; +type CreateContactParams = { + name: string; + emails: string[]; + phones: string[]; + unknown: boolean; + customFields?: Record; + contactManager?: string; + channels?: ILivechatContactChannel[]; +}; + export const Contacts = { async registerContact({ token, @@ -165,3 +184,65 @@ export const Contacts = { return contactId; }, }; + +export async function createContact(params: CreateContactParams): Promise { + const { name, emails, phones, customFields = {}, contactManager, channels, unknown } = params; + + if (contactManager) { + const contactManagerUser = await Users.findOneAgentById>(contactManager, { projection: { roles: 1 } }); + if (!contactManagerUser) { + throw new Error('error-contact-manager-not-found'); + } + } + + const allowedCustomFields = await getAllowedCustomFields(); + validateCustomFields(allowedCustomFields, customFields); + + const { insertedId } = await LivechatContacts.insertOne({ + name, + emails, + phones, + contactManager, + channels, + customFields, + unknown, + }); + + return insertedId; +} + +async function getAllowedCustomFields(): Promise { + return LivechatCustomField.findByScope( + 'visitor', + { + projection: { _id: 1, label: 1, regexp: 1, required: 1 }, + }, + false, + ).toArray(); +} + +export function validateCustomFields(allowedCustomFields: ILivechatCustomField[], customFields: Record) { + for (const cf of allowedCustomFields) { + if (!customFields.hasOwnProperty(cf._id)) { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + const cfValue: string = trim(customFields[cf._id]); + + if (!cfValue || typeof cfValue !== 'string') { + if (cf.required) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + continue; + } + + if (cf.regexp) { + const regex = new RegExp(cf.regexp); + if (!regex.test(cfValue)) { + throw new Error(i18n.t('error-invalid-custom-field-value', { field: cf.label })); + } + } + } +} diff --git a/apps/meteor/server/models/LivechatContacts.ts b/apps/meteor/server/models/LivechatContacts.ts new file mode 100644 index 000000000000..d341ae87b021 --- /dev/null +++ b/apps/meteor/server/models/LivechatContacts.ts @@ -0,0 +1,6 @@ +import { registerModel } from '@rocket.chat/models'; + +import { db } from '../database/utils'; +import { LivechatContactsRaw } from './raw/LivechatContacts'; + +registerModel('ILivechatContactsModel', new LivechatContactsRaw(db)); diff --git a/apps/meteor/server/models/raw/LivechatContacts.ts b/apps/meteor/server/models/raw/LivechatContacts.ts new file mode 100644 index 000000000000..1f5f29a3cc78 --- /dev/null +++ b/apps/meteor/server/models/raw/LivechatContacts.ts @@ -0,0 +1,11 @@ +import type { ILivechatContact, RocketChatRecordDeleted } from '@rocket.chat/core-typings'; +import type { ILivechatContactsModel } from '@rocket.chat/model-typings'; +import type { Collection, Db } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class LivechatContactsRaw extends BaseRaw implements ILivechatContactsModel { + constructor(db: Db, trash?: Collection>) { + super(db, 'livechat_contact', trash); + } +} diff --git a/apps/meteor/server/models/startup.ts b/apps/meteor/server/models/startup.ts index 3d6dc6066689..eaca155674f5 100644 --- a/apps/meteor/server/models/startup.ts +++ b/apps/meteor/server/models/startup.ts @@ -22,6 +22,7 @@ import './Integrations'; import './Invites'; import './LivechatAgentActivity'; import './LivechatBusinessHours'; +import './LivechatContacts'; import './LivechatCustomField'; import './LivechatDepartment'; import './LivechatDepartmentAgents'; diff --git a/apps/meteor/tests/end-to-end/api/livechat/contacts.ts b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts new file mode 100644 index 000000000000..21eced5ee7e9 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/livechat/contacts.ts @@ -0,0 +1,299 @@ +import { faker } from '@faker-js/faker'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; + +import { getCredentials, api, request, credentials } from '../../../data/api-data'; +import { createCustomField, deleteCustomField } from '../../../data/livechat/custom-fields'; +import { createAgent } from '../../../data/livechat/rooms'; +import { removePermissionFromAllRoles, restorePermissionToRoles, updatePermission, updateSetting } from '../../../data/permissions.helper'; +import { createUser, deleteUser } from '../../../data/users.helper'; + +describe('LIVECHAT - contacts', () => { + before((done) => getCredentials(done)); + + before(async () => { + await updateSetting('Livechat_enabled', true); + await updatePermission('create-livechat-contact', ['admin']); + }); + + after(async () => { + await restorePermissionToRoles('create-livechat-contact'); + await updateSetting('Livechat_enabled', true); + }); + + describe('[POST] omnichannel/contacts', () => { + it('should be able to create a new contact', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + it("should return an error if user doesn't have 'create-livechat-contact' permission", async () => { + await removePermissionFromAllRoles('create-livechat-contact'); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body.error).to.be.equal('User does not have the permissions required for this action [error-unauthorized]'); + + await restorePermissionToRoles('create-livechat-contact'); + }); + + it('should return an error if contact manager not exists', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + }); + + it('should return an error if contact manager is not a livechat-agent', async () => { + const normalUser = await createUser(); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: normalUser._id, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('error-contact-manager-not-found'); + + await deleteUser(normalUser); + }); + + it('should be able to create a new contact with a contact manager', async () => { + const user = await createUser(); + const livechatAgent = await createAgent(user.username); + + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + contactManager: livechatAgent._id, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + + await deleteUser(user); + }); + + describe('Custom Fields', () => { + before(async () => { + await createCustomField({ + field: 'cf1', + label: 'Custom Field 1', + scope: 'visitor', + visibility: 'public', + type: 'input', + required: true, + regexp: '^[0-9]+$', + searchable: true, + public: true, + }); + }); + + after(async () => { + await deleteCustomField('cf1'); + }); + + it('should validate custom fields correctly', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: '123', + }, + }); + + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('contactId'); + expect(res.body.contactId).to.be.an('string'); + }); + + it('should return an error for missing required custom field', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: {}, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + + it('should return an error for invalid custom field value', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + customFields: { + cf1: 'invalid', + }, + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('Invalid value for Custom Field 1 field'); + }); + }); + + describe('Fields Validation', () => { + it('should return an error if name is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'name' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'emails' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is missing', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal("must have required property 'phones' [invalid-params]"); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: 'invalid', + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be array [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if emails is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [{ invalid: true }], + phones: [faker.phone.number()], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if phones is not an array of strings', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [{ invalid: true }], + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must be string [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + + it('should return an error if additional fields are provided', async () => { + const res = await request + .post(api('omnichannel/contacts')) + .set(credentials) + .send({ + name: faker.person.fullName(), + emails: [faker.internet.email().toLowerCase()], + phones: [faker.phone.number()], + additional: 'invalid', + }); + + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error'); + expect(res.body.error).to.be.equal('must NOT have additional properties [invalid-params]'); + expect(res.body.errorType).to.be.equal('invalid-params'); + }); + }); + }); +}); diff --git a/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts new file mode 100644 index 000000000000..9ff2019ffca5 --- /dev/null +++ b/apps/meteor/tests/unit/app/livechat/server/lib/Contacts.spec.ts @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import proxyquire from 'proxyquire'; +import sinon from 'sinon'; + +const { validateCustomFields } = proxyquire.noCallThru().load('../../../../../../app/livechat/server/lib/Contacts', { + 'meteor/check': sinon.stub(), + 'meteor/meteor': sinon.stub(), +}); + +describe('[OC] Contacts', () => { + describe('validateCustomFields', () => { + const mockCustomFields = [{ _id: 'cf1', label: 'Custom Field 1', regexp: '^[0-9]+$', required: true }]; + + it('should validate custom fields correctly', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: '123' })).to.not.throw(); + }); + + it('should throw an error if a required custom field is missing', () => { + expect(() => validateCustomFields(mockCustomFields, {})).to.throw(); + }); + + it('should NOT throw an error when a non-required custom field is missing', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + + it('should throw an error if a custom field value does not match the regexp', () => { + expect(() => validateCustomFields(mockCustomFields, { cf1: 'invalid' })).to.throw(); + }); + + it('should handle an empty customFields input without throwing an error', () => { + const allowedCustomFields = [{ _id: 'field1', label: 'Field 1', required: false }]; + const customFields = {}; + + expect(() => validateCustomFields(allowedCustomFields, customFields)).not.to.throw(); + }); + }); +}); diff --git a/packages/core-typings/src/ILivechatContact.ts b/packages/core-typings/src/ILivechatContact.ts new file mode 100644 index 000000000000..149dab2b88b1 --- /dev/null +++ b/packages/core-typings/src/ILivechatContact.ts @@ -0,0 +1,25 @@ +import type { IRocketChatRecord } from './IRocketChatRecord'; + +export interface ILivechatContactChannel { + name: string; + verified: boolean; + visitorId: string; +} + +export interface ILivechatContactConflictingField { + field: string; + oldValue: string; + newValue: string; +} + +export interface ILivechatContact extends IRocketChatRecord { + name: string; + phones: string[]; + emails: string[]; + contactManager?: string; + unknown?: boolean; + hasConflict?: boolean; + conflictingFields?: ILivechatContactConflictingField[]; + customFields?: Record; + channels?: ILivechatContactChannel[]; +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index c04ffa998d77..5d2e2935a466 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -96,6 +96,7 @@ export * from './ILivechatCustomField'; export * from './IOmnichannel'; export * from './ILivechatAgentActivity'; export * from './ILivechatBusinessHour'; +export * from './ILivechatContact'; export * from './ILivechatVisitor'; export * from './ILivechatDepartmentAgents'; export * from './ILivechatAgent'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 61ad5d1f5c55..83def2bd19b2 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -21,6 +21,7 @@ export * from './models/IInvitesModel'; export * from './models/IImportDataModel'; export * from './models/ILivechatAgentActivityModel'; export * from './models/ILivechatBusinessHoursModel'; +export * from './models/ILivechatContactsModel'; export * from './models/ILivechatCustomFieldModel'; export * from './models/ILivechatDepartmentAgentsModel'; export * from './models/ILivechatDepartmentModel'; diff --git a/packages/model-typings/src/models/ILivechatContactsModel.ts b/packages/model-typings/src/models/ILivechatContactsModel.ts new file mode 100644 index 000000000000..bcf48a837400 --- /dev/null +++ b/packages/model-typings/src/models/ILivechatContactsModel.ts @@ -0,0 +1,5 @@ +import type { ILivechatContact } from '@rocket.chat/core-typings'; + +import type { IBaseModel } from './IBaseModel'; + +export type ILivechatContactsModel = IBaseModel; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 655a94923feb..eb357ed293ef 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -20,6 +20,7 @@ import type { IImportDataModel, ILivechatAgentActivityModel, ILivechatBusinessHoursModel, + ILivechatContactsModel, ILivechatCustomFieldModel, ILivechatDepartmentAgentsModel, ILivechatDepartmentModel, @@ -117,6 +118,7 @@ export const Integrations = proxify('IIntegrationsModel'); export const Invites = proxify('IInvitesModel'); export const LivechatAgentActivity = proxify('ILivechatAgentActivityModel'); export const LivechatBusinessHours = proxify('ILivechatBusinessHoursModel'); +export const LivechatContacts = proxify('ILivechatContactsModel'); export const LivechatCustomField = proxify('ILivechatCustomFieldModel'); export const LivechatDepartmentAgents = proxify('ILivechatDepartmentAgentsModel'); export const LivechatDepartment = proxify('ILivechatDepartmentModel'); diff --git a/packages/rest-typings/src/v1/omnichannel.ts b/packages/rest-typings/src/v1/omnichannel.ts index b8519bf8fe02..c15e94030de3 100644 --- a/packages/rest-typings/src/v1/omnichannel.ts +++ b/packages/rest-typings/src/v1/omnichannel.ts @@ -1211,6 +1211,49 @@ const POSTOmnichannelContactSchema = { export const isPOSTOmnichannelContactProps = ajv.compile(POSTOmnichannelContactSchema); +type POSTOmnichannelContactsProps = { + name: string; + emails: string[]; + phones: string[]; + customFields?: Record; + contactManager?: string; +}; + +const POSTOmnichannelContactsSchema = { + type: 'object', + properties: { + name: { + type: 'string', + }, + emails: { + type: 'array', + items: { + type: 'string', + }, + uniqueItems: true, + }, + phones: { + type: 'array', + items: { + type: 'string', + }, + uniqueItems: true, + }, + customFields: { + type: 'object', + nullable: true, + }, + contactManager: { + type: 'string', + nullable: true, + }, + }, + required: ['name', 'emails', 'phones'], + additionalProperties: false, +}; + +export const isPOSTOmnichannelContactsProps = ajv.compile(POSTOmnichannelContactsSchema); + type GETOmnichannelContactProps = { contactId: string }; const GETOmnichannelContactSchema = { @@ -3649,6 +3692,10 @@ export type OmnichannelEndpoints = { GET: (params: GETOmnichannelContactProps) => { contact: ILivechatVisitor | null }; }; + '/v1/omnichannel/contacts': { + POST: (params: POSTOmnichannelContactsProps) => { contactId: string }; + }; + '/v1/omnichannel/contact.search': { GET: (params: GETOmnichannelContactSearchProps) => { contact: ILivechatVisitor | null }; }; From c2609d7cca77cc4c3f342724e42a1781647cb516 Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Fri, 23 Aug 2024 12:10:09 -0300 Subject: [PATCH 03/95] refactor: Realtime `Doughnout Charts` to TS (#33092) --- .../app/livechat/client/lib/chartHandler.ts | 13 +++++------ ...entStatusChart.js => AgentStatusChart.tsx} | 23 ++++++++++++++----- .../charts/{ChatsChart.js => ChatsChart.tsx} | 23 ++++++++++++++----- .../charts/useUpdateChartData.ts | 14 +++++------ packages/i18n/src/locales/en.i18n.json | 1 + packages/i18n/src/locales/pt-BR.i18n.json | 1 + 6 files changed, 49 insertions(+), 26 deletions(-) rename apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/{AgentStatusChart.js => AgentStatusChart.tsx} (61%) rename apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/{ChatsChart.js => ChatsChart.tsx} (61%) diff --git a/apps/meteor/app/livechat/client/lib/chartHandler.ts b/apps/meteor/app/livechat/client/lib/chartHandler.ts index 19c1a004ca22..da2d4be3735c 100644 --- a/apps/meteor/app/livechat/client/lib/chartHandler.ts +++ b/apps/meteor/app/livechat/client/lib/chartHandler.ts @@ -177,10 +177,9 @@ export const drawDoughnutChart = async ( chartContext: { destroy: () => void } | undefined, dataLabels: string[], dataPoints: number[], -): Promise | void> => { +): Promise => { if (!chart) { - console.error('No chart element'); - return; + throw new Error('No chart element'); } if (chartContext) { chartContext.destroy(); @@ -200,7 +199,7 @@ export const drawDoughnutChart = async ( ], }, options: doughnutChartConfiguration(title), - }); + }) as ChartType; }; /** @@ -209,12 +208,12 @@ export const drawDoughnutChart = async ( * @param {String} label [chart label] * @param {Array(Double)} data [updated data] */ -export const updateChart = async (c: ChartType, label: string, data: { [x: string]: number }): Promise => { +export const updateChart = async (c: ChartType, label: string, data: number[]): Promise => { const chart = await c; if (chart.data?.labels?.indexOf(label) === -1) { // insert data chart.data.labels.push(label); - chart.data.datasets.forEach((dataset: { data: any[] }, idx: string | number) => { + chart.data.datasets.forEach((dataset: { data: any[] }, idx: number) => { dataset.data.push(data[idx]); }); } else { @@ -224,7 +223,7 @@ export const updateChart = async (c: ChartType, label: string, data: { [x: strin return; } - chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: string | number) => { + chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: number) => { dataset.data[index] = data[idx]; }); } diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.tsx similarity index 61% rename from apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js rename to apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.tsx index 4724bea74350..f1b2d0eed337 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/AgentStatusChart.tsx @@ -1,4 +1,8 @@ +import type { OperationParams } from '@rocket.chat/rest-typings'; +import type { TranslationContextValue, TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { Chart as ChartType } from 'chart.js'; +import type { MutableRefObject } from 'react'; import React, { useRef, useEffect } from 'react'; import { drawDoughnutChart } from '../../../../../app/livechat/client/lib/chartHandler'; @@ -16,20 +20,25 @@ const initialData = { offline: 0, }; -const init = (canvas, context, t) => +const init = (canvas: HTMLCanvasElement, context: ChartType | undefined, t: TranslationContextValue['translate']): Promise => drawDoughnutChart( canvas, t('Agents'), context, - labels.map((l) => t(l)), + labels.map((l) => t(l as TranslationKey)), Object.values(initialData), ); -const AgentStatusChart = ({ params, reloadRef, ...props }) => { +type AgentStatusChartsProps = { + params: OperationParams<'GET', '/v1/livechat/analytics/dashboards/charts/agents-status'>; + reloadRef: MutableRefObject<{ [x: string]: () => void }>; +}; + +const AgentStatusChart = ({ params, reloadRef, ...props }: AgentStatusChartsProps) => { const t = useTranslation(); - const canvas = useRef(); - const context = useRef(); + const canvas: MutableRefObject = useRef(null); + const context: MutableRefObject = useRef(); const updateChartData = useUpdateChartData({ context, @@ -46,7 +55,9 @@ const AgentStatusChart = ({ params, reloadRef, ...props }) => { useEffect(() => { const initChart = async () => { - context.current = await init(canvas.current, context.current, t); + if (canvas?.current) { + context.current = await init(canvas.current, context.current, t); + } }; initChart(); }, [t]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.tsx similarity index 61% rename from apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js rename to apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.tsx index 5a540dcd2dbd..b84dfe6918a2 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.js +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/ChatsChart.tsx @@ -1,4 +1,8 @@ +import type { OperationParams } from '@rocket.chat/rest-typings'; +import type { TranslationContextValue, TranslationKey } from '@rocket.chat/ui-contexts'; import { useTranslation } from '@rocket.chat/ui-contexts'; +import type { Chart as ChartType } from 'chart.js'; +import type { MutableRefObject } from 'react'; import React, { useRef, useEffect } from 'react'; import { drawDoughnutChart } from '../../../../../app/livechat/client/lib/chartHandler'; @@ -16,20 +20,25 @@ const initialData = { closed: 0, }; -const init = (canvas, context, t) => +const init = (canvas: HTMLCanvasElement, context: ChartType | undefined, t: TranslationContextValue['translate']) => drawDoughnutChart( canvas, t('Chats'), context, - labels.map((l) => t(l)), + labels.map((l) => t(l as TranslationKey)), Object.values(initialData), ); -const ChatsChart = ({ params, reloadRef, ...props }) => { +type ChatsChartProps = { + params: OperationParams<'GET', '/v1/livechat/analytics/dashboards/charts/chats'>; + reloadRef: MutableRefObject<{ [x: string]: () => void }>; +}; + +const ChatsChart = ({ params, reloadRef, ...props }: ChatsChartProps) => { const t = useTranslation(); - const canvas = useRef(); - const context = useRef(); + const canvas: MutableRefObject = useRef(null); + const context: MutableRefObject = useRef(); const updateChartData = useUpdateChartData({ context, @@ -46,7 +55,9 @@ const ChatsChart = ({ params, reloadRef, ...props }) => { useEffect(() => { const initChart = async () => { - context.current = await init(canvas.current, context.current, t); + if (canvas?.current) { + context.current = await init(canvas.current, context.current, t); + } }; initChart(); }, [t]); diff --git a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts index 805d828a9893..d942b93a4223 100644 --- a/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts +++ b/apps/meteor/client/views/omnichannel/realTimeMonitoring/charts/useUpdateChartData.ts @@ -1,19 +1,19 @@ import { useMutableCallback } from '@rocket.chat/fuselage-hooks'; +import type { TranslationContextValue } from '@rocket.chat/ui-contexts'; import { type Chart } from 'chart.js'; -import { type TFunction } from 'i18next'; -import { type RefObject } from 'react'; +import { type MutableRefObject } from 'react'; import { updateChart } from '../../../../../app/livechat/client/lib/chartHandler'; type UseUpdateChartDataOptions = { - context: RefObject; - canvas: RefObject; - init: (canvas: HTMLCanvasElement, context: undefined, t: TFunction) => Promise; - t: TFunction; + context: MutableRefObject; + canvas: MutableRefObject; + init: (canvas: HTMLCanvasElement, context: undefined, t: TranslationContextValue['translate']) => Promise; + t: TranslationContextValue['translate']; }; export const useUpdateChartData = ({ context: contextRef, canvas: canvasRef, init, t }: UseUpdateChartDataOptions) => - useMutableCallback(async (label: string, data: { [x: string]: number }) => { + useMutableCallback(async (label: string, data: number[]) => { const canvas = canvasRef.current; if (!canvas) { diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 204cc75b79f5..159c1641e05e 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -989,6 +989,7 @@ "Channels_list": "List of public channels", "Channel_what_is_this_channel_about": "What is this channel about?", "Chart": "Chart", + "Chats": "Chats", "Chat_button": "Chat button", "Chat_close": "Chat Close", "Chat_closed": "Chat closed", diff --git a/packages/i18n/src/locales/pt-BR.i18n.json b/packages/i18n/src/locales/pt-BR.i18n.json index c1ebbc28ca3b..f072bc626270 100644 --- a/packages/i18n/src/locales/pt-BR.i18n.json +++ b/packages/i18n/src/locales/pt-BR.i18n.json @@ -833,6 +833,7 @@ "Channels_list": "Lista de canais públicos", "Channel_what_is_this_channel_about": "Sobre o que é este canal?", "Chart": "Gráfico", + "Chats": "Conversas", "Chat_button": "Botão da conversa", "Chat_close": "Fechar conversa", "Chat_closed": "Conversa encerrada", From eaf9c8d23d09e3a436448bbcdf38e4fa38e8bf4a Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Fri, 23 Aug 2024 12:46:48 -0300 Subject: [PATCH 04/95] chore: Revive Livechat Storybook (#33137) --- packages/livechat/.storybook/main.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/livechat/.storybook/main.ts b/packages/livechat/.storybook/main.ts index 8809ce6be899..8103755e748d 100644 --- a/packages/livechat/.storybook/main.ts +++ b/packages/livechat/.storybook/main.ts @@ -30,7 +30,7 @@ const config: StorybookConfig = { [require.resolve('../src/lib/uiKit')]: require.resolve('./mocks/uiKit.ts'), }; - const isRuleSetRule = (rule: any): rule is RuleSetRule => typeof rule === 'object' && rule.test && rule.use; + const isRuleSetRule = (rule: any): rule is RuleSetRule => typeof rule === 'object'; config.module.rules ??= []; @@ -54,6 +54,15 @@ const config: StorybookConfig = { } urlLoader.test = /\.(webm|wav|m4a|aac|oga)(\?.*)?$/; + config.module.rules.push({ + test: /\.mjs$/, + include: /node_modules/, + type: 'javascript/auto', + use: { + loader: require.resolve('babel-loader'), + }, + }); + config.module.rules.push({ test: /\.scss$/, use: [ From 5c18a1ff3af537de3a8af9e3536353e578069bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=BAlia=20Jaeger=20Foresti?= <60678893+juliajforesti@users.noreply.github.com> Date: Mon, 26 Aug 2024 15:28:36 -0300 Subject: [PATCH 05/95] feat: add Advanced settings accordion to `EditRoomInfo` (#33114) Co-authored-by: Douglas Fabris <27704687+dougfabris@users.noreply.github.com> --- .changeset/stupid-pigs-share.md | 5 + .../Info/EditRoomInfo/EditRoomInfo.tsx | 419 +++++++++--------- apps/meteor/tests/e2e/e2e-encryption.spec.ts | 1 + .../fragments/home-flextab-room.ts | 4 + .../meteor/tests/e2e/retention-policy.spec.ts | 1 + apps/meteor/tests/e2e/team-management.spec.ts | 1 + 6 files changed, 232 insertions(+), 199 deletions(-) create mode 100644 .changeset/stupid-pigs-share.md diff --git a/.changeset/stupid-pigs-share.md b/.changeset/stupid-pigs-share.md new file mode 100644 index 000000000000..55d68c66d587 --- /dev/null +++ b/.changeset/stupid-pigs-share.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Wraps some room settings in an accordion advanced settings section in room edit contextual bar to improve organization diff --git a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx index ce2a7862bb0b..b49ddb845cdd 100644 --- a/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx +++ b/apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.tsx @@ -1,3 +1,4 @@ +/* eslint-disable complexity */ import type { IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'; import { isRoomFederated } from '@rocket.chat/core-typings'; import type { SelectOption } from '@rocket.chat/fuselage'; @@ -19,6 +20,7 @@ import { ButtonGroup, Box, TextAreaInput, + AccordionItem, } from '@rocket.chat/fuselage'; import { useEffectEvent, useUniqueId } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; @@ -223,6 +225,11 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => const retentionFilesOnlyField = useUniqueId(); const retentionIgnoreThreads = useUniqueId(); + const showAdvancedSettings = canViewEncrypted || canViewReadOnly || readOnly || canViewArchived || canViewJoinCode || canViewHideSysMes; + const showRetentionPolicy = canEditRoomRetentionPolicy && retentionPolicy?.enabled; + + const showAccordion = showAdvancedSettings || showRetentionPolicy; + return ( <> @@ -315,7 +322,6 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => )} - {canViewType && ( @@ -344,243 +350,258 @@ const EditRoomInfo = ({ room, onClickClose, onClickBack }: EditRoomInfoProps) => )} - {canViewEncrypted && ( - - - {t('Encrypted')} - ( - - )} - /> - - - {t('Encrypted_field_hint')} - - - )} - {canViewReadOnly && ( - - - {t('Read_only')} - ( - - )} - /> - - - {readOnly ? t('Read_only_field_hint_enabled', { roomType }) : t('Read_only_field_hint_disabled')} - - - )} - {readOnly && ( - - - {t('React_when_read_only')} - ( - - )} - /> - - - - {reactWhenReadOnly ? t('Anyone_can_react_to_messages') : t('Only_authorized_users_can_react_to_messages')} - - - - )} - {canViewArchived && ( - - - {t('Room_archivation_state_true')} - ( - - )} - /> - - {archived && ( - - {t('New_messages_cannot_be_sent')} - - )} - - )} - {canViewJoinCode && ( - - - {t('Password_to_access')} - ( - - )} - /> - - {joinCodeRequired && ( - - } - /> - - )} - - )} - {canViewHideSysMes && ( - - - {t('Hide_System_Messages')} - ( - - )} - /> - - - ( - - )} - /> - - - )} - {canEditRoomRetentionPolicy && retentionPolicy?.enabled && ( + {showAccordion && ( - - - - - {t('RetentionPolicyRoom_Enabled')} - } - /> - - - - - {t('RetentionPolicyRoom_OverrideGlobal')} - ( - - )} - /> - - - {retentionOverrideGlobal && ( - <> - - {t('RetentionPolicyRoom_ReadTheDocs')} - + {showAdvancedSettings && ( + + + + {t('Security_and_permissions')} + + {canViewEncrypted && ( - - {t('RetentionPolicyRoom_MaxAge', { max: retentionMaxAgeDefault })} - + {t('Encrypted')} ( - ( + ) => onChange(Number(e.currentTarget.value))} + disabled={!canToggleEncryption || isFederated} + checked={value} /> )} /> + + {t('Encrypted_field_hint')} + + )} + {canViewReadOnly && ( - {t('RetentionPolicyRoom_ExcludePinned')} + {t('Read_only')} ( - + )} /> + + {readOnly ? t('Read_only_field_hint_enabled', { roomType }) : t('Read_only_field_hint_disabled')} + + )} + {readOnly && ( - {t('RetentionPolicyRoom_FilesOnly')} + {t('React_when_read_only')} ( - + )} /> + + + {reactWhenReadOnly ? t('Anyone_can_react_to_messages') : t('Only_authorized_users_can_react_to_messages')} + + + )} + {canViewArchived && ( - {t('RetentionPolicy_DoNotPruneThreads')} + {t('Room_archivation_state_true')} ( - + )} /> + {archived && ( + + {t('New_messages_cannot_be_sent')} + + )} - - )} - - + )} + {canViewJoinCode && ( + + + {t('Password_to_access')} + ( + + )} + /> + + {joinCodeRequired && ( + + ( + + )} + /> + + )} + + )} + {canViewHideSysMes && ( + + + {t('Hide_System_Messages')} + ( + + )} + /> + + + ( + + )} + /> + + + )} + + + )} + {showRetentionPolicy && ( + + + + + {t('RetentionPolicyRoom_Enabled')} + ( + + )} + /> + + + + + {t('RetentionPolicyRoom_OverrideGlobal')} + ( + + )} + /> + + + {retentionOverrideGlobal && ( + <> + + {t('RetentionPolicyRoom_ReadTheDocs')} + + + + {t('RetentionPolicyRoom_MaxAge', { max: retentionMaxAgeDefault })} + + + ( + ) => onChange(Number(e.currentTarget.value))} + /> + )} + /> + + + + + {t('RetentionPolicyRoom_ExcludePinned')} + ( + + )} + /> + + + + + {t('RetentionPolicyRoom_FilesOnly')} + ( + + )} + /> + + + + + {t('RetentionPolicy_DoNotPruneThreads')} + ( + + )} + /> + + + + )} + + + )} )} diff --git a/apps/meteor/tests/e2e/e2e-encryption.spec.ts b/apps/meteor/tests/e2e/e2e-encryption.spec.ts index d98acaf847c4..8c6297e9975d 100644 --- a/apps/meteor/tests/e2e/e2e-encryption.spec.ts +++ b/apps/meteor/tests/e2e/e2e-encryption.spec.ts @@ -280,6 +280,7 @@ test.describe.serial('e2e-encryption', () => { await poHomeChannel.tabs.btnRoomInfo.click(); await poHomeChannel.tabs.room.btnEdit.click(); + await poHomeChannel.tabs.room.advancedSettingsAccordion.click(); await expect(poHomeChannel.tabs.room.checkboxEncrypted).toBeVisible(); }); diff --git a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts index a76d89a9d962..78d536448a75 100644 --- a/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts +++ b/apps/meteor/tests/e2e/page-objects/fragments/home-flextab-room.ts @@ -51,6 +51,10 @@ export class HomeFlextabRoom { return this.page.getByRole('dialog').getByRole('alert', { name: 'Retention policy warning callout' }); } + get advancedSettingsAccordion(): Locator { + return this.page.getByRole('dialog').getByRole('button', { name: 'Advanced settings' }); + } + get pruneAccordion(): Locator { return this.page.getByRole('dialog').getByRole('button', { name: 'Prune', exact: true }); } diff --git a/apps/meteor/tests/e2e/retention-policy.spec.ts b/apps/meteor/tests/e2e/retention-policy.spec.ts index 708e5f19b520..4f6b9ce3ff23 100644 --- a/apps/meteor/tests/e2e/retention-policy.spec.ts +++ b/apps/meteor/tests/e2e/retention-policy.spec.ts @@ -115,6 +115,7 @@ test.describe.serial('retention-policy', () => { await auxContext.poHomeChannel.sidenav.openChat(targetChannel); await auxContext.poHomeChannel.tabs.btnRoomInfo.click(); await auxContext.poHomeChannel.tabs.room.btnEdit.click(); + await auxContext.poHomeChannel.tabs.room.advancedSettingsAccordion.click(); await auxContext.poHomeChannel.tabs.room.checkboxReadOnly.check(); await auxContext.poHomeChannel.tabs.room.btnSave.click(); diff --git a/apps/meteor/tests/e2e/team-management.spec.ts b/apps/meteor/tests/e2e/team-management.spec.ts index 55530302ca96..350d55dfaab6 100644 --- a/apps/meteor/tests/e2e/team-management.spec.ts +++ b/apps/meteor/tests/e2e/team-management.spec.ts @@ -77,6 +77,7 @@ test.describe.serial('teams-management', () => { await poHomeTeam.sidenav.openChat(targetTeam); await poHomeTeam.tabs.btnRoomInfo.click(); await poHomeTeam.tabs.room.btnEdit.click(); + await poHomeTeam.tabs.room.advancedSettingsAccordion.click(); await poHomeTeam.tabs.room.checkboxReadOnly.click(); await poHomeTeam.tabs.room.btnSave.click(); }); From bf03fe82b98984d6610c6ab25345c1ed397702fb Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 27 Aug 2024 17:20:40 -0300 Subject: [PATCH 06/95] test: Add e2e tests for teams multi-step modals (#33168) --- .../contextualBar/channels/TeamsChannels.tsx | 2 +- .../teams/contextualBar/info/TeamsInfo.tsx | 3 +- .../tests/e2e/channel-management.spec.ts | 32 +++--- .../page-objects/fragments/home-content.ts | 14 +++ .../fragments/home-flextab-channels.ts | 24 +++++ .../fragments/home-flextab-members.ts | 8 ++ .../fragments/home-flextab-room.ts | 40 +++++++ .../page-objects/fragments/home-flextab.ts | 4 + .../page-objects/fragments/home-sidenav.ts | 16 ++- .../tests/e2e/page-objects/home-channel.ts | 13 --- .../meteor/tests/e2e/retention-policy.spec.ts | 2 +- apps/meteor/tests/e2e/team-management.spec.ts | 102 +++++++++++++++--- 12 files changed, 210 insertions(+), 50 deletions(-) diff --git a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx index 8bad02c135c8..b1847825ffab 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx +++ b/apps/meteor/client/views/teams/contextualBar/channels/TeamsChannels.tsx @@ -110,7 +110,7 @@ const TeamsChannels = ({ {t('Total')}: {total} - +