@messagevisor/react 0.0.1 → 0.1.0

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +7 -0
  4. package/jest.config.js +13 -0
  5. package/lib/MessagevisorContext.d.ts +21 -0
  6. package/lib/MessagevisorContext.js +39 -0
  7. package/lib/MessagevisorContext.js.map +1 -0
  8. package/lib/MessagevisorProvider.d.ts +12 -0
  9. package/lib/MessagevisorProvider.js +58 -0
  10. package/lib/MessagevisorProvider.js.map +1 -0
  11. package/lib/index.d.ts +6 -0
  12. package/lib/index.js +23 -0
  13. package/lib/index.js.map +1 -0
  14. package/lib/useMessagevisor.d.ts +20 -0
  15. package/lib/useMessagevisor.js +106 -0
  16. package/lib/useMessagevisor.js.map +1 -0
  17. package/lib/useMessagevisorSnapshot.d.ts +2 -0
  18. package/lib/useMessagevisorSnapshot.js +77 -0
  19. package/lib/useMessagevisorSnapshot.js.map +1 -0
  20. package/lib/useReactiveMessagevisor.d.ts +31 -0
  21. package/lib/useReactiveMessagevisor.js +141 -0
  22. package/lib/useReactiveMessagevisor.js.map +1 -0
  23. package/lib/useRichText.d.ts +13 -0
  24. package/lib/useRichText.js +112 -0
  25. package/lib/useRichText.js.map +1 -0
  26. package/lib/useSdk.d.ts +2 -0
  27. package/lib/useSdk.js +46 -0
  28. package/lib/useSdk.js.map +1 -0
  29. package/package.json +51 -13
  30. package/src/MessagevisorContext.ts +28 -0
  31. package/src/MessagevisorProvider.spec.tsx +29 -0
  32. package/src/MessagevisorProvider.tsx +41 -0
  33. package/src/index.ts +6 -0
  34. package/src/testUtils.ts +72 -0
  35. package/src/useMessagevisor.spec.tsx +425 -0
  36. package/src/useMessagevisor.ts +115 -0
  37. package/src/useMessagevisorSnapshot.ts +65 -0
  38. package/src/useReactiveMessagevisor.spec.tsx +507 -0
  39. package/src/useReactiveMessagevisor.ts +223 -0
  40. package/src/useRichText.tsx +116 -0
  41. package/src/useSdk.spec.tsx +45 -0
  42. package/src/useSdk.ts +15 -0
  43. package/tsconfig.cjs.json +11 -0
  44. package/tsconfig.typecheck.json +4 -0
@@ -0,0 +1,507 @@
1
+ import * as React from "react";
2
+ import { fireEvent, render, screen } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+
5
+ import { createMessagevisor } from "@messagevisor/sdk";
6
+ import { createICUModule } from "@messagevisor/module-icu";
7
+
8
+ import { MessagevisorProvider } from "./MessagevisorProvider";
9
+ import { datafile, createRichTestInstance, createTestInstance } from "./testUtils";
10
+ import {
11
+ useCurrency,
12
+ useDirection,
13
+ useFormatDate,
14
+ useFormatDateTimeRange,
15
+ useFormatMessage,
16
+ useFormatNumber,
17
+ useFormatRelativeTime,
18
+ useFormatTime,
19
+ useLocale,
20
+ useLocaleInfo,
21
+ useMessagevisorContext,
22
+ useTranslation,
23
+ } from "./useReactiveMessagevisor";
24
+ import { useMessagevisorSnapshot } from "./useMessagevisorSnapshot";
25
+ import { useSdk } from "./useSdk";
26
+
27
+ const nlDatafile = {
28
+ ...datafile,
29
+ locale: "nl-NL",
30
+ direction: "ltr",
31
+ revision: "2",
32
+ messages: {
33
+ ...datafile.messages,
34
+ greeting: {
35
+ overrides: [
36
+ {
37
+ key: "web-nl",
38
+ segments: "web",
39
+ translation: "Hallo web {name}",
40
+ },
41
+ ],
42
+ },
43
+ },
44
+ translations: {
45
+ ...datafile.translations,
46
+ greeting: "Hallo {name}",
47
+ total: "Totaal: {amount, number, money}",
48
+ },
49
+ } as typeof datafile;
50
+
51
+ const arDatafile = {
52
+ ...datafile,
53
+ locale: "ar-SA",
54
+ direction: "rtl",
55
+ revision: "3",
56
+ translations: {
57
+ ...datafile.translations,
58
+ greeting: "مرحبا {name}",
59
+ },
60
+ messages: {
61
+ ...datafile.messages,
62
+ greeting: {},
63
+ },
64
+ } as typeof datafile;
65
+
66
+ describe("reactive Messagevisor hooks", function () {
67
+ it("exposes the SDK snapshot and re-renders when observable state changes", function () {
68
+ let renderCount = 0;
69
+
70
+ function TestComponent() {
71
+ renderCount++;
72
+
73
+ const sdk = useSdk();
74
+ const snapshot = useMessagevisorSnapshot();
75
+
76
+ return (
77
+ <button onClick={() => sdk.setCurrency("EUR")}>
78
+ {snapshot.version}:{snapshot.locale}:{snapshot.currency || "none"}
79
+ </button>
80
+ );
81
+ }
82
+
83
+ render(
84
+ <MessagevisorProvider instance={createTestInstance()}>
85
+ <TestComponent />
86
+ </MessagevisorProvider>,
87
+ );
88
+
89
+ expect(screen.getByRole("button")).toHaveTextContent("1:en-US:none");
90
+
91
+ fireEvent.click(screen.getByRole("button"));
92
+
93
+ expect(screen.getByRole("button")).toHaveTextContent("2:en-US:EUR");
94
+ expect(renderCount).toEqual(2);
95
+ });
96
+
97
+ it("reactively translates when context, datafile, and locale change", function () {
98
+ function TestComponent() {
99
+ const sdk = useSdk();
100
+ const locale = useLocale();
101
+ const direction = useDirection() || "unknown";
102
+ const context = useMessagevisorContext();
103
+ const greeting = useTranslation("greeting", { name: "Ada" });
104
+
105
+ return (
106
+ <section>
107
+ <p>{locale}</p>
108
+ <p>{direction}</p>
109
+ <p>{String(context.platform || "no-platform")}</p>
110
+ <p>{greeting}</p>
111
+ <button onClick={() => sdk.setContext({ platform: "web" })}>context</button>
112
+ <button
113
+ onClick={() => {
114
+ sdk.setDatafile(nlDatafile);
115
+ sdk.setLocale("nl-NL");
116
+ }}
117
+ >
118
+ locale
119
+ </button>
120
+ </section>
121
+ );
122
+ }
123
+
124
+ render(
125
+ <MessagevisorProvider instance={createTestInstance()}>
126
+ <TestComponent />
127
+ </MessagevisorProvider>,
128
+ );
129
+
130
+ expect(screen.getByText("en-US")).toBeInTheDocument();
131
+ expect(screen.getByText("ltr")).toBeInTheDocument();
132
+ expect(screen.getByText("no-platform")).toBeInTheDocument();
133
+ expect(screen.getByText("Hello Ada")).toBeInTheDocument();
134
+
135
+ fireEvent.click(screen.getByText("context"));
136
+
137
+ expect(screen.getByText("web")).toBeInTheDocument();
138
+ expect(screen.getByText("Hello web Ada")).toBeInTheDocument();
139
+
140
+ fireEvent.click(screen.getByText("locale"));
141
+
142
+ expect(screen.getByText("nl-NL")).toBeInTheDocument();
143
+ expect(screen.getByText("ltr")).toBeInTheDocument();
144
+ expect(screen.getByText("Hallo web Ada")).toBeInTheDocument();
145
+ });
146
+
147
+ it("reactively exposes locale and direction together via useLocaleInfo", function () {
148
+ function TestComponent() {
149
+ const sdk = useSdk();
150
+ const localeInfo = useLocaleInfo();
151
+
152
+ return (
153
+ <section>
154
+ <p>
155
+ {localeInfo.locale}:{localeInfo.direction || "unknown"}
156
+ </p>
157
+ <button
158
+ onClick={() => {
159
+ sdk.setDatafile(arDatafile);
160
+ sdk.setLocale("ar-SA");
161
+ }}
162
+ >
163
+ switch
164
+ </button>
165
+ </section>
166
+ );
167
+ }
168
+
169
+ render(
170
+ <MessagevisorProvider instance={createTestInstance()}>
171
+ <TestComponent />
172
+ </MessagevisorProvider>,
173
+ );
174
+
175
+ expect(screen.getByText("en-US:ltr")).toBeInTheDocument();
176
+
177
+ fireEvent.click(screen.getByText("switch"));
178
+
179
+ expect(screen.getByText("ar-SA:rtl")).toBeInTheDocument();
180
+ });
181
+
182
+ it("updates useDirection when the active locale datafile is replaced", function () {
183
+ function TestComponent() {
184
+ const sdk = useSdk();
185
+ const direction = useDirection() || "unknown";
186
+
187
+ return (
188
+ <section>
189
+ <p>{direction}</p>
190
+ <button
191
+ onClick={() =>
192
+ sdk.setDatafile({
193
+ ...datafile,
194
+ locale: "en-US",
195
+ direction: "rtl",
196
+ revision: "2",
197
+ })
198
+ }
199
+ >
200
+ replace
201
+ </button>
202
+ </section>
203
+ );
204
+ }
205
+
206
+ render(
207
+ <MessagevisorProvider instance={createTestInstance()}>
208
+ <TestComponent />
209
+ </MessagevisorProvider>,
210
+ );
211
+
212
+ expect(screen.getByText("ltr")).toBeInTheDocument();
213
+
214
+ fireEvent.click(screen.getByText("replace"));
215
+
216
+ expect(screen.getByText("rtl")).toBeInTheDocument();
217
+ });
218
+
219
+ it("reactively formats messages, numbers, currency, dates, times, ranges, and relative time", function () {
220
+ function TestComponent() {
221
+ const sdk = useSdk();
222
+ const currency = useCurrency() || "none";
223
+ const message = useFormatMessage("Hi {name}", { name: "Ada" });
224
+ const number = useFormatNumber(12, "money");
225
+ const money = useFormatNumber(12, "money", { currency: "EUR" });
226
+ const date = useFormatDate("2025-01-02T00:00:00Z", "short");
227
+ const time = useFormatTime("2025-01-02T12:00:00Z", {
228
+ hour: "numeric",
229
+ minute: "2-digit",
230
+ });
231
+ const range = useFormatDateTimeRange("2025-01-02T00:00:00Z", "2025-01-03T00:00:00Z", "short");
232
+ const relative = useFormatRelativeTime(-1, "day", "short");
233
+
234
+ return (
235
+ <section>
236
+ <p>{currency}</p>
237
+ <p>{message}</p>
238
+ <p>{number}</p>
239
+ <p>{money}</p>
240
+ <p>{date}</p>
241
+ <p>{time}</p>
242
+ <p>{range}</p>
243
+ <p>{relative}</p>
244
+ <button
245
+ onClick={() => {
246
+ sdk.setCurrency("EUR");
247
+ sdk.setTimeZone("America/New_York");
248
+ }}
249
+ >
250
+ update
251
+ </button>
252
+ </section>
253
+ );
254
+ }
255
+
256
+ render(
257
+ <MessagevisorProvider instance={createTestInstance()}>
258
+ <TestComponent />
259
+ </MessagevisorProvider>,
260
+ );
261
+
262
+ expect(screen.getByText("none")).toBeInTheDocument();
263
+ expect(screen.getByText("Hi Ada")).toBeInTheDocument();
264
+ expect(screen.getByText("$12.00")).toBeInTheDocument();
265
+ expect(screen.getByText("€12.00")).toBeInTheDocument();
266
+ expect(screen.getByText("1/2/25")).toBeInTheDocument();
267
+ expect(screen.getByText("12:00 PM")).toBeInTheDocument();
268
+ expect(screen.getByText(/Jan 2.*3/)).toBeInTheDocument();
269
+ expect(screen.getByText("yesterday")).toBeInTheDocument();
270
+
271
+ fireEvent.click(screen.getByText("update"));
272
+
273
+ expect(screen.getByText("EUR")).toBeInTheDocument();
274
+ expect(screen.getByText("$12.00")).toBeInTheDocument();
275
+ expect(screen.getByText("€12.00")).toBeInTheDocument();
276
+ expect(screen.getByText("7:00 AM")).toBeInTheDocument();
277
+ });
278
+
279
+ it("refreshes subscriptions when the provider receives a new instance", function () {
280
+ const first = createTestInstance();
281
+ const second = createMessagevisor({
282
+ datafile: {
283
+ ...datafile,
284
+ translations: {
285
+ ...datafile.translations,
286
+ greeting: "Hi {name}",
287
+ },
288
+ },
289
+ currency: "EUR",
290
+ modules: [createICUModule()],
291
+ });
292
+
293
+ function TestComponent() {
294
+ const greeting = useTranslation("greeting", { name: "Ada" });
295
+ const currency = useCurrency();
296
+
297
+ return (
298
+ <p>
299
+ {greeting}:{currency || "none"}
300
+ </p>
301
+ );
302
+ }
303
+
304
+ const { rerender } = render(
305
+ <MessagevisorProvider instance={first}>
306
+ <TestComponent />
307
+ </MessagevisorProvider>,
308
+ );
309
+
310
+ expect(screen.getByText("Hello Ada:none")).toBeInTheDocument();
311
+
312
+ rerender(
313
+ <MessagevisorProvider instance={second}>
314
+ <TestComponent />
315
+ </MessagevisorProvider>,
316
+ );
317
+
318
+ expect(screen.getByText("Hi Ada:EUR")).toBeInTheDocument();
319
+ });
320
+
321
+ it("reactively translates rich text with provider defaults and per-call overrides", function () {
322
+ function TestComponent() {
323
+ const sdk = useSdk();
324
+ const terms = useTranslation("richTerms", {
325
+ product: "Messagevisor",
326
+ link: (chunks) => <button>{chunks}</button>,
327
+ });
328
+ const inline = useFormatMessage("Inline <strong>{name}</strong>.", {
329
+ name: "Ada",
330
+ });
331
+
332
+ return (
333
+ <section>
334
+ <p>{terms}</p>
335
+ <p>{inline}</p>
336
+ <button onClick={() => sdk.setContext({ platform: "web" })}>update</button>
337
+ </section>
338
+ );
339
+ }
340
+
341
+ render(
342
+ <MessagevisorProvider
343
+ instance={createRichTestInstance()}
344
+ defaultRichTextElements={{
345
+ link: (chunks) => <a href="/terms">{chunks}</a>,
346
+ strong: (chunks) => <strong>{chunks}</strong>,
347
+ }}
348
+ >
349
+ <TestComponent />
350
+ </MessagevisorProvider>,
351
+ );
352
+
353
+ expect(screen.getByRole("button", { name: "terms" })).toBeInTheDocument();
354
+ expect(screen.queryByRole("link", { name: "terms" })).not.toBeInTheDocument();
355
+ expect(screen.getByText("Messagevisor").tagName).toEqual("STRONG");
356
+ expect(screen.getByText("Ada").tagName).toEqual("STRONG");
357
+
358
+ fireEvent.click(screen.getByRole("button", { name: "update" }));
359
+
360
+ expect(screen.getByRole("button", { name: "terms" })).toBeInTheDocument();
361
+ });
362
+
363
+ it("runs provider modules for reactive translation and message formatting", function () {
364
+ const payloads: any[] = [];
365
+
366
+ function TestComponent() {
367
+ const translated = useTranslation("greeting", { name: "Ada" });
368
+ const formatted = useFormatMessage("Inline {name}", { name: "Ada" });
369
+
370
+ return (
371
+ <section>
372
+ <p>{translated}</p>
373
+ <p>{formatted}</p>
374
+ </section>
375
+ );
376
+ }
377
+
378
+ render(
379
+ <MessagevisorProvider
380
+ instance={createTestInstance()}
381
+ modules={[
382
+ {
383
+ transform(payload) {
384
+ payloads.push(payload);
385
+
386
+ if (payload.source === "translation") {
387
+ return `${payload.translation}!`;
388
+ }
389
+
390
+ return undefined;
391
+ },
392
+ },
393
+ ]}
394
+ >
395
+ <TestComponent />
396
+ </MessagevisorProvider>,
397
+ );
398
+
399
+ expect(screen.getByText("Hello Ada!")).toBeInTheDocument();
400
+ expect(screen.getByText("Inline Ada")).toBeInTheDocument();
401
+ expect(payloads.map((payload) => payload.source)).toEqual(["translation", "formatMessage"]);
402
+ expect(payloads[0].messageKey).toEqual("greeting");
403
+ expect(payloads[1].messageKey).toBeUndefined();
404
+ });
405
+
406
+ it("keeps provider defaults away from plain placeholders in reactive hooks", function () {
407
+ function TestComponent() {
408
+ const translated: string = useTranslation("plainLink", { link: "this link" });
409
+ const formatted: string = useFormatMessage("Inline {link}", { link: "plain link" });
410
+
411
+ return (
412
+ <section>
413
+ <p>{translated}</p>
414
+ <p>{formatted}</p>
415
+ </section>
416
+ );
417
+ }
418
+
419
+ render(
420
+ <MessagevisorProvider
421
+ instance={createRichTestInstance()}
422
+ defaultRichTextElements={{
423
+ link: (chunks) => <a href="/terms">{chunks}</a>,
424
+ }}
425
+ >
426
+ <TestComponent />
427
+ </MessagevisorProvider>,
428
+ );
429
+
430
+ expect(screen.getByText("Use this link")).toBeInTheDocument();
431
+ expect(screen.getByText("Inline plain link")).toBeInTheDocument();
432
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
433
+ });
434
+
435
+ it("types rich reactive hooks as renderable React nodes", function () {
436
+ function TestComponent() {
437
+ const translated: React.ReactNode = useTranslation("richTerms", {
438
+ product: "Messagevisor",
439
+ });
440
+ const formatted: React.ReactNode = useFormatMessage("Inline <strong>{name}</strong>.", {
441
+ name: "Ada",
442
+ });
443
+
444
+ return (
445
+ <section>
446
+ <p>{translated}</p>
447
+ <p>{formatted}</p>
448
+ </section>
449
+ );
450
+ }
451
+
452
+ render(
453
+ <MessagevisorProvider
454
+ instance={createRichTestInstance()}
455
+ defaultRichTextElements={{
456
+ link: (chunks) => <a href="/terms">{chunks}</a>,
457
+ strong: (chunks) => <strong>{chunks}</strong>,
458
+ }}
459
+ >
460
+ <TestComponent />
461
+ </MessagevisorProvider>,
462
+ );
463
+
464
+ expect(screen.getByRole("link", { name: "terms" })).toBeInTheDocument();
465
+ expect(screen.getByText("Ada").tagName).toEqual("STRONG");
466
+ });
467
+
468
+ it("can return raw rich chunks when fragment wrapping is disabled", function () {
469
+ const chunks: React.ReactNode[] = [];
470
+
471
+ function TestComponent() {
472
+ const terms = useTranslation("richTerms", {
473
+ product: "Messagevisor",
474
+ });
475
+
476
+ if (Array.isArray(terms)) {
477
+ chunks.push(...terms);
478
+ }
479
+
480
+ return <p>ready</p>;
481
+ }
482
+
483
+ render(
484
+ <MessagevisorProvider
485
+ instance={createRichTestInstance()}
486
+ wrapRichTextChunksInFragment={false}
487
+ defaultRichTextElements={{
488
+ link: (value) => <a href="/terms">{value}</a>,
489
+ strong: (value) => <strong>{value}</strong>,
490
+ }}
491
+ >
492
+ <TestComponent />
493
+ </MessagevisorProvider>,
494
+ );
495
+
496
+ expect(chunks).toHaveLength(5);
497
+ expect(React.isValidElement(chunks[1])).toEqual(true);
498
+ expect(
499
+ (chunks[1] as React.ReactElement<{ href: string; children: React.ReactNode }>).type,
500
+ ).toEqual("a");
501
+ expect(
502
+ (chunks[1] as React.ReactElement<{ href: string; children: React.ReactNode }>).props.href,
503
+ ).toEqual("/terms");
504
+ expect(React.isValidElement(chunks[3])).toEqual(true);
505
+ expect((chunks[3] as React.ReactElement).type).toEqual("strong");
506
+ });
507
+ });