From 8a18705d96f282df8b2ef749c4129b112e4113fb Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Mon, 27 Apr 2026 17:25:14 -0300 Subject: [PATCH 1/2] fix: Livechat Custom Fields displaying only visitor scope --- .../contactInfo/ContactInfo/ContactInfo.tsx | 2 +- .../hooks/useValidCustomFields.spec.ts | 91 +++++++++++++++++++ .../contactInfo/hooks/useValidCustomFields.ts | 12 ++- .../directory/chats/ChatInfo/ChatInfo.tsx | 2 +- 4 files changed, 100 insertions(+), 7 deletions(-) create mode 100644 apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts diff --git a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx index 4ffdb0727971c..a03bf5d2d624b 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx +++ b/apps/meteor/client/views/omnichannel/contactInfo/ContactInfo/ContactInfo.tsx @@ -37,7 +37,7 @@ const ContactInfo = ({ contact, onClose }: ContactInfoProps) => { const { name, emails, phones, conflictingFields, createdAt, lastChat, contactManager, customFields: userCustomFields } = contact; const hasConflicts = conflictingFields && conflictingFields?.length > 0; - const customFieldEntries = useValidCustomFields(userCustomFields); + const customFieldEntries = useValidCustomFields(userCustomFields, 'visitor'); return ( diff --git a/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts new file mode 100644 index 0000000000000..55bddb8c7c466 --- /dev/null +++ b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts @@ -0,0 +1,91 @@ +import type { ILivechatCustomField } from '@rocket.chat/core-typings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { renderHook } from '@testing-library/react'; + +import { useValidCustomFields } from './useValidCustomFields'; +import { useCustomFieldsQuery } from '../../hooks/useCustomFieldsQuery'; + +jest.mock('../../hooks/useCustomFieldsQuery', () => ({ + useCustomFieldsQuery: jest.fn(), +})); + +const mockedUseCustomFieldsQuery = jest.mocked(useCustomFieldsQuery); + +const makeField = (partial: Pick & Partial): ILivechatCustomField => ({ + _updatedAt: new Date(), + label: 'Field', + visibility: 'visible', + ...partial, +}); + +describe('useValidCustomFields', () => { + const visitorField = makeField({ _id: 'cf_visitor', scope: 'visitor' }); + const roomField = makeField({ _id: 'cf_room', scope: 'room' }); + + const withViewPermission = mockAppRoot().withPermission('view-livechat-room-customfields').build(); + const withoutViewPermission = mockAppRoot().build(); + + beforeEach(() => { + mockedUseCustomFieldsQuery.mockReturnValue({ + data: { customFields: [visitorField, roomField] }, + isError: false, + } as ReturnType); + }); + + describe('scope', () => { + const userCustomFields = { + cf_visitor: 'visitor value', + cf_room: 'room value', + }; + + it('keeps only visitor-scoped custom fields when scope is visitor', () => { + const { result } = renderHook(() => useValidCustomFields(userCustomFields, 'visitor'), { + wrapper: withViewPermission, + }); + + expect(result.current).toEqual([['cf_visitor', 'visitor value']]); + }); + + it('keeps only room-scoped custom fields when scope is room', () => { + const { result } = renderHook(() => useValidCustomFields(userCustomFields, 'room'), { wrapper: withViewPermission }); + + expect(result.current).toEqual([['cf_room', 'room value']]); + }); + + it('omits a key when the field is defined for the other scope', () => { + mockedUseCustomFieldsQuery.mockReturnValue({ + data: { customFields: [roomField] }, + isError: false, + } as ReturnType); + + const { result } = renderHook(() => useValidCustomFields({ cf_room: 'room only' }, 'visitor'), { + wrapper: withViewPermission, + }); + + expect(result.current).toEqual([]); + }); + }); + + describe('other filters (baseline)', () => { + it('returns an empty list without view permission even when scope matches', () => { + const { result } = renderHook(() => useValidCustomFields({ cf_visitor: 'x' }, 'visitor'), { wrapper: withoutViewPermission }); + + expect(result.current).toEqual([]); + }); + + it('excludes fields that are not set as visible', () => { + mockedUseCustomFieldsQuery.mockReturnValue({ + data: { + customFields: [makeField({ _id: 'cf_hidden', scope: 'visitor', visibility: 'hidden' }), visitorField], + }, + isError: false, + } as ReturnType); + + const { result } = renderHook(() => useValidCustomFields({ cf_hidden: 'secret', cf_visitor: 'ok' }, 'visitor'), { + wrapper: withViewPermission, + }); + + expect(result.current).toEqual([['cf_visitor', 'ok']]); + }); + }); +}); diff --git a/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.ts b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.ts index 70fbaca379c8b..ede97ee8cbac1 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.ts +++ b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.ts @@ -4,12 +4,12 @@ import { useMemo } from 'react'; import { useCustomFieldsQuery } from '../../hooks/useCustomFieldsQuery'; -const checkIsVisibleAndScopeVisitor = (key: string, customFields: ILivechatCustomField[]) => { +const checkIsVisibleAndCorrectScope = (key: string, customFields: ILivechatCustomField[], scope: 'visitor' | 'room') => { const field = customFields?.find(({ _id }) => _id === key); - return field?.visibility === 'visible' && field?.scope === 'visitor'; + return field?.visibility === 'visible' && field?.scope === scope; }; -export const useValidCustomFields = (userCustomFields: Record | undefined) => { +export const useValidCustomFields = (userCustomFields: Record | undefined, scope: 'visitor' | 'room') => { const { data, isError } = useCustomFieldsQuery(); const canViewCustomFields = usePermission('view-livechat-room-customfields'); @@ -18,8 +18,10 @@ export const useValidCustomFields = (userCustomFields: Record checkIsVisibleAndScopeVisitor(key, data?.customFields) && value); - }, [data?.customFields, userCustomFields, canViewCustomFields, isError]); + return Object.entries(userCustomFields).filter( + ([key, value]) => checkIsVisibleAndCorrectScope(key, data?.customFields, scope) && value, + ); + }, [canViewCustomFields, userCustomFields, data?.customFields, isError, scope]); return customFieldEntries; }; diff --git a/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfo.tsx b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfo.tsx index b371e45ffc59e..db0b7c3dcfa24 100644 --- a/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfo.tsx +++ b/apps/meteor/client/views/omnichannel/directory/chats/ChatInfo/ChatInfo.tsx @@ -65,7 +65,7 @@ function ChatInfo({ id, route }: ChatInfoProps) { const queueTime = useMemo(() => formatQueuedAt(room), [room]); - const customFieldEntries = useValidCustomFields(livechatData); + const customFieldEntries = useValidCustomFields(livechatData, 'room'); const onEditClick = useEffectEvent(() => { const hasEditAccess = !!subscription || hasLocalEditRoomPermission || hasGlobalEditRoomPermission; From e629876632fbbe8447dc04b54bb24d3b3a0111c7 Mon Sep 17 00:00:00 2001 From: Douglas Fabris Date: Tue, 28 Apr 2026 15:30:55 -0300 Subject: [PATCH 2/2] test: update tests for useValidCustomFields to use dynamic mock data and async assertions --- .../hooks/useValidCustomFields.spec.ts | 86 +++++++++---------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts index 55bddb8c7c466..5d4e126370113 100644 --- a/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts +++ b/apps/meteor/client/views/omnichannel/contactInfo/hooks/useValidCustomFields.spec.ts @@ -1,15 +1,8 @@ import type { ILivechatCustomField } from '@rocket.chat/core-typings'; import { mockAppRoot } from '@rocket.chat/mock-providers'; -import { renderHook } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import { useValidCustomFields } from './useValidCustomFields'; -import { useCustomFieldsQuery } from '../../hooks/useCustomFieldsQuery'; - -jest.mock('../../hooks/useCustomFieldsQuery', () => ({ - useCustomFieldsQuery: jest.fn(), -})); - -const mockedUseCustomFieldsQuery = jest.mocked(useCustomFieldsQuery); const makeField = (partial: Pick & Partial): ILivechatCustomField => ({ _updatedAt: new Date(), @@ -18,74 +11,77 @@ const makeField = (partial: Pick & Partia ...partial, }); +const buildWrapper = ({ withPermission, customFields }: { withPermission: boolean; customFields: ILivechatCustomField[] }) => { + const builder = mockAppRoot().withEndpoint( + 'GET', + '/v1/livechat/custom-fields', + () => + ({ + customFields, + total: customFields.length, + offset: 0, + count: customFields.length, + }) as any, + ); + + if (withPermission) { + builder.withPermission('view-livechat-room-customfields'); + } + + return builder.build(); +}; + describe('useValidCustomFields', () => { const visitorField = makeField({ _id: 'cf_visitor', scope: 'visitor' }); const roomField = makeField({ _id: 'cf_room', scope: 'room' }); - const withViewPermission = mockAppRoot().withPermission('view-livechat-room-customfields').build(); - const withoutViewPermission = mockAppRoot().build(); - - beforeEach(() => { - mockedUseCustomFieldsQuery.mockReturnValue({ - data: { customFields: [visitorField, roomField] }, - isError: false, - } as ReturnType); - }); - describe('scope', () => { const userCustomFields = { cf_visitor: 'visitor value', cf_room: 'room value', }; - it('keeps only visitor-scoped custom fields when scope is visitor', () => { + it('keeps only visitor-scoped custom fields when scope is visitor', async () => { const { result } = renderHook(() => useValidCustomFields(userCustomFields, 'visitor'), { - wrapper: withViewPermission, + wrapper: buildWrapper({ withPermission: true, customFields: [visitorField, roomField] }), }); - expect(result.current).toEqual([['cf_visitor', 'visitor value']]); + await waitFor(() => expect(result.current).toEqual([['cf_visitor', 'visitor value']])); }); - it('keeps only room-scoped custom fields when scope is room', () => { - const { result } = renderHook(() => useValidCustomFields(userCustomFields, 'room'), { wrapper: withViewPermission }); + it('keeps only room-scoped custom fields when scope is room', async () => { + const { result } = renderHook(() => useValidCustomFields(userCustomFields, 'room'), { + wrapper: buildWrapper({ withPermission: true, customFields: [visitorField, roomField] }), + }); - expect(result.current).toEqual([['cf_room', 'room value']]); + await waitFor(() => expect(result.current).toEqual([['cf_room', 'room value']])); }); - it('omits a key when the field is defined for the other scope', () => { - mockedUseCustomFieldsQuery.mockReturnValue({ - data: { customFields: [roomField] }, - isError: false, - } as ReturnType); - + it('omits a key when the field is defined for the other scope', async () => { const { result } = renderHook(() => useValidCustomFields({ cf_room: 'room only' }, 'visitor'), { - wrapper: withViewPermission, + wrapper: buildWrapper({ withPermission: true, customFields: [roomField] }), }); - expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toEqual([])); }); }); describe('other filters (baseline)', () => { - it('returns an empty list without view permission even when scope matches', () => { - const { result } = renderHook(() => useValidCustomFields({ cf_visitor: 'x' }, 'visitor'), { wrapper: withoutViewPermission }); + it('returns an empty list without view permission even when scope matches', async () => { + const { result } = renderHook(() => useValidCustomFields({ cf_visitor: 'x' }, 'visitor'), { + wrapper: buildWrapper({ withPermission: false, customFields: [visitorField] }), + }); - expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toEqual([])); }); - it('excludes fields that are not set as visible', () => { - mockedUseCustomFieldsQuery.mockReturnValue({ - data: { - customFields: [makeField({ _id: 'cf_hidden', scope: 'visitor', visibility: 'hidden' }), visitorField], - }, - isError: false, - } as ReturnType); - + it('excludes fields that are not set as visible', async () => { + const hiddenField = makeField({ _id: 'cf_hidden', scope: 'visitor', visibility: 'hidden' }); const { result } = renderHook(() => useValidCustomFields({ cf_hidden: 'secret', cf_visitor: 'ok' }, 'visitor'), { - wrapper: withViewPermission, + wrapper: buildWrapper({ withPermission: true, customFields: [hiddenField, visitorField] }), }); - expect(result.current).toEqual([['cf_visitor', 'ok']]); + await waitFor(() => expect(result.current).toEqual([['cf_visitor', 'ok']])); }); }); });