@dialpad/i18n 1.16.0 → 1.20.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,349 @@
1
+ import { RawBundleSource } from '@dialpad/i18n-services/bundle-source';
2
+ import type { LocaleManagerParams } from '@dialpad/i18n-services/locale-manager';
3
+ import { MemoryStorageWrapper } from '@dialpad/i18n-services/storage';
4
+
5
+ import { expect, describe, it, beforeEach, vi } from 'vitest';
6
+ import { INJECTION_KEY_PREFIX, LocaleManager } from '../locale-manager';
7
+ import type { App } from 'vue';
8
+
9
+ const EN_US = 'en-US';
10
+ const ES_LA = 'es-LA';
11
+
12
+ function createTestBundleSources() {
13
+ const PowerdialerBundle = new RawBundleSource({
14
+ resources: [
15
+ [
16
+ EN_US,
17
+ 'app-core',
18
+ `PAGE_TITLE_CAMPAIGNS = Campaigns
19
+ LEAD_PHONE_NUM_LABEL = Phone Number
20
+ CREATE_LEAD_BUTTON = Create Lead`,
21
+ ],
22
+ [
23
+ ES_LA,
24
+ 'app-core',
25
+ `PAGE_TITLE_CAMPAIGNS = Campañas
26
+ LEAD_PHONE_NUM_LABEL = Número de Teléfono
27
+ CREATE_LEAD_BUTTON = Crear Cliente Potencial`,
28
+ ],
29
+ ],
30
+ });
31
+
32
+ // Admin bundle source
33
+ const adminBundle = new RawBundleSource({
34
+ resources: [
35
+ [
36
+ EN_US,
37
+ 'admin-test',
38
+ `ADMIN_DASHBOARD = Admin Dashboard
39
+ ADMIN_USERS = Manage Users
40
+ ADMIN_REPORTS = Admin Reports
41
+ BUTTON_SAVE = Admin Save
42
+ test-admin-only = This key ONLY exists in admin bundle
43
+
44
+ # Same key as Powerdialer but different value
45
+ PAGE_TITLE_CAMPAIGNS = Admin Campaigns View`,
46
+ ],
47
+ [
48
+ ES_LA,
49
+ 'admin-test',
50
+ `ADMIN_DASHBOARD = Panel de Administración
51
+ ADMIN_USERS = Gestionar Usuarios
52
+ ADMIN_REPORTS = Reportes de Admin
53
+ BUTTON_SAVE = Guardar Admin
54
+ test-admin-only = Esta clave SOLO existe en el bundle de admin
55
+
56
+ # Same key as Powerdialer but different value
57
+ PAGE_TITLE_CAMPAIGNS = Vista de Campañas Admin`,
58
+ ],
59
+ ],
60
+ });
61
+
62
+ return { PowerdialerBundle, adminBundle };
63
+ }
64
+
65
+ function createTestManager(
66
+ bundleSource: RawBundleSource,
67
+ namespaces: string[],
68
+ props?: Partial<LocaleManagerParams>,
69
+ ): LocaleManager {
70
+ return new LocaleManager({
71
+ bundleSource,
72
+ namespaces,
73
+ warmUp: false,
74
+ fallbackLocale: EN_US,
75
+ preferredLocale: EN_US,
76
+ storageWrapper: new MemoryStorageWrapper(),
77
+ ...props,
78
+ });
79
+ }
80
+
81
+ function mockVueApp(): App {
82
+ return {
83
+ _context: { provides: {} },
84
+ use: vi.fn(),
85
+ provide: vi.fn(),
86
+ component: vi.fn(),
87
+ } as unknown as App;
88
+ }
89
+
90
+ describe('LocaleManager - Multiple Instances', () => {
91
+ let powerdialerManager: LocaleManager;
92
+ let adminManager: LocaleManager;
93
+ let mockApp: App;
94
+
95
+ beforeEach(async () => {
96
+ const { PowerdialerBundle, adminBundle } = createTestBundleSources();
97
+
98
+ powerdialerManager = createTestManager(PowerdialerBundle, ['app-core']);
99
+ adminManager = createTestManager(adminBundle, ['admin-test']);
100
+
101
+ mockApp = mockVueApp();
102
+
103
+ // Warm up both managers with proper initialization
104
+ powerdialerManager.updateLocaleSettings({
105
+ namespaces: ['app-core'],
106
+ preferredLocale: EN_US,
107
+ });
108
+ adminManager.updateLocaleSettings({
109
+ namespaces: ['admin-test'],
110
+ preferredLocale: EN_US,
111
+ });
112
+
113
+ // Install managers to Vue app (required for fluentFormat to work)
114
+ powerdialerManager.install(mockApp);
115
+ adminManager.install(mockApp);
116
+
117
+ // Wait for both managers to be ready
118
+ await powerdialerManager.ready;
119
+ await adminManager.ready;
120
+ });
121
+
122
+ describe('Bundle Key Isolation', () => {
123
+ it('should allow each manager to access only its own namespace keys', async () => {
124
+ // Powerdialer manager should access app-core keys
125
+ expect(powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
126
+ 'Campaigns',
127
+ );
128
+ expect(powerdialerManager.fluentFormat('LEAD_PHONE_NUM_LABEL')).toBe(
129
+ 'Phone Number',
130
+ );
131
+ expect(powerdialerManager.fluentFormat('CREATE_LEAD_BUTTON')).toBe(
132
+ 'Create Lead',
133
+ );
134
+
135
+ // Admin manager should access admin-test keys
136
+ expect(adminManager.fluentFormat('ADMIN_DASHBOARD')).toBe(
137
+ 'Admin Dashboard',
138
+ );
139
+ expect(adminManager.fluentFormat('test-admin-only')).toBe(
140
+ 'This key ONLY exists in admin bundle',
141
+ );
142
+ expect(adminManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
143
+ 'Admin Campaigns View',
144
+ );
145
+ });
146
+
147
+ it('should return raw keys for non-existent keys in each namespace', () => {
148
+ // Powerdialer manager should not access admin-only keys
149
+ expect(powerdialerManager.fluentFormat('ADMIN_DASHBOARD')).toBe(
150
+ 'ADMIN_DASHBOARD',
151
+ );
152
+ expect(powerdialerManager.fluentFormat('test-admin-only')).toBe(
153
+ 'test-admin-only',
154
+ );
155
+
156
+ // Admin manager should not access Powerdialer-only keys
157
+ expect(adminManager.fluentFormat('LEAD_PHONE_NUM_LABEL')).toBe(
158
+ 'LEAD_PHONE_NUM_LABEL',
159
+ );
160
+ expect(adminManager.fluentFormat('CREATE_LEAD_BUTTON')).toBe(
161
+ 'CREATE_LEAD_BUTTON',
162
+ );
163
+ });
164
+ });
165
+
166
+ describe('Cross-contamination Prevention', () => {
167
+ it("should prevent managers from accessing each other's namespaces", () => {
168
+ // Same key name, different values based on namespace
169
+ const PowerdialerCampaigns = powerdialerManager.fluentFormat(
170
+ 'PAGE_TITLE_CAMPAIGNS',
171
+ );
172
+ const adminCampaigns = adminManager.fluentFormat('PAGE_TITLE_CAMPAIGNS');
173
+
174
+ expect(PowerdialerCampaigns).toBe('Campaigns');
175
+ expect(adminCampaigns).toBe('Admin Campaigns View');
176
+ expect(PowerdialerCampaigns).not.toBe(adminCampaigns);
177
+ });
178
+
179
+ it('should maintain namespace isolation even with similar key names', () => {
180
+ // Admin has BUTTON_SAVE, Powerdialer doesn't
181
+ expect(adminManager.fluentFormat('BUTTON_SAVE')).toBe('Admin Save');
182
+ expect(powerdialerManager.fluentFormat('BUTTON_SAVE')).toBe(
183
+ 'BUTTON_SAVE',
184
+ ); // Raw key returned
185
+
186
+ // Powerdialer has CREATE_LEAD_BUTTON, Admin doesn't
187
+ expect(powerdialerManager.fluentFormat('CREATE_LEAD_BUTTON')).toBe(
188
+ 'Create Lead',
189
+ );
190
+ expect(adminManager.fluentFormat('CREATE_LEAD_BUTTON')).toBe(
191
+ 'CREATE_LEAD_BUTTON',
192
+ ); // Raw key returned
193
+ });
194
+ });
195
+
196
+ describe('Language Switching', () => {
197
+ it('should switch language for individual managers independently', async () => {
198
+ // Set up injection keys for both managers (like the working test)
199
+ const provides = mockApp._context.provides as Record<
200
+ string,
201
+ LocaleManager
202
+ >;
203
+ provides[`${INJECTION_KEY_PREFIX}.app-core`] = powerdialerManager;
204
+ provides[`${INJECTION_KEY_PREFIX}.admin-test`] = adminManager;
205
+
206
+ // Switch Powerdialer to Spanish using change() method with namespace
207
+ powerdialerManager.changeLocale({ preferredLocale: ES_LA }, 'app-core');
208
+ await powerdialerManager.ready;
209
+
210
+ // Powerdialer should show Spanish
211
+ expect(powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
212
+ 'Campañas',
213
+ );
214
+ expect(powerdialerManager.fluentFormat('LEAD_PHONE_NUM_LABEL')).toBe(
215
+ 'Número de Teléfono',
216
+ );
217
+
218
+ // Admin should still show English
219
+ expect(adminManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
220
+ 'Admin Campaigns View',
221
+ );
222
+ expect(adminManager.fluentFormat('ADMIN_DASHBOARD')).toBe(
223
+ 'Admin Dashboard',
224
+ );
225
+ });
226
+
227
+ it('should switch language for all managers when using changeLocale globally', async () => {
228
+ // Set up injection keys for both managers
229
+ const provides = mockApp._context.provides as Record<
230
+ string,
231
+ LocaleManager
232
+ >;
233
+ provides[`${INJECTION_KEY_PREFIX}.app-core`] = powerdialerManager;
234
+ provides[`${INJECTION_KEY_PREFIX}.admin-test`] = adminManager;
235
+
236
+ // Switch all managers to Spanish
237
+ powerdialerManager.changeLocale({ preferredLocale: ES_LA });
238
+
239
+ // Wait for both managers to be ready
240
+ await powerdialerManager.ready;
241
+ await adminManager.ready;
242
+
243
+ // Both managers should show Spanish
244
+ expect(powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
245
+ 'Campañas',
246
+ );
247
+ expect(adminManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
248
+ 'Vista de Campañas Admin',
249
+ );
250
+ expect(adminManager.fluentFormat('ADMIN_DASHBOARD')).toBe(
251
+ 'Panel de Administración',
252
+ );
253
+ });
254
+
255
+ it('should maintain namespace isolation during language switching', async () => {
256
+ // Switch both to Spanish
257
+ const provides = mockApp._context.provides as Record<
258
+ string,
259
+ LocaleManager
260
+ >;
261
+ provides[`${INJECTION_KEY_PREFIX}.app-core`] = powerdialerManager;
262
+ provides[`${INJECTION_KEY_PREFIX}.admin-test`] = adminManager;
263
+
264
+ powerdialerManager.changeLocale({ preferredLocale: ES_LA });
265
+ await powerdialerManager.ready;
266
+ await adminManager.ready;
267
+
268
+ // Each manager should access only its own Spanish translations
269
+ expect(powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
270
+ 'Campañas',
271
+ );
272
+ expect(adminManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
273
+ 'Vista de Campañas Admin',
274
+ );
275
+
276
+ // Admin-only keys should still be isolated
277
+ expect(adminManager.fluentFormat('test-admin-only')).toBe(
278
+ 'Esta clave SOLO existe en el bundle de admin',
279
+ );
280
+ expect(powerdialerManager.fluentFormat('test-admin-only')).toBe(
281
+ 'test-admin-only',
282
+ ); // Raw key
283
+ });
284
+ });
285
+
286
+ describe('Manager Independence', () => {
287
+ it('should allow managers to have different preferred locales simultaneously', async () => {
288
+ // Set up injection keys for both managers
289
+ const provides = mockApp._context.provides as Record<
290
+ string,
291
+ LocaleManager
292
+ >;
293
+ provides[`${INJECTION_KEY_PREFIX}.app-core`] = powerdialerManager;
294
+ provides[`${INJECTION_KEY_PREFIX}.admin-test`] = adminManager;
295
+
296
+ // Set Powerdialer to Spanish using change() method with namespace
297
+ powerdialerManager.changeLocale({ preferredLocale: ES_LA }, 'app-core');
298
+ await powerdialerManager.ready;
299
+
300
+ // Keep Admin in English (no change)
301
+
302
+ // Verify different locales
303
+ expect(powerdialerManager.currentLocaleProp.value).toBe(ES_LA);
304
+ expect(adminManager.currentLocaleProp.value).toBe(EN_US);
305
+
306
+ // Verify translations reflect different locales
307
+ expect(powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
308
+ 'Campañas',
309
+ );
310
+ expect(adminManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
311
+ 'Admin Campaigns View',
312
+ );
313
+ });
314
+
315
+ it("should not interfere with each other's bundle loading", async () => {
316
+ // Force reload bundles for one manager
317
+ powerdialerManager.changeLocale({ useCache: false });
318
+ await powerdialerManager.ready;
319
+
320
+ // Other manager should still work normally
321
+ expect(adminManager.fluentFormat('ADMIN_DASHBOARD')).toBe(
322
+ 'Admin Dashboard',
323
+ );
324
+ expect(powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS')).toBe(
325
+ 'Campaigns',
326
+ );
327
+ });
328
+ });
329
+
330
+ describe('Error Handling', () => {
331
+ it('should handle missing translations gracefully in each manager', () => {
332
+ // Non-existent keys should return raw key strings
333
+ expect(powerdialerManager.fluentFormat('NON_EXISTENT_KEY')).toBe(
334
+ 'NON_EXISTENT_KEY',
335
+ );
336
+ expect(adminManager.fluentFormat('ANOTHER_MISSING_KEY')).toBe(
337
+ 'ANOTHER_MISSING_KEY',
338
+ );
339
+ });
340
+
341
+ it('should not crash when one manager has bundle issues', () => {
342
+ // This test ensures that if one manager has problems, others continue working
343
+ expect(() => {
344
+ powerdialerManager.fluentFormat('PAGE_TITLE_CAMPAIGNS');
345
+ adminManager.fluentFormat('ADMIN_DASHBOARD');
346
+ }).not.toThrow();
347
+ });
348
+ });
349
+ });
@@ -23,11 +23,11 @@ function mockBundleSource(): BundleSource {
23
23
  }
24
24
 
25
25
  function mockVueApp(): any {
26
- return { use: () => {}, provide: () => {} };
26
+ return { use: () => {}, provide: () => {}, component: () => {} };
27
27
  }
28
28
 
29
29
  // TODO - would go well in a testing util lib. not quite sync, but immediate
30
- // and reliable for stuff that you don't expect to resovle for "a hot second"
30
+ // and reliable for stuff that you don't expect to resolve for "a hot second"
31
31
  async function isResolved(check: Promise<unknown>): Promise<boolean> {
32
32
  const dummy = {};
33
33
  return (await Promise.race([check, Promise.resolve(dummy)])) !== dummy;
@@ -6,11 +6,23 @@ import type {
6
6
  SetLocaleParams,
7
7
  } from '@dialpad/i18n-services/locale-manager';
8
8
 
9
- import { computed, inject, ref } from 'vue';
9
+ import { computed, ref, inject } from 'vue';
10
10
  import { BaseLocaleManager } from '@dialpad/i18n-services/locale-manager';
11
11
 
12
12
  export const INJECTION_KEY_PREFIX = 'GLOBAL_LOCALE_MANAGER';
13
13
 
14
+ /**
15
+ * This is a backup storage for LocaleManagers in order to solve
16
+ * the constraint of having to call useI18N() inside a <script setup>.
17
+ *
18
+ * This is kinda hackish (I tried to keep it simple as well) but otherwise we're forcing consumers
19
+ * (i.e. Dialtone) to potentially modify their whole database
20
+ * and set the <script> for each components to <script setup>
21
+ *
22
+ * (I haven't found a way to do this only relying on Vue 3 capabilities T_T)
23
+ */
24
+ export const globalLocaleManagers = new Map<string, LocaleManager>();
25
+
14
26
  export interface UseI18N {
15
27
  currentLocale: Ref<string | null>;
16
28
  setI18N: (args?: Partial<SetLocaleParams>, namespace?: string) => void;
@@ -59,9 +71,17 @@ export class LocaleManager extends BaseLocaleManager {
59
71
  );
60
72
  }
61
73
  this.app = app;
62
- app.use(this.fluent);
63
- // TODO - allow custom injection key i 'spose
74
+
75
+ // Only install fluent plugin if not already installed
76
+ // This prevents "component i18n has already been registered" warnings
77
+ const isFluentInstalled = app.component('i18n') !== undefined;
78
+ if (!isFluentInstalled) {
79
+ app.use(this.fluent);
80
+ }
81
+
82
+ // Always register this LocaleManager for injection, regardless of plugin installation
64
83
  app.provide(parseInjectionName(namespace), this);
84
+ globalLocaleManagers.set(parseInjectionName(namespace), this);
65
85
  }
66
86
 
67
87
  /**
@@ -74,56 +94,95 @@ export class LocaleManager extends BaseLocaleManager {
74
94
  * provided, all LocaleManagers will be changed.
75
95
  */
76
96
  changeLocale(args?: Partial<SetLocaleParams>, namespace?: string): void {
77
- const localeManagers: LocaleManager[] = [];
78
- const providedValues = this.app?._context.provides;
79
- if (providedValues) {
80
- if (namespace) {
81
- // We cannot use inject here because is likely to not either inside setup() or functional components. Also see Line #93
82
- // btw, Object.entries() returns [key, value]
83
- const keyValue =
84
- Object.entries(providedValues).find(
85
- ([key, value]) =>
86
- typeof key === 'string' &&
87
- key === parseInjectionName(namespace) &&
88
- value instanceof LocaleManager,
89
- ) ?? [];
90
- const localeManager = keyValue[1];
91
- if (!localeManager) {
92
- throw new Error('LocaleManager not found!');
93
- }
94
- localeManagers.push(localeManager);
95
- } else {
96
- // This is very ugly but it's the only way I found to get all the localeManagers without
97
- // having to create a singleton, if you have a better idea, let me know!! :-]
98
- // Get string-keyed properties
99
- Object.entries(providedValues).forEach(([key, value]) => {
100
- if (
101
- typeof key === 'string' &&
102
- key.startsWith(INJECTION_KEY_PREFIX.toString()) &&
103
- value instanceof LocaleManager
104
- ) {
105
- localeManagers.push(value);
106
- }
107
- });
108
- }
109
- } else {
110
- throw new Error('No locale managers are set up yet!');
111
- }
97
+ const localeManagers = namespace
98
+ ? this.getLocaleManagerByNamespace(namespace)
99
+ : this.getAllLocaleManagers();
112
100
 
113
101
  for (const localeManager of localeManagers) {
114
102
  localeManager.updateLocaleSettings(args);
115
103
  }
116
104
  }
105
+
106
+ private getLocaleManagerByNamespace(namespace: string): LocaleManager[] {
107
+ const localeManager =
108
+ this.findLocaleManagerInProvides(namespace) ??
109
+ findLocaleManager(namespace);
110
+ if (!localeManager) {
111
+ throw new Error('LocaleManager not found!');
112
+ }
113
+ return [localeManager];
114
+ }
115
+
116
+ // This method mostly focuses on remain backward compatible and reduce risks when implementing new instance providing mechanisms
117
+ private findLocaleManagerInProvides(
118
+ namespace: string,
119
+ ): LocaleManager | undefined {
120
+ const providedValues = this.app?._context.provides;
121
+ if (!providedValues) {
122
+ return;
123
+ }
124
+
125
+ const targetKey = parseInjectionName(namespace);
126
+ return this.findLocaleManagersByKeyFilter(
127
+ providedValues,
128
+ (key) => key === targetKey,
129
+ )[0];
130
+ }
131
+
132
+ private getAllLocaleManagers(): LocaleManager[] {
133
+ const providedValues = this.app?._context.provides;
134
+ if (!providedValues) {
135
+ throw new Error('No locale managers are set up yet!');
136
+ }
137
+
138
+ return this.findLocaleManagersByKeyFilter(providedValues, (key) =>
139
+ key.startsWith(INJECTION_KEY_PREFIX.toString()),
140
+ );
141
+ }
142
+
143
+ private findLocaleManagersByKeyFilter(
144
+ providedValues: Record<string | symbol, unknown>,
145
+ keyFilter: (key: string) => boolean,
146
+ ): LocaleManager[] {
147
+ const localeManagers: LocaleManager[] = [];
148
+ Object.entries(providedValues).forEach(([key, value]) => {
149
+ if (
150
+ typeof key === 'string' &&
151
+ keyFilter(key) &&
152
+ value instanceof LocaleManager
153
+ ) {
154
+ localeManagers.push(value);
155
+ }
156
+ });
157
+
158
+ return localeManagers;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Use i18n functionality without requiring to depend only on Vue's inject
164
+ * @param namespace - The namespace of the locale manager to use, defaults to 'default'
165
+ */
166
+ export function findLocaleManager(
167
+ namespace: string,
168
+ ): LocaleManager | undefined {
169
+ const injectionKey = parseInjectionName(namespace);
170
+ // Use null as default to suppress Vue injection warning when key is not found
171
+ const injected = inject<LocaleManager | null>(injectionKey, null);
172
+ return injected ?? globalLocaleManagers.get(injectionKey);
117
173
  }
118
174
 
119
175
  // TODO - allow custom injection key or whatever???
120
176
  // TODO - also maybe just use getCurrentApp + the app's global config? idk.
121
177
  export function useI18N(namespace = 'default'): UseI18N {
122
- const localeManager: LocaleManager | undefined = inject(
123
- parseInjectionName(namespace),
124
- );
125
- if (!localeManager)
126
- throw new Error(`locale manager doesn't exist using ${namespace}`);
178
+ const localeManager = findLocaleManager(namespace);
179
+
180
+ if (!localeManager) {
181
+ throw new Error(
182
+ `locale manager doesn't exist using ${namespace}. Make sure your locale manager was set up correctly`,
183
+ );
184
+ }
185
+
127
186
  return {
128
187
  currentLocale: computed(() => localeManager.currentLocaleProp.value),
129
188
  setI18N: (args?: Partial<SetLocaleParams>, namespace?: string) => {
package/vite.config.ts CHANGED
@@ -7,7 +7,7 @@ export default defineConfig({
7
7
  sourcemap: true,
8
8
  minify: false,
9
9
  rollupOptions: {
10
- external: ['vue', /^@dialpad/],
10
+ external: ['vue'],
11
11
  output: {
12
12
  preserveModules: false,
13
13
  minifyInternalExports: false,