@capillarytech/creatives-library 8.0.316-alpha.3 → 8.0.316-alpha.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.
Files changed (105) hide show
  1. package/constants/unified.js +14 -0
  2. package/package.json +1 -1
  3. package/utils/templateVarUtils.js +172 -0
  4. package/utils/tests/templateVarUtils.test.js +160 -0
  5. package/v2Components/CapTagList/index.js +10 -0
  6. package/v2Components/CommonTestAndPreview/CustomValuesEditor.js +70 -49
  7. package/v2Components/CommonTestAndPreview/DeliverySettings/DeliverySettings.scss +8 -2
  8. package/v2Components/CommonTestAndPreview/DeliverySettings/ModifyDeliverySettings.js +207 -21
  9. package/v2Components/CommonTestAndPreview/DeliverySettings/constants.js +16 -0
  10. package/v2Components/CommonTestAndPreview/DeliverySettings/index.js +85 -10
  11. package/v2Components/CommonTestAndPreview/DeliverySettings/messages.js +30 -0
  12. package/v2Components/CommonTestAndPreview/DeliverySettings/utils/parseSenderDetailsResponse.js +79 -11
  13. package/v2Components/CommonTestAndPreview/SendTestMessage.js +11 -5
  14. package/v2Components/CommonTestAndPreview/UnifiedPreview/_unifiedPreview.scss +20 -1
  15. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +133 -4
  16. package/v2Components/CommonTestAndPreview/_commonTestAndPreview.scss +12 -0
  17. package/v2Components/CommonTestAndPreview/constants.js +38 -0
  18. package/v2Components/CommonTestAndPreview/index.js +693 -155
  19. package/v2Components/CommonTestAndPreview/messages.js +41 -3
  20. package/v2Components/CommonTestAndPreview/previewApiUtils.js +59 -0
  21. package/v2Components/CommonTestAndPreview/sagas.js +15 -6
  22. package/v2Components/CommonTestAndPreview/tests/CustomValuesEditor.test.js +172 -0
  23. package/v2Components/CommonTestAndPreview/tests/DeliverySettings/ModifyDeliverySettings.test.js +269 -1
  24. package/v2Components/CommonTestAndPreview/tests/DeliverySettings/index.test.js +118 -5
  25. package/v2Components/CommonTestAndPreview/tests/DeliverySettings/utils/parseSenderDetailsResponse.test.js +245 -0
  26. package/v2Components/CommonTestAndPreview/tests/SendTestMessage.test.js +25 -4
  27. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +100 -1
  28. package/v2Components/CommonTestAndPreview/tests/index.test.js +19 -1
  29. package/v2Components/CommonTestAndPreview/tests/previewApiUtils.test.js +67 -0
  30. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +2 -2
  31. package/v2Components/FormBuilder/index.js +7 -1
  32. package/v2Components/SmsFallback/SmsFallbackLocalSelector.js +87 -0
  33. package/v2Components/SmsFallback/constants.js +73 -0
  34. package/v2Components/SmsFallback/index.js +956 -0
  35. package/v2Components/SmsFallback/index.scss +265 -0
  36. package/v2Components/SmsFallback/messages.js +78 -0
  37. package/v2Components/SmsFallback/smsFallbackUtils.js +107 -0
  38. package/v2Components/SmsFallback/tests/SmsFallbackLocalSelector.test.js +50 -0
  39. package/v2Components/SmsFallback/tests/rcsSmsFallback.acceptance.test.js +147 -0
  40. package/v2Components/SmsFallback/tests/smsFallbackHandlers.test.js +304 -0
  41. package/v2Components/SmsFallback/tests/smsFallbackUi.test.js +197 -0
  42. package/v2Components/SmsFallback/tests/smsFallbackUtils.test.js +261 -0
  43. package/v2Components/SmsFallback/tests/useLocalTemplateList.test.js +327 -0
  44. package/v2Components/SmsFallback/useLocalTemplateList.js +92 -0
  45. package/v2Components/TestAndPreviewSlidebox/index.js +8 -1
  46. package/v2Components/TestAndPreviewSlidebox/sagas.js +11 -4
  47. package/v2Components/TestAndPreviewSlidebox/tests/saga.test.js +3 -1
  48. package/v2Components/VarSegmentMessageEditor/constants.js +2 -0
  49. package/v2Components/VarSegmentMessageEditor/index.js +125 -0
  50. package/v2Components/VarSegmentMessageEditor/index.scss +46 -0
  51. package/v2Containers/CreativesContainer/CreativesSlideBoxWrapper.js +43 -0
  52. package/v2Containers/CreativesContainer/SlideBoxContent.js +36 -4
  53. package/v2Containers/CreativesContainer/SlideBoxFooter.js +10 -1
  54. package/v2Containers/CreativesContainer/SlideBoxHeader.js +29 -4
  55. package/v2Containers/CreativesContainer/constants.js +9 -0
  56. package/v2Containers/CreativesContainer/embeddedSlideboxUtils.js +67 -0
  57. package/v2Containers/CreativesContainer/index.js +286 -93
  58. package/v2Containers/CreativesContainer/index.scss +51 -1
  59. package/v2Containers/CreativesContainer/tests/SlideBoxContent.localTemplates.test.js +90 -0
  60. package/v2Containers/CreativesContainer/tests/__snapshots__/SlideBoxContent.test.js.snap +8 -0
  61. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +20 -10
  62. package/v2Containers/CreativesContainer/tests/index.test.js +71 -9
  63. package/v2Containers/CreativesContainer/tests/useLocalTemplatesProp.test.js +125 -0
  64. package/v2Containers/Rcs/constants.js +32 -1
  65. package/v2Containers/Rcs/index.js +950 -873
  66. package/v2Containers/Rcs/index.scss +85 -6
  67. package/v2Containers/Rcs/messages.js +10 -1
  68. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +205 -0
  69. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +40834 -1963
  70. package/v2Containers/Rcs/tests/__snapshots__/utils.test.js.snap +0 -5
  71. package/v2Containers/Rcs/tests/index.test.js +41 -38
  72. package/v2Containers/Rcs/tests/mockData.js +38 -0
  73. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +251 -0
  74. package/v2Containers/Rcs/tests/utils.test.js +379 -1
  75. package/v2Containers/Rcs/utils.js +358 -10
  76. package/v2Containers/Sms/Create/index.js +81 -36
  77. package/v2Containers/Sms/smsFormDataHelpers.js +67 -0
  78. package/v2Containers/SmsTrai/Create/index.js +9 -4
  79. package/v2Containers/SmsTrai/Edit/constants.js +2 -0
  80. package/v2Containers/SmsTrai/Edit/index.js +609 -128
  81. package/v2Containers/SmsTrai/Edit/index.scss +121 -0
  82. package/v2Containers/SmsTrai/Edit/messages.js +9 -4
  83. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +4327 -2374
  84. package/v2Containers/SmsWrapper/index.js +37 -8
  85. package/v2Containers/TagList/index.js +6 -0
  86. package/v2Containers/Templates/TemplatesActionBar.js +101 -0
  87. package/v2Containers/Templates/_templates.scss +61 -2
  88. package/v2Containers/Templates/actions.js +11 -0
  89. package/v2Containers/Templates/constants.js +2 -0
  90. package/v2Containers/Templates/index.js +90 -40
  91. package/v2Containers/Templates/sagas.js +57 -12
  92. package/v2Containers/Templates/tests/TemplatesActionBar.test.js +120 -0
  93. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +1043 -1079
  94. package/v2Containers/Templates/tests/sagas.test.js +110 -12
  95. package/v2Containers/Templates/tests/smsTemplatesListApi.test.js +180 -0
  96. package/v2Containers/Templates/utils/smsTemplatesListApi.js +79 -0
  97. package/v2Containers/TemplatesV2/TemplatesV2.style.js +72 -1
  98. package/v2Containers/TemplatesV2/index.js +86 -23
  99. package/v2Containers/TemplatesV2/tests/TemplatesV2.localTemplates.test.js +131 -0
  100. package/v2Containers/WebPush/Create/components/MessageSection.js +54 -18
  101. package/v2Containers/WebPush/Create/components/MessageSection.test.js +28 -0
  102. package/v2Containers/WebPush/Create/components/__snapshots__/MessageSection.test.js.snap +7 -3
  103. package/v2Containers/Whatsapp/index.js +7 -23
  104. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +578 -34
  105. package/v2Containers/Whatsapp/tests/index.test.js +172 -0
@@ -158,6 +158,20 @@ export const TAG_CONTENT_REGEX = /{{([^}]+)}}/g;
158
158
  export const ENTRY_TRIGGER_TAG_REGEX = /\bentryTrigger\.\w+(?:\.\w+)?(?:\(\w+\))?/g;
159
159
  export const SKIP_TAGS_REGEX_GROUPS = ["dynamic_expiry_date_after_\\d+_days.FORMAT_\\d", "unsubscribe\\(#[a-zA-Z\\d]{6}\\)", "Link_to_[a-zA-Z]", "SURVEY.*.TOKEN", "^[A-Za-z].*\\([a-zA-Z\\d]*\\)", "referral_unique_(code|url).*userid"];
160
160
 
161
+ // --- Template variable tokens (`{{var}}`, DLT `{#var#}`) ---
162
+ /** Global: all `{{…}}` placeholders in a string. */
163
+ export const DEFAULT_MUSTACHE_VAR_REGEX = /\{\{[^}]+\}\}/g;
164
+ /** Global: `{{…}}` or DLT `{#…#}` tokens (SMS combined mode). */
165
+ export const COMBINED_SMS_TEMPLATE_VAR_REGEX = /\{\{[^}]+\}\}|\{\#[^#]*\#\}/g;
166
+ /** Full-string check: one mustache token. */
167
+ export const MUSTACHE_VAR_TOKEN_FULL_STRING_REGEX = /^\{\{[^}]+\}\}$/;
168
+ /** Full-string check: one DLT hash token. */
169
+ export const DLT_HASH_VAR_TOKEN_FULL_STRING_REGEX = /^\{\#[^#]*\#\}$/;
170
+ /** Global with capture group: inner name inside `{{name}}`. */
171
+ export const MUSTACHE_VAR_NAME_CAPTURE_REGEX = /\{\{([^}]+)\}\}/g;
172
+ /** Global with capture group: inner body inside `{#body#}`. */
173
+ export const DLT_VAR_BODY_CAPTURE_REGEX = /\{\#([^#]*)\#\}/g;
174
+
161
175
  export const GET_TRANSLATION_MAPPED = {
162
176
  'en': 'en-US',
163
177
  'zh-cn': 'zh',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.316-alpha.3",
4
+ "version": "8.0.316-alpha.4",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Shared utilities for templates containing {{var}} and DLT `{#var#}` tokens.
3
+ * Same split process used by WhatsApp/RCS: match vars with regex, then split content at each var.
4
+ */
5
+
6
+ import {
7
+ COMBINED_SMS_TEMPLATE_VAR_REGEX,
8
+ DEFAULT_MUSTACHE_VAR_REGEX,
9
+ DLT_HASH_VAR_TOKEN_FULL_STRING_REGEX,
10
+ DLT_VAR_BODY_CAPTURE_REGEX,
11
+ MUSTACHE_VAR_NAME_CAPTURE_REGEX,
12
+ MUSTACHE_VAR_TOKEN_FULL_STRING_REGEX,
13
+ } from '../constants/unified';
14
+
15
+ export { COMBINED_SMS_TEMPLATE_VAR_REGEX, DEFAULT_MUSTACHE_VAR_REGEX } from '../constants/unified';
16
+
17
+ const isMustacheVarToken = (s) =>
18
+ typeof s === 'string' && MUSTACHE_VAR_TOKEN_FULL_STRING_REGEX.test(s);
19
+
20
+ export const isDltHashVarToken = (s) =>
21
+ typeof s === 'string' && DLT_HASH_VAR_TOKEN_FULL_STRING_REGEX.test(s);
22
+
23
+ export const isAnyTemplateVarToken = (s) => isMustacheVarToken(s) || isDltHashVarToken(s);
24
+
25
+ /**
26
+ * `RegExp.prototype.exec` only advances `lastIndex` when the `g` and/or `y` flag is set.
27
+ * A non-global regex in a `while ((m = re.exec(s)) !== null)` loop never advances and can run forever.
28
+ *
29
+ * @param {RegExp} regex
30
+ * @returns {RegExp} Same instance if already global; otherwise a new RegExp with `g` appended to flags.
31
+ */
32
+ function ensureGlobalRegexForExecLoop(regex) {
33
+ if (!regex || !(regex instanceof RegExp) || regex.global) {
34
+ return regex;
35
+ }
36
+ return new RegExp(regex.source, `${regex.flags}g`);
37
+ }
38
+
39
+ /**
40
+ * Splits `content` into alternating plain-text segments and variable tokens, using the order of
41
+ * matches in `matchedVariableTokens` (e.g. from `String.prototype.match` with a global regex).
42
+ *
43
+ * @param {string[]} matchedVariableTokens - Matched tokens in left-to-right order
44
+ * @param {string} content - Full template string
45
+ * @returns {string[]}
46
+ */
47
+ export const splitContentByOrderedVarTokens = (matchedVariableTokens, content) => {
48
+ const segmentList = [];
49
+ const tokenQueue = [...(matchedVariableTokens ?? [])];
50
+ let remainder = content ?? '';
51
+ while ((remainder?.length ?? 0) > 0) {
52
+ const nextVarToken = tokenQueue?.[0];
53
+ if (nextVarToken == null || nextVarToken === '') {
54
+ segmentList.push(remainder);
55
+ break;
56
+ }
57
+ const varStartIndex = remainder.indexOf(nextVarToken);
58
+ if (varStartIndex !== -1) {
59
+ segmentList.push(remainder.substring(0, varStartIndex));
60
+ segmentList.push(nextVarToken);
61
+ const afterVar = varStartIndex + (nextVarToken?.length ?? 0);
62
+ remainder = remainder.substring(afterVar, remainder?.length ?? 0);
63
+ tokenQueue.shift();
64
+ } else {
65
+ segmentList.push(remainder);
66
+ break;
67
+ }
68
+ }
69
+ return segmentList;
70
+ };
71
+
72
+ /**
73
+ * Splits a template string into an array of text + {{var}} segments using the given regex.
74
+ *
75
+ * @param {string} str - Template string
76
+ * @param {RegExp} [varRegex] - Regex that matches var tokens; defaults to DEFAULT_MUSTACHE_VAR_REGEX
77
+ * @returns {string[]}
78
+ */
79
+ export const splitTemplateVarString = (str = '', varRegex = DEFAULT_MUSTACHE_VAR_REGEX) => {
80
+ if (!str) return [];
81
+ const matchedVariableTokens = str.match(varRegex) || [];
82
+ return splitContentByOrderedVarTokens(matchedVariableTokens, str).filter(
83
+ (segment) => segment === 0 || segment
84
+ );
85
+ };
86
+
87
+ /**
88
+ * Extracts unique variable names from `{{var}}` (and, when using default capture, `{#var#}`) strings.
89
+ *
90
+ * @param {string} templateStr
91
+ * @param {RegExp} [captureRegex] - regex with a single capture group for the var name; if omitted, also scans DLT `{#…#}`.
92
+ * @returns {string[]}
93
+ */
94
+ export const extractTemplateVariables = (templateStr = '', captureRegex) => {
95
+ if (!templateStr) return [];
96
+ const variables = [];
97
+ const add = (name) => {
98
+ const n = (name || '').trim();
99
+ if (n && !variables.includes(n)) variables.push(n);
100
+ };
101
+ const mustacheRe = ensureGlobalRegexForExecLoop(
102
+ captureRegex || MUSTACHE_VAR_NAME_CAPTURE_REGEX,
103
+ );
104
+ let match;
105
+ while ((match = mustacheRe.exec(templateStr)) !== null) {
106
+ add(match?.[1]);
107
+ }
108
+ if (!captureRegex) {
109
+ const dltRe = new RegExp(DLT_VAR_BODY_CAPTURE_REGEX.source, DLT_VAR_BODY_CAPTURE_REGEX.flags);
110
+ let dltMatch;
111
+ while ((dltMatch = dltRe.exec(templateStr)) !== null) {
112
+ add(dltMatch?.[1] || 'var');
113
+ }
114
+ }
115
+ return variables;
116
+ };
117
+
118
+ /**
119
+ * SMS / DLT template preview: replace `{{…}}` / `{#…#}` tokens using `varMapData` keys `${token}_${index}`.
120
+ * Used by SmsTraiEdit (RCS SMS fallback) and UnifiedPreview fallback SMS bubble.
121
+ * DLT `{#…#}`: empty / unset slot values show the raw token (matches DLT preview UX); mustache `{{…}}` still
122
+ * resolves to empty when the slot is cleared.
123
+ */
124
+ export const getFallbackResolvedContent = (templateStr = '', varMapData = {}) => {
125
+ const fallbackVarSlotMap = varMapData ?? {};
126
+ const templateSegments = splitTemplateVarString(templateStr, COMBINED_SMS_TEMPLATE_VAR_REGEX);
127
+ return templateSegments
128
+ .map((segment, segmentIndex) => {
129
+ const isVariableToken = typeof segment === 'string' && isAnyTemplateVarToken(segment);
130
+ if (!isVariableToken) return segment;
131
+ const slotKey = `${segment}_${segmentIndex}`;
132
+ if (Object.prototype.hasOwnProperty.call(fallbackVarSlotMap, slotKey)) {
133
+ const slotValue = fallbackVarSlotMap[slotKey];
134
+ if (isDltHashVarToken(segment)) {
135
+ if (slotValue == null) return segment;
136
+ const str = String(slotValue);
137
+ if (str.trim() === '') return segment;
138
+ return str;
139
+ }
140
+ return slotValue == null ? '' : String(slotValue);
141
+ }
142
+ if (isDltHashVarToken(segment)) return segment;
143
+ return '';
144
+ })
145
+ .join('');
146
+ };
147
+
148
+ /**
149
+ * SMS fallback **card** (library list): show filled slot text; if a slot is empty, unset, or
150
+ * whitespace-only, show the raw `{{…}}` / `{#…#}` token so “save without labels” still shows
151
+ * `{#var#}` in the card. {@link getFallbackResolvedContent} keeps DLT placeholders in preview when
152
+ * slots are empty; mustache cleared slots can still render empty.
153
+ */
154
+ export const getFallbackResolvedContentForCardDisplay = (templateStr = '', varMapData = {}) => {
155
+ const fallbackVarSlotMap = varMapData ?? {};
156
+ const templateSegments = splitTemplateVarString(templateStr, COMBINED_SMS_TEMPLATE_VAR_REGEX);
157
+ return templateSegments
158
+ .map((segment, segmentIndex) => {
159
+ const isVariableToken = typeof segment === 'string' && isAnyTemplateVarToken(segment);
160
+ if (!isVariableToken) return segment;
161
+ const slotKey = `${segment}_${segmentIndex}`;
162
+ if (Object.prototype.hasOwnProperty.call(fallbackVarSlotMap, slotKey)) {
163
+ const slotValue = fallbackVarSlotMap[slotKey];
164
+ if (slotValue == null) return segment;
165
+ const str = String(slotValue);
166
+ if (str.trim() === '') return segment;
167
+ return str;
168
+ }
169
+ return segment;
170
+ })
171
+ .join('');
172
+ };
@@ -0,0 +1,160 @@
1
+ import {
2
+ extractTemplateVariables,
3
+ splitTemplateVarString,
4
+ splitContentByOrderedVarTokens,
5
+ getFallbackResolvedContent,
6
+ getFallbackResolvedContentForCardDisplay,
7
+ isDltHashVarToken,
8
+ isAnyTemplateVarToken,
9
+ } from '../templateVarUtils';
10
+
11
+ describe('templateVarUtils', () => {
12
+ describe('splitContentByOrderedVarTokens', () => {
13
+ it('pushes remainder when next token is not found in string', () => {
14
+ expect(splitContentByOrderedVarTokens(['{{b}}'], 'hello')).toEqual(['hello']);
15
+ });
16
+
17
+ it('handles empty token queue by pushing remainder', () => {
18
+ expect(splitContentByOrderedVarTokens([], 'rest')).toEqual(['rest']);
19
+ });
20
+
21
+ it('handles null matchedVariableTokens as empty queue', () => {
22
+ expect(splitContentByOrderedVarTokens(null, 'abc')).toEqual(['abc']);
23
+ });
24
+
25
+ it('stops and pushes remainder when next token in queue is empty string', () => {
26
+ expect(splitContentByOrderedVarTokens(['{{a}}', ''], 'x{{a}}y')).toEqual(['x', '{{a}}', 'y']);
27
+ });
28
+
29
+ it('splits multiple ordered tokens across content', () => {
30
+ expect(splitContentByOrderedVarTokens(['{{a}}', '{{b}}'], 'p{{a}}q{{b}}r')).toEqual([
31
+ 'p',
32
+ '{{a}}',
33
+ 'q',
34
+ '{{b}}',
35
+ 'r',
36
+ ]);
37
+ });
38
+
39
+ it('returns empty segment list when content is null and queue is empty', () => {
40
+ expect(splitContentByOrderedVarTokens([], null)).toEqual([]);
41
+ });
42
+ });
43
+
44
+ describe('splitTemplateVarString', () => {
45
+ it('returns empty array for falsy string', () => {
46
+ expect(splitTemplateVarString('')).toEqual([]);
47
+ expect(splitTemplateVarString(null)).toEqual([]);
48
+ });
49
+
50
+ it('splits mustache segments', () => {
51
+ const parts = splitTemplateVarString('Hi {{name}}!');
52
+ expect(parts.some((p) => p.includes('name'))).toBe(true);
53
+ });
54
+
55
+ it('omits empty segments after filtering', () => {
56
+ expect(splitTemplateVarString('a{{x}}b')).toEqual(['a', '{{x}}', 'b']);
57
+ });
58
+ });
59
+
60
+ describe('getFallbackResolvedContent', () => {
61
+ it('replaces var segments from varMapData', () => {
62
+ const out = getFallbackResolvedContent('{{a}}', { '{{a}}_0': 'X' });
63
+ expect(out).toBe('X');
64
+ });
65
+
66
+ it('keeps DLT token string when slot empty', () => {
67
+ const out = getFallbackResolvedContent('{#v#}', {});
68
+ expect(out).toContain('#');
69
+ });
70
+
71
+ it('returns empty string for mustache slot when map has no value', () => {
72
+ const out = getFallbackResolvedContent('Hi {{missing}}', {});
73
+ expect(out).toBe('Hi ');
74
+ });
75
+
76
+ it('treats null as empty; explicit empty string is kept (cleared input)', () => {
77
+ expect(getFallbackResolvedContent('{{a}}', { '{{a}}_0': null })).toBe('');
78
+ expect(getFallbackResolvedContent('{{a}}', { '{{a}}_0': '' })).toBe('');
79
+ });
80
+
81
+ it('keeps DLT token in preview when slot map has empty string (no tag yet)', () => {
82
+ expect(getFallbackResolvedContent('{#v#}', { '{#v#}_0': '' })).toBe('{#v#}');
83
+ });
84
+
85
+ it('keeps DLT token in preview when slot map has null', () => {
86
+ expect(getFallbackResolvedContent('{#v#}', { '{#v#}_0': null })).toBe('{#v#}');
87
+ });
88
+
89
+ it('uses slot replacement when value is non-empty string', () => {
90
+ expect(getFallbackResolvedContent('A{{b}}C', { '{{b}}_1': 'ok' })).toBe('AokC');
91
+ });
92
+ });
93
+
94
+ describe('getFallbackResolvedContentForCardDisplay', () => {
95
+ it('shows raw tokens when map is empty or slots unset', () => {
96
+ expect(getFallbackResolvedContentForCardDisplay('Hi {{name}}', {})).toBe('Hi {{name}}');
97
+ expect(getFallbackResolvedContentForCardDisplay('{#var#}', {})).toBe('{#var#}');
98
+ });
99
+
100
+ it('shows raw token when slot key exists but value is empty (no labels)', () => {
101
+ expect(getFallbackResolvedContentForCardDisplay('{#var#}', { '{#var#}_0': '' })).toBe('{#var#}');
102
+ expect(getFallbackResolvedContentForCardDisplay('{{a}}', { '{{a}}_0': '' })).toBe('{{a}}');
103
+ });
104
+
105
+ it('replaces when slot has non-empty value', () => {
106
+ expect(getFallbackResolvedContentForCardDisplay('Hi {{name}}', { '{{name}}_1': 'Pat' })).toBe('Hi Pat');
107
+ });
108
+ });
109
+
110
+ describe('isDltHashVarToken / isAnyTemplateVarToken', () => {
111
+ it('detects DLT hash token', () => {
112
+ expect(isDltHashVarToken('{#x#}')).toBe(true);
113
+ expect(isDltHashVarToken('plain')).toBe(false);
114
+ });
115
+
116
+ it('isAnyTemplateVarToken combines mustache and DLT', () => {
117
+ expect(isAnyTemplateVarToken('{{a}}')).toBe(true);
118
+ expect(isAnyTemplateVarToken('{#x#}')).toBe(true);
119
+ });
120
+ });
121
+
122
+ describe('extractTemplateVariables', () => {
123
+ it('returns empty array for empty or missing template', () => {
124
+ expect(extractTemplateVariables('')).toEqual([]);
125
+ expect(extractTemplateVariables()).toEqual([]);
126
+ });
127
+
128
+ it('includes DLT {#var#} names when no custom capture regex', () => {
129
+ const vars = extractTemplateVariables('Hello {#promo#} and {{name}}');
130
+ expect(vars).toEqual(expect.arrayContaining(['promo', 'name']));
131
+ });
132
+
133
+ it('skips DLT scan when captureRegex is provided', () => {
134
+ const re = /\{\{([^}]+)\}\}/g;
135
+ expect(extractTemplateVariables('{#x#} {{y}}', re)).toEqual(['y']);
136
+ });
137
+
138
+ it('dedupes duplicate variable names', () => {
139
+ expect(extractTemplateVariables('{{a}} {{a}}')).toEqual(['a']);
140
+ });
141
+
142
+ it('uses default name var when DLT body capture is empty', () => {
143
+ const vars = extractTemplateVariables('{##}');
144
+ expect(vars).toContain('var');
145
+ });
146
+ });
147
+
148
+ describe('extractTemplateVariables (regex global)', () => {
149
+ it('extracts all vars when caller passes a non-global capture regex (avoids infinite exec loop)', () => {
150
+ const nonGlobal = /\{\{([^}]+)\}\}/;
151
+ expect(nonGlobal.global).toBe(false);
152
+ expect(extractTemplateVariables('{{a}} and {{b}}', nonGlobal)).toEqual(['a', 'b']);
153
+ });
154
+
155
+ it('still works when capture regex already has the global flag', () => {
156
+ const globalRe = /\{\{([^}]+)\}\}/g;
157
+ expect(extractTemplateVariables('{{x}} {{y}}', globalRe)).toEqual(['x', 'y']);
158
+ });
159
+ });
160
+ });
@@ -382,6 +382,9 @@ class CapTagList extends React.Component { // eslint-disable-line react/prefer-s
382
382
  render() {
383
383
  const {
384
384
  hidePopover = false, intl = {}, moduleFilterEnabled, label, modalProps, channel, fetchingSchemaError = false,
385
+ overlayStyle,
386
+ overlayClassName,
387
+ getPopupContainer,
385
388
  } = this.props;
386
389
  const {formatMessage} = intl;
387
390
  const {
@@ -474,6 +477,9 @@ class CapTagList extends React.Component { // eslint-disable-line react/prefer-s
474
477
  content={contentSection}
475
478
  trigger="click"
476
479
  placement={this.props.popoverPlacement || (channel === EMAIL.toUpperCase() ? "leftTop" : "rightTop")}
480
+ overlayStyle={overlayStyle}
481
+ overlayClassName={overlayClassName}
482
+ getPopupContainer={getPopupContainer}
477
483
  >
478
484
  <CapTooltip
479
485
  title={
@@ -545,6 +551,10 @@ CapTagList.propTypes = {
545
551
  disableTooltipMsg: PropTypes.string,
546
552
  fetchingSchemaError: PropTypes.bool,
547
553
  popoverPlacement: PropTypes.string,
554
+ overlayStyle: PropTypes.object,
555
+ overlayClassName: PropTypes.string,
556
+ /** e.g. () => document.body — avoids overflow/stacking issues inside slideboxes */
557
+ getPopupContainer: PropTypes.func,
548
558
  };
549
559
 
550
560
  CapTagList.defaultValue = {
@@ -8,6 +8,7 @@ import CapButton from '@capillarytech/cap-ui-library/CapButton';
8
8
  import CapInput from '@capillarytech/cap-ui-library/CapInput';
9
9
  import CapLabel from '@capillarytech/cap-ui-library/CapLabel';
10
10
  import messages from './messages';
11
+ import { CUSTOM_VALUES_EDITOR_SECTION_FALLBACK_KEY } from './constants';
11
12
 
12
13
  const CustomValuesEditor = ({
13
14
  isExtractingTags,
@@ -16,15 +17,16 @@ const CustomValuesEditor = ({
16
17
  setShowJSON,
17
18
  customValues,
18
19
  handleJSONTextChange,
19
- extractedTags,
20
- requiredTags,
21
- optionalTags,
20
+ sections,
22
21
  handleCustomValueChange,
23
22
  handleDiscardCustomValues,
24
23
  handleUpdatePreview,
25
24
  isUpdatingPreview,
26
25
  formatMessage,
27
26
  }) => {
27
+ /** Same as SMS Test & Preview: show token path from extract-tags (fullPath or name). */
28
+ const getPersonalizationTagColumnLabel = (tagNode) => tagNode?.fullPath ?? tagNode?.name ?? '';
29
+
28
30
  if (isExtractingTags) {
29
31
  return (
30
32
  <CapRow className="loading-container">
@@ -77,52 +79,68 @@ const CustomValuesEditor = ({
77
79
  </CapRow>
78
80
  ) : (
79
81
  <>
80
- {extractedTags?.length > 0 && (
81
- <CapRow className="values-table">
82
- <CapRow className="table-header">
83
- <CapLabel type="label31" className="header-cell">
84
- <FormattedMessage {...messages.personalizationTags} />
85
- </CapLabel>
86
- <CapLabel type="label31" className="header-cell">
87
- <FormattedMessage {...messages.customValues} />
82
+ {(sections || []).filter((tagsSection) =>
83
+ (tagsSection?.requiredTags?.length || 0) + (tagsSection?.optionalTags?.length || 0) > 0).map((section) => (
84
+ <React.Fragment key={section.key || section.title?.id || section.title || CUSTOM_VALUES_EDITOR_SECTION_FALLBACK_KEY}>
85
+ {section.title ? (
86
+ <CapLabel type="label2" className="tags-section-title">
87
+ {typeof section.title === 'string' ? section.title : <FormattedMessage {...section.title} />}
88
88
  </CapLabel>
89
- </CapRow>
90
- {requiredTags.map((tag) => (
91
- <CapRow key={tag.fullPath} className="value-row">
92
- <CapRow className="tag-name">
93
- {tag.fullPath}
94
- <span className="required-tag-indicator">*</span>
95
- </CapRow>
96
- <CapRow className="tag-input">
97
- <CapInput
98
- type="text"
99
- isRequired
100
- className="tag-input-field"
101
- value={customValues[tag.fullPath] || ''}
102
- onChange={(e) => handleCustomValueChange(tag.fullPath, e.target.value)}
103
- placeholder={formatMessage(messages.enterValue)}
104
- size="small"
105
- />
106
- </CapRow>
89
+ ) : null}
90
+ <CapRow className="values-table">
91
+ <CapRow className="table-header">
92
+ <CapLabel type="label31" className="header-cell">
93
+ <FormattedMessage {...messages.personalizationTags} />
94
+ </CapLabel>
95
+ <CapLabel type="label31" className="header-cell">
96
+ <FormattedMessage {...messages.customValues} />
97
+ </CapLabel>
107
98
  </CapRow>
108
- ))}
109
- {optionalTags?.map((tag) => (
110
- <CapRow key={tag.fullPath} className="value-row">
111
- <CapRow className="tag-name">{tag.fullPath}</CapRow>
112
- <CapRow className="tag-input">
113
- <CapInput
114
- type="text"
115
- className="tag-input-field"
116
- value={customValues[tag.fullPath] || ''}
117
- onChange={(e) => handleCustomValueChange(tag.fullPath, e.target.value)}
118
- placeholder={formatMessage(messages.enterValue)}
119
- size="small"
120
- />
99
+ {(section?.requiredTags || []).map((tag, tagIndex) => {
100
+ const personalizationTagColumnText = getPersonalizationTagColumnLabel(tag);
101
+ return (
102
+ <CapRow key={tag?.fullPath ?? `required-${tagIndex}`} className="value-row">
103
+ <CapRow className="tag-name">
104
+ {personalizationTagColumnText}
105
+ <span className="required-tag-indicator">*</span>
106
+ </CapRow>
107
+ <CapRow className="tag-input">
108
+ <CapInput
109
+ type="text"
110
+ isRequired
111
+ className="tag-input-field"
112
+ value={customValues?.[tag?.fullPath] ?? ''}
113
+ onChange={(e) => handleCustomValueChange(tag?.fullPath, e.target.value)}
114
+ placeholder={formatMessage(messages.enterValue)}
115
+ size="small"
116
+ />
117
+ </CapRow>
121
118
  </CapRow>
122
- </CapRow>
123
- ))}
124
- </CapRow>
125
- )}
119
+ );
120
+ })}
121
+ {(section?.optionalTags || []).map((tag, tagIndex) => {
122
+ const personalizationTagColumnText = getPersonalizationTagColumnLabel(tag);
123
+ return (
124
+ <CapRow key={tag?.fullPath ?? `optional-${tagIndex}`} className="value-row">
125
+ <CapRow className="tag-name">
126
+ {personalizationTagColumnText}
127
+ </CapRow>
128
+ <CapRow className="tag-input">
129
+ <CapInput
130
+ type="text"
131
+ className="tag-input-field"
132
+ value={customValues?.[tag?.fullPath] ?? ''}
133
+ onChange={(e) => handleCustomValueChange(tag?.fullPath, e.target.value)}
134
+ placeholder={formatMessage(messages.enterValue)}
135
+ size="small"
136
+ />
137
+ </CapRow>
138
+ </CapRow>
139
+ );
140
+ })}
141
+ </CapRow>
142
+ </React.Fragment>
143
+ ))}
126
144
  </>
127
145
  )}
128
146
  <CapRow className="editor-actions">
@@ -156,9 +174,12 @@ CustomValuesEditor.propTypes = {
156
174
  setShowJSON: PropTypes.func.isRequired,
157
175
  customValues: PropTypes.object.isRequired,
158
176
  handleJSONTextChange: PropTypes.func.isRequired,
159
- extractedTags: PropTypes.array.isRequired,
160
- requiredTags: PropTypes.array.isRequired,
161
- optionalTags: PropTypes.array.isRequired,
177
+ sections: PropTypes.arrayOf(PropTypes.shape({
178
+ key: PropTypes.string,
179
+ title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
180
+ requiredTags: PropTypes.array,
181
+ optionalTags: PropTypes.array,
182
+ })).isRequired,
162
183
  handleCustomValueChange: PropTypes.func.isRequired,
163
184
  handleDiscardCustomValues: PropTypes.func.isRequired,
164
185
  handleUpdatePreview: PropTypes.func.isRequired,
@@ -18,11 +18,17 @@
18
18
  }
19
19
 
20
20
  &__summary-entry {
21
+ display: flex;
22
+ align-items: baseline;
23
+ gap: 0;
21
24
  margin-right: $CAP_SPACE_18;
22
25
  }
23
26
 
24
- &__summary-key {
25
- line-height: $CAP_SPACE_16;
27
+ &__summary-key,
28
+ &__summary-value {
29
+ line-height: 1.4;
30
+ margin-top: 0;
31
+ margin-bottom: 0;
26
32
  }
27
33
 
28
34
  &__edit-icon {