@capillarytech/creatives-library 8.0.328 → 8.0.330-alpha.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 (46) hide show
  1. package/constants/unified.js +4 -0
  2. package/package.json +1 -1
  3. package/services/api.js +17 -0
  4. package/services/tests/api.test.js +85 -0
  5. package/utils/commonUtils.js +28 -0
  6. package/utils/tagValidations.js +2 -3
  7. package/utils/templateVarUtils.js +35 -6
  8. package/utils/tests/commonUtil.test.js +169 -0
  9. package/utils/tests/tagValidations.test.js +1 -1
  10. package/utils/tests/templateVarUtils.test.js +44 -0
  11. package/v2Components/CommonTestAndPreview/AddTestCustomer.js +42 -0
  12. package/v2Components/CommonTestAndPreview/CustomerCreationModal.js +155 -0
  13. package/v2Components/CommonTestAndPreview/ExistingCustomerModal.js +93 -0
  14. package/v2Components/CommonTestAndPreview/SendTestMessage.js +79 -51
  15. package/v2Components/CommonTestAndPreview/_commonTestAndPreview.scss +134 -34
  16. package/v2Components/CommonTestAndPreview/actions.js +10 -0
  17. package/v2Components/CommonTestAndPreview/constants.js +15 -1
  18. package/v2Components/CommonTestAndPreview/index.js +364 -72
  19. package/v2Components/CommonTestAndPreview/messages.js +106 -0
  20. package/v2Components/CommonTestAndPreview/reducer.js +10 -0
  21. package/v2Components/CommonTestAndPreview/tests/AddTestCustomer.test.js +66 -0
  22. package/v2Components/CommonTestAndPreview/tests/CommonTestAndPreview.addTestCustomer.test.js +648 -0
  23. package/v2Components/CommonTestAndPreview/tests/CustomValuesEditor.test.js +24 -0
  24. package/v2Components/CommonTestAndPreview/tests/CustomerCreationModal.test.js +174 -0
  25. package/v2Components/CommonTestAndPreview/tests/ExistingCustomerModal.test.js +114 -0
  26. package/v2Components/CommonTestAndPreview/tests/SendTestMessage.test.js +52 -0
  27. package/v2Components/CommonTestAndPreview/tests/constants.test.js +31 -1
  28. package/v2Components/CommonTestAndPreview/tests/index.test.js +36 -0
  29. package/v2Components/CommonTestAndPreview/tests/reducer.test.js +71 -0
  30. package/v2Components/CommonTestAndPreview/tests/selectors.test.js +17 -0
  31. package/v2Components/SmsFallback/smsFallbackUtils.js +14 -3
  32. package/v2Components/SmsFallback/tests/smsFallbackUtils.test.js +16 -0
  33. package/v2Components/TestAndPreviewSlidebox/index.js +5 -0
  34. package/v2Containers/CreativesContainer/index.js +15 -10
  35. package/v2Containers/Rcs/constants.js +6 -2
  36. package/v2Containers/Rcs/index.js +219 -91
  37. package/v2Containers/Rcs/messages.js +2 -1
  38. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +20 -0
  39. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +2370 -1758
  40. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +67 -0
  41. package/v2Containers/Rcs/tests/utils.test.js +56 -0
  42. package/v2Containers/Rcs/utils.js +53 -6
  43. package/v2Containers/SmsTrai/Edit/index.js +27 -0
  44. package/v2Containers/SmsTrai/Edit/messages.js +5 -0
  45. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +357 -324
  46. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +5586 -5212
@@ -168,6 +168,10 @@ export const COMBINED_SMS_TEMPLATE_VAR_REGEX = /\{\{[^}]+\}\}|\{\#[^#]*\#\}/g;
168
168
  export const MUSTACHE_VAR_TOKEN_FULL_STRING_REGEX = /^\{\{[^}]+\}\}$/;
169
169
  /** Full-string check: one DLT hash token. */
170
170
  export const DLT_HASH_VAR_TOKEN_FULL_STRING_REGEX = /^\{\#[^#]*\#\}$/;
171
+ /** Full-string with capture group: inner name for `{{ name }}`-style tokens (whitespace-tolerant). */
172
+ export const MUSTACHE_TOKEN_INNER_NAME_REGEX = /^\{\{\s*([^}]+?)\s*\}\}$/;
173
+ /** Full-string with capture group: inner name/body for `{# name #}` DLT tokens (whitespace-tolerant). */
174
+ export const DLT_HASH_TOKEN_INNER_NAME_REGEX = /^\{#\s*(.*?)\s*#\}$/;
171
175
  /** Global with capture group: inner name inside `{{name}}`. */
172
176
  export const MUSTACHE_VAR_NAME_CAPTURE_REGEX = /\{\{([^}]+)\}\}/g;
173
177
  /** Global with capture group: inner body inside `{#body#}`. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.328",
4
+ "version": "8.0.330-alpha.0",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
package/services/api.js CHANGED
@@ -741,4 +741,21 @@ export const getBeePopupBuilderToken = () => {
741
741
  return request(url, getAPICallObject('GET'));
742
742
  };
743
743
 
744
+ /**
745
+ * Look up member by identifier (email or mobile) for add test customer flow.
746
+ * Uses standard API auth headers (no x-cap-ct) to avoid CORS preflight issues with node layer.
747
+ * @param {string} identifierType - 'email' or 'mobile'
748
+ * @param {string} identifierValue - email address or mobile number
749
+ * @returns {Promise} Promise resolving to { success, response: { exists, customerDetails } }
750
+ */
751
+ export const getMembersLookup = (identifierType, identifierValue) => {
752
+ const url = `${API_ENDPOINT}/members?identifierType=${encodeURIComponent(identifierType)}&identifierValue=${encodeURIComponent(identifierValue)}`;
753
+ return request(url, getAPICallObject('GET'));
754
+ };
755
+
756
+ export const createTestCustomer = (payload) => {
757
+ const url = `${API_ENDPOINT}/testCustomers`;
758
+ return request(url, getAPICallObject('POST', payload));
759
+ };
760
+
744
761
  export {request, getAPICallObject};
@@ -29,6 +29,8 @@ import {
29
29
  getAssetStatus,
30
30
  getBeePopupBuilderToken,
31
31
  getCmsAccounts,
32
+ getMembersLookup,
33
+ createTestCustomer,
32
34
  } from '../api';
33
35
  import { mockData } from './mockData';
34
36
  import getSchema from '../getSchema';
@@ -1014,3 +1016,86 @@ describe('getCmsAccounts', () => {
1014
1016
  expect(result).toBeInstanceOf(Promise);
1015
1017
  });
1016
1018
  });
1019
+
1020
+ describe('getMembersLookup', () => {
1021
+ beforeEach(() => {
1022
+ global.fetch = jest.fn();
1023
+ global.fetch.mockReturnValue(Promise.resolve({
1024
+ status: 200,
1025
+ json: () => Promise.resolve({ success: true, response: { exists: false, customerDetails: [] } }),
1026
+ }));
1027
+ });
1028
+
1029
+ it('should return a Promise', () => {
1030
+ const result = getMembersLookup('email', 'user@example.com');
1031
+ expect(result).toBeInstanceOf(Promise);
1032
+ });
1033
+
1034
+ it('should be callable with identifierType and identifierValue', () => {
1035
+ expect(typeof getMembersLookup).toBe('function');
1036
+ const result = getMembersLookup('mobile', '9123456789');
1037
+ expect(result).toBeInstanceOf(Promise);
1038
+ });
1039
+
1040
+ it('should call fetch with correct URL encoding and GET method', () => {
1041
+ global.fetch.mockClear();
1042
+ getMembersLookup('email', 'user+test@example.com');
1043
+ expect(global.fetch).toHaveBeenCalled();
1044
+ const calls = global.fetch.mock.calls;
1045
+ const withEncoding = calls.find(
1046
+ (c) =>
1047
+ c[0] &&
1048
+ String(c[0]).includes('members') &&
1049
+ String(c[0]).includes('identifierType=email') &&
1050
+ String(c[0]).includes('user%2Btest%40example.com')
1051
+ );
1052
+ if (withEncoding) {
1053
+ const [url, options] = withEncoding;
1054
+ expect(url).toContain('identifierType=email');
1055
+ expect(url).toContain('identifierValue=user%2Btest%40example.com');
1056
+ expect(options?.method || 'GET').toBe('GET');
1057
+ }
1058
+ const anyMembersCall = calls.find((c) => c[0] && String(c[0]).includes('members'));
1059
+ expect(anyMembersCall).toBeDefined();
1060
+ expect(anyMembersCall[0]).toContain('identifierType=');
1061
+ expect(anyMembersCall[0]).toContain('identifierValue=');
1062
+ expect(anyMembersCall[1]?.method || 'GET').toBe('GET');
1063
+ });
1064
+ });
1065
+
1066
+ describe('createTestCustomer', () => {
1067
+ beforeEach(() => {
1068
+ global.fetch = jest.fn();
1069
+ global.fetch.mockReturnValue(Promise.resolve({
1070
+ status: 200,
1071
+ json: () => Promise.resolve({ success: true }),
1072
+ }));
1073
+ });
1074
+
1075
+ it('should return a Promise', () => {
1076
+ const payload = { customer: { email: 'test@example.com', firstName: 'Test' } };
1077
+ const result = createTestCustomer(payload);
1078
+ expect(result).toBeInstanceOf(Promise);
1079
+ });
1080
+
1081
+ it('should accept customerId payload for existing customer', () => {
1082
+ const payload = { customerId: 'cust-123' };
1083
+ const result = createTestCustomer(payload);
1084
+ expect(result).toBeInstanceOf(Promise);
1085
+ expect(global.fetch).toHaveBeenCalled();
1086
+ const lastCall = global.fetch.mock.calls[global.fetch.mock.calls.length - 1];
1087
+ expect(lastCall[0]).toContain('testCustomers');
1088
+ expect(lastCall[1].method).toBe('POST');
1089
+ expect(lastCall[1].body).toBe(JSON.stringify(payload));
1090
+ });
1091
+
1092
+ it('should call fetch with /testCustomers URL and POST method', () => {
1093
+ const payload = { customer: { email: 'n@b.co', firstName: 'N' } };
1094
+ createTestCustomer(payload);
1095
+ expect(global.fetch).toHaveBeenCalled();
1096
+ const lastCall = global.fetch.mock.calls[global.fetch.mock.calls.length - 1];
1097
+ expect(lastCall[0]).toContain('testCustomers');
1098
+ expect(lastCall[1].method).toBe('POST');
1099
+ expect(lastCall[1].body).toBe(JSON.stringify(payload));
1100
+ });
1101
+ });
@@ -17,6 +17,8 @@ import {
17
17
  } from "../v2Containers/CreativesContainer/constants";
18
18
  import { GLOBAL_CONVERT_OPTIONS } from "../v2Components/FormBuilder/constants";
19
19
  import { SMS_TRAI_VAR } from '../v2Containers/SmsTrai/Edit/constants';
20
+ import { EMAIL_REGEX, PHONE_REGEX } from '../v2Components/CommonTestAndPreview/constants';
21
+
20
22
  export const apiMessageFormatHandler = (id, fallback) => (
21
23
  <FormattedMessage id={id} defaultMessage={fallback} />
22
24
  );
@@ -530,3 +532,29 @@ export const checkForPersonalizationTokens = (formData) => {
530
532
  }
531
533
  return false;
532
534
  };
535
+
536
+ export const isValidEmail = (email) => EMAIL_REGEX.test(email);
537
+ export const isValidMobile = (mobile) => PHONE_REGEX.test(mobile);
538
+
539
+ export const formatPhoneNumber = (phone) => {
540
+ if (!phone) return '';
541
+ return String(phone).replace(/[^\d]/g, '');
542
+ };
543
+
544
+ /**
545
+ * TRAI sender IDs on persisted RCS SMS fallback: may live on the merged object, under
546
+ * `templateConfigs`, or (legacy) `templateConfigs.header`. Same resolution order as
547
+ * `createPayload` in `Rcs/index.js`.
548
+ */
549
+ export function extractRegisteredSenderIdsFromSmsFallbackRecord(record) {
550
+ if (!record || typeof record !== 'object') return null;
551
+ const tc = record.templateConfigs && typeof record.templateConfigs === 'object'
552
+ ? record.templateConfigs
553
+ : {};
554
+ const candidates = [record.registeredSenderIds, tc.registeredSenderIds, tc.header];
555
+ for (let i = 0; i < candidates.length; i += 1) {
556
+ const a = candidates[i];
557
+ if (Array.isArray(a) && a.length > 0) return a;
558
+ }
559
+ return null;
560
+ }
@@ -207,8 +207,7 @@ export const validateIfTagClosed = (value) => {
207
207
  export const preprocessHtml = (content) => {
208
208
  const replacements = {
209
209
  "&#39;": "'",
210
- "&quot;": "'",
211
- '"': "'",
210
+ "&quot;": '"',
212
211
  "&amp;": "&",
213
212
  "&lt;": "<",
214
213
  "&gt;": ">",
@@ -226,7 +225,7 @@ export const preprocessHtml = (content) => {
226
225
  });
227
226
 
228
227
  // Step 2: Perform the standard replacements on the entire content
229
- return contentWithStyleFixes?.replace(/&#39;|&quot;|&amp;|&lt;|&gt;|"|\n/g, (match) => replacements[match]);
228
+ return contentWithStyleFixes?.replace(/&#39;|&quot;|&amp;|&lt;|&gt;|\n/g, (match) => replacements[match]);
230
229
  };
231
230
 
232
231
  //this is used to get the subtags from custom or extended tags
@@ -7,7 +7,9 @@ import {
7
7
  COMBINED_SMS_TEMPLATE_VAR_REGEX,
8
8
  DEFAULT_MUSTACHE_VAR_REGEX,
9
9
  DLT_HASH_VAR_TOKEN_FULL_STRING_REGEX,
10
+ DLT_HASH_TOKEN_INNER_NAME_REGEX,
10
11
  DLT_VAR_BODY_CAPTURE_REGEX,
12
+ MUSTACHE_TOKEN_INNER_NAME_REGEX,
11
13
  MUSTACHE_VAR_NAME_CAPTURE_REGEX,
12
14
  MUSTACHE_VAR_TOKEN_FULL_STRING_REGEX,
13
15
  } from '../constants/unified';
@@ -115,13 +117,36 @@ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
115
117
  return variables;
116
118
  };
117
119
 
120
+ /**
121
+ * Looks up the inner name of a `{{name}}` or `{#name#}` token in a flat key→value map.
122
+ * Handles both exact matches and dot-path suffixes (e.g. `tag.FORMAT_1` → name `FORMAT_1`).
123
+ * Returns the resolved string value, or `undefined` if not found / blank.
124
+ */
125
+ function resolveUserVarForToken(segment, userVarMap) {
126
+ if (!userVarMap || typeof userVarMap !== 'object') return undefined;
127
+ const mMustache = segment.match(MUSTACHE_TOKEN_INNER_NAME_REGEX);
128
+ const mDlt = segment.match(DLT_HASH_TOKEN_INNER_NAME_REGEX);
129
+ const innerName = ((mMustache ? mMustache[1] : (mDlt ? mDlt[1] : '')) ?? '').trim();
130
+ if (!innerName) return undefined;
131
+ const direct = userVarMap[innerName];
132
+ if (direct != null && String(direct).trim() !== '') return String(direct);
133
+ const hit = Object.keys(userVarMap).find((k) => k === innerName || k.endsWith(`.${innerName}`));
134
+ if (hit != null && userVarMap[hit] != null && String(userVarMap[hit]).trim() !== '') return String(userVarMap[hit]);
135
+ return undefined;
136
+ }
137
+
118
138
  /**
119
139
  * SMS / DLT template preview: replace `{{…}}` / `{#…#}` tokens using `varMapData` keys `${token}_${index}`.
120
140
  * Used by SmsTraiEdit (RCS SMS fallback) and UnifiedPreview fallback SMS bubble.
121
141
  * DLT `{#…#}`: empty / unset slot values show the raw token (matches DLT preview UX); mustache `{{…}}` still
122
142
  * resolves to empty when the slot is cleared.
143
+ *
144
+ * @param {string} templateStr
145
+ * @param {Object} varMapData - Slot map (`${token}_${index}` → value)
146
+ * @param {Object|null} [userVarMap] - Optional Test & Preview custom values; used as fallback when a slot
147
+ * is absent or blank. When omitted the function behaves identically to its original two-argument form.
123
148
  */
124
- export const getFallbackResolvedContent = (templateStr = '', varMapData = {}) => {
149
+ export const getFallbackResolvedContent = (templateStr = '', varMapData = {}, userVarMap = null) => {
125
150
  const fallbackVarSlotMap = varMapData ?? {};
126
151
  const templateSegments = splitTemplateVarString(templateStr, COMBINED_SMS_TEMPLATE_VAR_REGEX);
127
152
  return templateSegments
@@ -132,13 +157,17 @@ export const getFallbackResolvedContent = (templateStr = '', varMapData = {}) =>
132
157
  if (Object.prototype.hasOwnProperty.call(fallbackVarSlotMap, slotKey)) {
133
158
  const slotValue = fallbackVarSlotMap[slotKey];
134
159
  if (isDltHashVarToken(segment)) {
135
- if (slotValue == null) return segment;
136
- const str = String(slotValue);
137
- if (str.trim() === '') return segment;
138
- return str;
160
+ if (slotValue == null || String(slotValue).trim() === '') {
161
+ if (userVarMap) { const uv = resolveUserVarForToken(segment, userVarMap); if (uv !== undefined) return uv; }
162
+ return segment;
163
+ }
164
+ return String(slotValue);
139
165
  }
140
- return slotValue == null ? '' : String(slotValue);
166
+ if (slotValue != null && String(slotValue).trim() !== '') return String(slotValue);
167
+ if (userVarMap) { const uv = resolveUserVarForToken(segment, userVarMap); if (uv !== undefined) return uv; }
168
+ return '';
141
169
  }
170
+ if (userVarMap) { const uv = resolveUserVarForToken(segment, userVarMap); if (uv !== undefined) return uv; }
142
171
  if (isDltHashVarToken(segment)) return segment;
143
172
  return '';
144
173
  })
@@ -8,6 +8,11 @@ import {
8
8
  validateCarouselCards,
9
9
  hasPersonalizationTags,
10
10
  checkForPersonalizationTokens,
11
+ isValidEmail,
12
+ isValidMobile,
13
+ formatPhoneNumber,
14
+ getMessageForDevice,
15
+ getTitleForDevice,
11
16
  } from "../commonUtils";
12
17
  import { skipTags } from "../tagValidations";
13
18
  import { SMS_TRAI_VAR } from '../../v2Containers/SmsTrai/Edit/constants';
@@ -629,6 +634,170 @@ describe("validateLiquidTemplateContent", () => {
629
634
  });
630
635
  });
631
636
 
637
+ describe("isValidEmail", () => {
638
+ it("returns true for valid email addresses", () => {
639
+ expect(isValidEmail("user@example.com")).toBe(true);
640
+ expect(isValidEmail("test.user@domain.co")).toBe(true);
641
+ expect(isValidEmail("a@b.co")).toBe(true);
642
+ });
643
+
644
+ it("returns false for invalid email addresses", () => {
645
+ expect(isValidEmail("")).toBe(false);
646
+ expect(isValidEmail("invalid")).toBe(false);
647
+ expect(isValidEmail("@nodomain.com")).toBe(false);
648
+ expect(isValidEmail("noatsign.com")).toBe(false);
649
+ expect(isValidEmail("missingtld@domain")).toBe(false);
650
+ expect(isValidEmail(" user@example.com ")).toBe(false);
651
+ });
652
+
653
+ it("returns true for edge case single-char local part", () => {
654
+ expect(isValidEmail("x@y.co")).toBe(true);
655
+ });
656
+ });
657
+
658
+ describe("isValidMobile", () => {
659
+ it("returns true for valid mobile numbers (8-15 digits, no leading zero)", () => {
660
+ expect(isValidMobile("12345678")).toBe(true);
661
+ expect(isValidMobile("9123456789")).toBe(true);
662
+ expect(isValidMobile("123456789012345")).toBe(true);
663
+ });
664
+
665
+ it("returns false for invalid mobile numbers", () => {
666
+ expect(isValidMobile("")).toBe(false);
667
+ expect(isValidMobile("01234567")).toBe(false);
668
+ expect(isValidMobile("1234567")).toBe(false);
669
+ expect(isValidMobile("1234567890123456")).toBe(false);
670
+ expect(isValidMobile("abc12345678")).toBe(false);
671
+ expect(isValidMobile("12345 67890")).toBe(false);
672
+ });
673
+
674
+ it("returns true for exactly 8 and exactly 15 digits", () => {
675
+ expect(isValidMobile("12345678")).toBe(true);
676
+ expect(isValidMobile("123456789012345")).toBe(true);
677
+ });
678
+ });
679
+
680
+ describe("formatPhoneNumber", () => {
681
+ it("returns empty string for falsy input", () => {
682
+ expect(formatPhoneNumber("")).toBe("");
683
+ expect(formatPhoneNumber(null)).toBe("");
684
+ expect(formatPhoneNumber(undefined)).toBe("");
685
+ });
686
+
687
+ it("strips non-digit characters", () => {
688
+ expect(formatPhoneNumber("91 234 567 890")).toBe("91234567890");
689
+ expect(formatPhoneNumber("+91-234567890")).toBe("91234567890");
690
+ expect(formatPhoneNumber("(123) 456-7890")).toBe("1234567890");
691
+ });
692
+
693
+ it("returns digits-only string unchanged", () => {
694
+ expect(formatPhoneNumber("9123456789")).toBe("9123456789");
695
+ });
696
+
697
+ it("returns empty string for whitespace-only input", () => {
698
+ expect(formatPhoneNumber(" ")).toBe("");
699
+ });
700
+ });
701
+
702
+ describe("hasPersonalizationTags", () => {
703
+ it("returns true when text has liquid tags {{ }}", () => {
704
+ expect(hasPersonalizationTags("Hello {{name}}")).toBe(true);
705
+ expect(hasPersonalizationTags("{{foo}}")).toBe(true);
706
+ });
707
+
708
+ it("returns true when text has bracket tags [ ]", () => {
709
+ expect(hasPersonalizationTags("Hello [event.name]")).toBe(true);
710
+ expect(hasPersonalizationTags("[tag]")).toBe(true);
711
+ });
712
+
713
+ it("returns false for empty or no tags", () => {
714
+ expect(hasPersonalizationTags("")).toBeFalsy();
715
+ expect(hasPersonalizationTags()).toBeFalsy();
716
+ expect(hasPersonalizationTags("plain text")).toBe(false);
717
+ expect(hasPersonalizationTags("only {{ open")).toBe(false);
718
+ });
719
+
720
+ it("returns false when only [ or only ] present without matching pair", () => {
721
+ expect(hasPersonalizationTags("only [ open")).toBe(false);
722
+ expect(hasPersonalizationTags("only ] close")).toBe(false);
723
+ });
724
+
725
+ it("returns true when liquid tags have content with spaces", () => {
726
+ expect(hasPersonalizationTags("Hello {{ customer.name }}")).toBe(true);
727
+ });
728
+ });
729
+
730
+ describe("getMessageForDevice", () => {
731
+ it("returns message for device from templateData", () => {
732
+ const templateData = {
733
+ versions: {
734
+ android: { base: { expandableDetails: { message: "Android msg" } } },
735
+ ios: { base: { expandableDetails: { message: "iOS msg" } } },
736
+ },
737
+ };
738
+ expect(getMessageForDevice(templateData, "android")).toBe("Android msg");
739
+ expect(getMessageForDevice(templateData, "ios")).toBe("iOS msg");
740
+ });
741
+
742
+ it("returns undefined for missing path", () => {
743
+ expect(getMessageForDevice(null, "android")).toBeUndefined();
744
+ expect(getMessageForDevice({}, "android")).toBeUndefined();
745
+ expect(getMessageForDevice({ versions: {} }, "android")).toBeUndefined();
746
+ });
747
+
748
+ it("returns undefined when base exists but expandableDetails is missing", () => {
749
+ expect(getMessageForDevice({ versions: { android: { base: {} } } }, "android")).toBeUndefined();
750
+ });
751
+ });
752
+
753
+ describe("getTitleForDevice", () => {
754
+ it("returns title for device from templateData", () => {
755
+ const templateData = {
756
+ versions: {
757
+ android: { base: { title: "Android title" } },
758
+ ios: { base: { title: "iOS title" } },
759
+ },
760
+ };
761
+ expect(getTitleForDevice(templateData, "android")).toBe("Android title");
762
+ expect(getTitleForDevice(templateData, "ios")).toBe("iOS title");
763
+ });
764
+
765
+ it("returns empty string for missing title", () => {
766
+ expect(getTitleForDevice(null, "android")).toBe("");
767
+ expect(getTitleForDevice({}, "android")).toBe("");
768
+ expect(getTitleForDevice({ versions: { android: { base: {} } } }, "android")).toBe("");
769
+ });
770
+
771
+ it("returns empty string when base.title is undefined", () => {
772
+ expect(getTitleForDevice({ versions: { android: { base: { title: undefined } } } }, "android")).toBe("");
773
+ });
774
+ });
775
+
776
+ describe("checkForPersonalizationTokens", () => {
777
+ it("returns true when formData contains liquid or bracket tokens", () => {
778
+ expect(checkForPersonalizationTokens({ 0: { content: "Hi {{name}}" } })).toBe(true);
779
+ expect(checkForPersonalizationTokens({ tab1: { message: "Hello [event.id]" } })).toBe(true);
780
+ });
781
+
782
+ it("returns false for empty or no tokens", () => {
783
+ expect(checkForPersonalizationTokens(null)).toBe(false);
784
+ expect(checkForPersonalizationTokens({})).toBe(false);
785
+ expect(checkForPersonalizationTokens({ 0: { content: "plain" } })).toBe(false);
786
+ });
787
+
788
+ it("returns false for non-object formData", () => {
789
+ expect(checkForPersonalizationTokens("string")).toBe(false);
790
+ });
791
+
792
+ it("returns true for two-level nested object containing token", () => {
793
+ expect(checkForPersonalizationTokens({ tab: { body: "Hi {{name}}" } })).toBe(true);
794
+ });
795
+
796
+ it("returns false for formData with array value (no string tokens)", () => {
797
+ expect(checkForPersonalizationTokens({ 0: { items: ["a", "b"] } })).toBe(false);
798
+ });
799
+ });
800
+
632
801
  describe("validateMobilePushContent", () => {
633
802
  const formatMessage = jest.fn(msg => msg.id);
634
803
  const messages = {
@@ -642,7 +642,7 @@ describe('preprocessHtml', () => {
642
642
 
643
643
  it('should replace &quot; with double quotes', () => {
644
644
  const input = 'She said, &quot;Hello, World!&quot;';
645
- const expectedOutput = "She said, 'Hello, World!'";
645
+ const expectedOutput = 'She said, "Hello, World!"';
646
646
  expect(preprocessHtml(input)).toEqual(expectedOutput);
647
647
  });
648
648
 
@@ -157,4 +157,48 @@ describe('templateVarUtils', () => {
157
157
  expect(extractTemplateVariables('{{x}} {{y}}', globalRe)).toEqual(['x', 'y']);
158
158
  });
159
159
  });
160
+
161
+ describe('getFallbackResolvedContent — userVarMap (third param)', () => {
162
+ it('falls back to userVarMap for mustache slot absent from varMapData', () => {
163
+ expect(getFallbackResolvedContent('Hi {{name}}', {}, { name: 'Alice' })).toBe('Hi Alice');
164
+ });
165
+
166
+ it('falls back to userVarMap for DLT slot absent from varMapData', () => {
167
+ expect(getFallbackResolvedContent('{#city#}', {}, { city: 'Mumbai' })).toBe('Mumbai');
168
+ });
169
+
170
+ it('falls back to userVarMap when mustache slot is present but blank', () => {
171
+ expect(getFallbackResolvedContent('{{x}}', { '{{x}}_0': '' }, { x: 'val' })).toBe('val');
172
+ });
173
+
174
+ it('falls back to userVarMap when DLT slot is present but blank', () => {
175
+ expect(getFallbackResolvedContent('{#x#}', { '{#x#}_0': '' }, { x: 'val' })).toBe('val');
176
+ });
177
+
178
+ it('slot wins over userVarMap when mustache slot is non-empty', () => {
179
+ expect(getFallbackResolvedContent('{{x}}', { '{{x}}_0': 'slot-val' }, { x: 'user-val' })).toBe('slot-val');
180
+ });
181
+
182
+ it('slot wins over userVarMap when DLT slot is non-empty', () => {
183
+ expect(getFallbackResolvedContent('{#x#}', { '{#x#}_0': 'slot' }, { x: 'user' })).toBe('slot');
184
+ });
185
+
186
+ it('resolves dot-path suffix key from userVarMap', () => {
187
+ expect(
188
+ getFallbackResolvedContent('{{FORMAT_1}}', {}, { 'expiry.FORMAT_1': 'Dec 2025' }),
189
+ ).toBe('Dec 2025');
190
+ });
191
+
192
+ it('userVarMap null behaves identically to the two-argument form', () => {
193
+ expect(getFallbackResolvedContent('{{a}}', { '{{a}}_0': 'x' }, null)).toBe('x');
194
+ });
195
+
196
+ it('blank userVarMap value leaves mustache token as empty string', () => {
197
+ expect(getFallbackResolvedContent('{{a}}', {}, { a: '' })).toBe('');
198
+ });
199
+
200
+ it('blank userVarMap value leaves DLT token as raw token', () => {
201
+ expect(getFallbackResolvedContent('{#a#}', {}, { a: '' })).toBe('{#a#}');
202
+ });
203
+ });
160
204
  });
@@ -0,0 +1,42 @@
1
+ import PropTypes from "prop-types";
2
+ import { FormattedMessage } from "react-intl";
3
+ import CapButton from "@capillarytech/cap-ui-library/CapButton";
4
+ import CapIcon from "@capillarytech/cap-ui-library/CapIcon";
5
+ import messages from "./messages";
6
+ import React from "react";
7
+
8
+ const AddTestCustomerButton = ({
9
+ searchValue,
10
+ handleAddTestCustomer
11
+ }) => (
12
+ <CapButton
13
+ onClick={handleAddTestCustomer}
14
+ type="flat"
15
+ size="small"
16
+ className="test-customer-add-btn"
17
+ prefix={
18
+ <CapIcon
19
+ type="add-profile"
20
+ className="add-test-customer-icon"
21
+ />
22
+ }
23
+ >
24
+ <FormattedMessage
25
+ {...messages.addTestCustomerWithValue}
26
+ values={{
27
+ searchValue: (
28
+ <span className="test-customer-add-btn-value" title={searchValue || ""}>
29
+ "{searchValue}"
30
+ </span>
31
+ ),
32
+ }}
33
+ />
34
+ </CapButton>
35
+ );
36
+
37
+ AddTestCustomerButton.propTypes = {
38
+ searchValue: PropTypes.string.isRequired,
39
+ handleAddTestCustomer: PropTypes.func.isRequired
40
+ };
41
+
42
+ export default AddTestCustomerButton;