diff --git a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts index 72ab68a3b9..bec37dcf3c 100644 --- a/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts +++ b/plugin-hrm-form/src/___tests__/maskIdentifiers/index.test.ts @@ -16,18 +16,47 @@ /* eslint-disable camelcase */ -import { Manager } from '@twilio/flex-ui'; +import { Manager, Notifications } from '@twilio/flex-ui'; import each from 'jest-each'; -import { maskConversationServiceUserNames } from '../../maskIdentifiers'; +import { maskConversationServiceUserNames, maskChannelStringsWithIdentifiers } from '../../maskIdentifiers'; import { getInitializedCan } from '../../permissions/rules'; import { PermissionActions } from '../../permissions/actions'; +import { lookupTranslation } from '../../translations'; jest.mock('../../permissions/rules', () => ({ getInitializedCan: jest.fn(), })); +jest.mock('../../translations', () => ({ + lookupTranslation: jest.fn(), +})); + +jest.mock('@twilio/flex-ui', () => ({ + Manager: { + getInstance: jest.fn(), + }, + Notifications: { + registeredNotifications: new Map(), + }, + NotificationIds: { + NewChatMessage: 'NewChatMessage', + IncomingTask: 'IncomingTask', + }, + DefaultTaskChannels: { + ChatSms: { name: 'ChatSms' }, + }, + MessageList: { + Content: { remove: jest.fn() }, + }, +})); + +const mockRegisteredNotifications = Notifications.registeredNotifications as Map; + +const mockLookupTranslation = lookupTranslation as jest.MockedFunction; + const mockGetInitializedCan = getInitializedCan as jest.MockedFunction; +const mockManagerGetInstance = Manager.getInstance as jest.MockedFunction; describe('maskConversationServiceUserNames', () => { let mockManager: any; @@ -391,3 +420,85 @@ describe('maskConversationServiceUserNames', () => { }); }); }); + +describe('maskChannelStringsWithIdentifiers', () => { + let mockCan: jest.Mock; + + const createChannelType = (name = 'default') => ({ + name, + templates: { + IncomingTaskCanvas: { firstLine: '' }, + TaskListItem: { firstLine: '', secondLine: '' }, + CallCanvas: { firstLine: '' }, + TaskCanvasHeader: { title: '' }, + Supervisor: { + TaskCanvasHeader: { title: '' }, + TaskOverviewCanvas: { firstLine: '' }, + }, + TaskCard: { firstLine: '' }, + }, + notifications: { + override: {} as Record void>, + }, + }); + + beforeEach(() => { + mockCan = jest.fn(); + mockGetInitializedCan.mockReturnValue(mockCan); + mockManagerGetInstance.mockReturnValue({ strings: { MaskIdentifiers: 'MASKED' } } as any); + mockLookupTranslation.mockReturnValue('Masked Title'); + mockRegisteredNotifications.clear(); + mockRegisteredNotifications.set('IncomingTask', {}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('when VIEW_IDENTIFIERS permission is denied', () => { + beforeEach(() => { + mockCan.mockImplementation((action: string) => action !== PermissionActions.VIEW_IDENTIFIERS); + }); + + test('sets notification override for NewChatMessage', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(typeof channelType.notifications.override.NewChatMessage).toBe('function'); + }); + + test('notification override handler sets browser title using lookupTranslation', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + + const notification = { options: { browser: { title: '' } } }; + channelType.notifications.override.NewChatMessage(notification); + + expect(mockLookupTranslation).toHaveBeenCalledWith('BrowserNotification-ChatMessage-MaskedTitle'); + expect(notification.options.browser.title).toBe('Masked Title'); + }); + + test('deletes IncomingTask notification', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(mockRegisteredNotifications.has('IncomingTask')).toBe(false); + }); + }); + + describe('when VIEW_IDENTIFIERS permission is granted', () => { + beforeEach(() => { + mockCan.mockReturnValue(true); + }); + + test('does not set notification override', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(channelType.notifications.override.NewChatMessage).toBeUndefined(); + }); + + test('does not delete IncomingTask notification', () => { + const channelType = createChannelType(); + maskChannelStringsWithIdentifiers(channelType as any); + expect(mockRegisteredNotifications.has('IncomingTask')).toBe(true); + }); + }); +}); diff --git a/plugin-hrm-form/src/___tests__/translations/index.test.ts b/plugin-hrm-form/src/___tests__/translations/index.test.ts index 849363e8fc..9f5552a70f 100644 --- a/plugin-hrm-form/src/___tests__/translations/index.test.ts +++ b/plugin-hrm-form/src/___tests__/translations/index.test.ts @@ -14,13 +14,20 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { loadTranslations, initLocalization } from '../../translations'; +import { Manager } from '@twilio/flex-ui'; + +import { loadTranslations, initLocalization, lookupTranslation } from '../../translations'; -// Mock translation files at the top level jest.mock('../../translations/en.json', () => ({}), { virtual: true }); jest.mock('../../translations/en-US.json', () => ({}), { virtual: true }); jest.mock('../../translations/en-GB.json', () => ({}), { virtual: true }); +jest.mock('@twilio/flex-ui', () => ({ + Manager: { + getInstance: jest.fn(), + }, +})); + const mockGetAseloFeatureFlags = jest.fn(); const mockGetHrmConfig = jest.fn(); const mockGetDefinitionVersions = jest.fn(); @@ -160,3 +167,31 @@ describe('Hierarchical Translations', () => { }); }); }); + +describe('lookupTranslation', () => { + const mockManagerGetInstance = Manager.getInstance as jest.MockedFunction; + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('returns compiled string for a key that exists in Manager strings', () => { + mockManagerGetInstance.mockReturnValue({ strings: { MyKey: 'Hello World' } } as any); + expect(lookupTranslation('MyKey')).toBe('Hello World'); + }); + + test('falls back to using the code itself as template when key does not exist in Manager strings', () => { + mockManagerGetInstance.mockReturnValue({ strings: {} } as any); + expect(lookupTranslation('FallbackKey')).toBe('FallbackKey'); + }); + + test('passes parameters to the Handlebars template', () => { + mockManagerGetInstance.mockReturnValue({ strings: { Greeting: 'Hello {{name}}!' } } as any); + expect(lookupTranslation('Greeting', { name: 'World' })).toBe('Hello World!'); + }); + + test('uses empty parameters object by default', () => { + mockManagerGetInstance.mockReturnValue({ strings: { SimpleMsg: 'No params here' } } as any); + expect(lookupTranslation('SimpleMsg')).toBe('No params here'); + }); +}); diff --git a/plugin-hrm-form/src/maskIdentifiers/index.ts b/plugin-hrm-form/src/maskIdentifiers/index.ts index 69d1b14072..a2a9d17783 100644 --- a/plugin-hrm-form/src/maskIdentifiers/index.ts +++ b/plugin-hrm-form/src/maskIdentifiers/index.ts @@ -15,21 +15,32 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ import { - Strings, - TaskChannelDefinition, - MessageList, - StateHelper, + AppState, ConversationHelper, - TaskHelper, DefaultTaskChannels, Manager, - AppState, + MessageList, + Notifications, + NotificationIds, + StateHelper, + Strings, + TaskChannelDefinition, + TaskHelper, } from '@twilio/flex-ui'; // Weird type to pull in, but I can't see how it can be inferred from the public API, so it's this or 'any' xD import type { ChatProperties } from '@twilio/flex-ui/src/internal-flex-commons/src'; import { getInitializedCan } from '../permissions/rules'; import { PermissionActions } from '../permissions/actions'; +import { lookupTranslation } from '../translations'; + +const maskNotifications = (channelType: TaskChannelDefinition) => { + channelType.notifications.override[NotificationIds.NewChatMessage] = notification => { + notification.options.browser.title = lookupTranslation('BrowserNotification-ChatMessage-MaskedTitle'); + }; + // Trying to modify this notification doesn't appear to work so remove it + Notifications.registeredNotifications.delete(NotificationIds.IncomingTask); +}; // Mask identifiers in the channel strings export const maskChannelStringsWithIdentifiers = (channelType: TaskChannelDefinition) => { @@ -80,6 +91,7 @@ export const maskChannelStringsWithIdentifiers = (channelType: TaskChannelDefini TaskCard.firstLine = 'MaskIdentifiers'; Supervisor.TaskOverviewCanvas.firstLine = 'MaskIdentifiers'; + maskNotifications(channelType); }; // Mask identifiers in the manager strings & messaging canvas diff --git a/plugin-hrm-form/src/translations/en.json b/plugin-hrm-form/src/translations/en.json index 9ea2830a19..c861e52987 100644 --- a/plugin-hrm-form/src/translations/en.json +++ b/plugin-hrm-form/src/translations/en.json @@ -660,5 +660,6 @@ "Modals-ConfirmDialog-ConfirmButton": "OK", "Modals-CloseDialog-CancelButton": "Cancel", - "Modals-CloseDialog-DiscardButton": "Discard" + "Modals-CloseDialog-DiscardButton": "Discard", + "BrowserNotification-ChatMessage-MaskedTitle": "New message" } diff --git a/plugin-hrm-form/src/translations/index.ts b/plugin-hrm-form/src/translations/index.ts index 4406762754..0423eb2852 100644 --- a/plugin-hrm-form/src/translations/index.ts +++ b/plugin-hrm-form/src/translations/index.ts @@ -14,6 +14,9 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ +import Handlebars from 'handlebars'; +import { Manager } from '@twilio/flex-ui'; + import { getDefinitionVersions } from '../hrmConfig'; // default language to initialize plugin @@ -117,3 +120,8 @@ export const initLocalization = (localizationConfig: LocalizationConfig, helplin return { translateUI, getMessage }; }; + +export const lookupTranslation = (code: string, parameters: Record = {}): string => { + const { strings } = Manager.getInstance(); + return Handlebars.compile(strings[code] ?? code)(parameters); +};