@capillarytech/creatives-library 8.0.319 → 8.0.320

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 (106) 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 +352 -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 +341 -0
  26. package/v2Components/CommonTestAndPreview/tests/SendTestMessage.test.js +25 -4
  27. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +199 -1
  28. package/v2Components/CommonTestAndPreview/tests/index.test.js +132 -4
  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 +422 -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 +289 -99
  58. package/v2Containers/CreativesContainer/index.scss +51 -1
  59. package/v2Containers/CreativesContainer/tests/SlideBoxContent.localTemplates.test.js +90 -0
  60. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +104 -0
  61. package/v2Containers/CreativesContainer/tests/SlideBoxHeader.test.js +110 -0
  62. package/v2Containers/CreativesContainer/tests/__snapshots__/SlideBoxContent.test.js.snap +8 -0
  63. package/v2Containers/CreativesContainer/tests/__snapshots__/SlideBoxHeader.test.js.snap +363 -0
  64. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +20 -10
  65. package/v2Containers/CreativesContainer/tests/embeddedSlideboxUtils.test.js +258 -0
  66. package/v2Containers/CreativesContainer/tests/index.test.js +71 -9
  67. package/v2Containers/CreativesContainer/tests/useLocalTemplatesProp.test.js +125 -0
  68. package/v2Containers/Rcs/constants.js +32 -1
  69. package/v2Containers/Rcs/index.js +950 -873
  70. package/v2Containers/Rcs/index.scss +85 -6
  71. package/v2Containers/Rcs/messages.js +10 -1
  72. package/v2Containers/Rcs/rcsLibraryHydrationUtils.js +205 -0
  73. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +40834 -1963
  74. package/v2Containers/Rcs/tests/__snapshots__/utils.test.js.snap +0 -5
  75. package/v2Containers/Rcs/tests/index.test.js +41 -38
  76. package/v2Containers/Rcs/tests/mockData.js +38 -0
  77. package/v2Containers/Rcs/tests/rcsLibraryHydrationUtils.test.js +251 -0
  78. package/v2Containers/Rcs/tests/utils.test.js +379 -1
  79. package/v2Containers/Rcs/utils.js +358 -10
  80. package/v2Containers/Sms/Create/index.js +81 -36
  81. package/v2Containers/Sms/smsFormDataHelpers.js +67 -0
  82. package/v2Containers/Sms/tests/smsFormDataHelpers.test.js +253 -0
  83. package/v2Containers/SmsTrai/Create/index.js +9 -4
  84. package/v2Containers/SmsTrai/Edit/constants.js +2 -0
  85. package/v2Containers/SmsTrai/Edit/index.js +609 -128
  86. package/v2Containers/SmsTrai/Edit/index.scss +121 -0
  87. package/v2Containers/SmsTrai/Edit/messages.js +9 -4
  88. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +4327 -2374
  89. package/v2Containers/SmsWrapper/index.js +37 -8
  90. package/v2Containers/TagList/index.js +6 -0
  91. package/v2Containers/Templates/TemplatesActionBar.js +101 -0
  92. package/v2Containers/Templates/_templates.scss +61 -2
  93. package/v2Containers/Templates/actions.js +11 -0
  94. package/v2Containers/Templates/constants.js +2 -0
  95. package/v2Containers/Templates/index.js +90 -40
  96. package/v2Containers/Templates/sagas.js +57 -12
  97. package/v2Containers/Templates/tests/TemplatesActionBar.test.js +120 -0
  98. package/v2Containers/Templates/tests/__snapshots__/index.test.js.snap +1043 -1079
  99. package/v2Containers/Templates/tests/sagas.test.js +193 -12
  100. package/v2Containers/Templates/tests/smsTemplatesListApi.test.js +180 -0
  101. package/v2Containers/Templates/utils/smsTemplatesListApi.js +79 -0
  102. package/v2Containers/TemplatesV2/TemplatesV2.style.js +72 -1
  103. package/v2Containers/TemplatesV2/index.js +86 -23
  104. package/v2Containers/TemplatesV2/tests/TemplatesV2.localTemplates.test.js +131 -0
  105. package/v2Containers/Whatsapp/index.js +3 -20
  106. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +578 -34
@@ -24,6 +24,20 @@ export default defineMessages({
24
24
  id: `${scope}.personalizationTags`,
25
25
  defaultMessage: 'Personalization Tags',
26
26
  },
27
+ rcsTagsSectionTitle: {
28
+ id: `${scope}.rcsTagsSectionTitle`,
29
+ defaultMessage: 'RCS Tags',
30
+ },
31
+ /** Primary SMS section when channel is SMS — `messages[`${channel}TagsSectionTitle`]` resolves to this. */
32
+ SMSTagsSectionTitle: {
33
+ id: `${scope}.SMSTagsSectionTitle`,
34
+ defaultMessage: 'SMS tags',
35
+ },
36
+ /** Fallback SMS under RCS — same label as SMS Test & Preview for consistent personalization UX. */
37
+ smsFallbackTagsSectionTitle: {
38
+ id: `${scope}.smsFallbackTagsSectionTitle`,
39
+ defaultMessage: 'SMS tags',
40
+ },
27
41
  customValues: {
28
42
  id: `${scope}.customValues`,
29
43
  defaultMessage: 'Custom Values',
@@ -36,6 +50,14 @@ export default defineMessages({
36
50
  id: `${scope}.discardCustomValues`,
37
51
  defaultMessage: 'Discard custom values',
38
52
  },
53
+ rcsSenderIdLabel: {
54
+ id: `${scope}.rcsSenderIdLabel`,
55
+ defaultMessage: 'RCS Sender ID',
56
+ },
57
+ fallbackSmsSenderIdLabel: {
58
+ id: `${scope}.fallbackSmsSenderIdLabel`,
59
+ defaultMessage: 'Fallback SMS Sender ID',
60
+ },
39
61
  updatePreview: {
40
62
  id: `${scope}.updatePreview`,
41
63
  defaultMessage: 'Update Preview',
@@ -92,6 +114,14 @@ export default defineMessages({
92
114
  id: `${scope}.previewTitle`,
93
115
  defaultMessage: 'Preview',
94
116
  },
117
+ rcsTab: {
118
+ id: `${scope}.rcsTab`,
119
+ defaultMessage: 'RCS',
120
+ },
121
+ smsFallbackTab: {
122
+ id: `${scope}.smsFallbackTab`,
123
+ defaultMessage: 'Fallback SMS',
124
+ },
95
125
  previewPlaceholder: {
96
126
  id: `${scope}.previewPlaceholder`,
97
127
  defaultMessage: 'Click "Update Preview" to see the rendered email.',
@@ -180,14 +210,22 @@ export default defineMessages({
180
210
  id: `${scope}.lastModified`,
181
211
  defaultMessage: 'Last modified',
182
212
  },
183
- by: {
184
- id: `${scope}.by`,
185
- defaultMessage: 'by',
213
+ byAuthor: {
214
+ id: `${scope}.byAuthor`,
215
+ defaultMessage: 'by {name}',
186
216
  },
187
217
  senderId: {
188
218
  id: `${scope}.senderId`,
189
219
  defaultMessage: 'Sender ID',
190
220
  },
221
+ rcsSenderIdLabel: {
222
+ id: `${scope}.rcsSenderIdLabel`,
223
+ defaultMessage: 'RCS sender ID',
224
+ },
225
+ domainLabel: {
226
+ id: `${scope}.domainLabel`,
227
+ defaultMessage: 'Domain',
228
+ },
191
229
  urlPreviewImage: {
192
230
  id: `${scope}.urlPreviewImage`,
193
231
  defaultMessage: 'URL Preview Image',
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Helpers for Liquid `/preview` (updateEmailPreview) responses.
3
+ * The API may nest the payload under `data` or return fields at the top level.
4
+ * SMS responses often use `messageBody` instead of `resolvedBody`.
5
+ */
6
+
7
+ import { getFallbackResolvedContent } from '../../utils/templateVarUtils';
8
+ import { RCS_SMS_FALLBACK_VAR_MAPPED_PROP } from './constants';
9
+
10
+ export function normalizePreviewApiPayload(raw) {
11
+ if (!raw || typeof raw !== 'object') return raw;
12
+ const next = { ...raw };
13
+ if (next.resolvedBody == null) {
14
+ const alt = next.messageBody ?? next.previewMessage ?? next.previewText;
15
+ if (alt != null) next.resolvedBody = alt;
16
+ }
17
+ return next;
18
+ }
19
+
20
+ /**
21
+ * Returns a normalized preview object suitable for Redux `previewData`, or null on error / empty.
22
+ */
23
+ export function extractPreviewFromLiquidResponse(response) {
24
+ if (!response || response.error) return null;
25
+ if (response.errors && Array.isArray(response.errors) && response.errors.length > 0) return null;
26
+
27
+ let body;
28
+ if (response.data !== undefined && response.data !== null && typeof response.data === 'object') {
29
+ body = normalizePreviewApiPayload(response.data);
30
+ } else if (typeof response === 'object' && !Array.isArray(response)) {
31
+ body = normalizePreviewApiPayload(response);
32
+ } else {
33
+ return null;
34
+ }
35
+
36
+ if (!body) return null;
37
+ if (body.resolvedBody === undefined && body.messageBody === undefined) {
38
+ return null;
39
+ }
40
+ return body;
41
+ }
42
+
43
+ /**
44
+ * RCS SMS fallback: merge template (`templateContent` / `content`) with VarSegment
45
+ * `rcsSmsFallbackVarMapped` before Liquid /preview, tag extraction, or createMessageMeta.
46
+ * Raw template alone is stale when the user edits slot values.
47
+ */
48
+ export function getSmsFallbackTextForTagExtraction(smsFallbackContext) {
49
+ const rawTemplateBody =
50
+ smsFallbackContext?.templateContent ?? smsFallbackContext?.content ?? '';
51
+ if (!rawTemplateBody) return '';
52
+ const rcsSmsFallbackVarMapped = smsFallbackContext?.[RCS_SMS_FALLBACK_VAR_MAPPED_PROP];
53
+ const hasRcsSmsFallbackVarMappedEntries =
54
+ Object.keys(rcsSmsFallbackVarMapped ?? {}).length > 0;
55
+ if (hasRcsSmsFallbackVarMappedEntries) {
56
+ return getFallbackResolvedContent(rawTemplateBody, rcsSmsFallbackVarMapped ?? {});
57
+ }
58
+ return rawTemplateBody;
59
+ }
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import {
6
- call, put, takeLatest, all,
6
+ call, put, takeLatest, takeEvery, all,
7
7
  } from 'redux-saga/effects';
8
8
  import get from 'lodash/get';
9
9
  import isEmpty from 'lodash/isEmpty';
@@ -45,6 +45,7 @@ import {
45
45
  CHANNELS,
46
46
  } from './constants';
47
47
  import { parseSenderDetailsResponse } from './DeliverySettings/utils/parseSenderDetailsResponse';
48
+ import { extractPreviewFromLiquidResponse } from './previewApiUtils';
48
49
 
49
50
  // Search Customers Saga
50
51
  export function* searchCustomersSaga(action) {
@@ -91,11 +92,12 @@ export function* updatePreviewSaga(action) {
91
92
  const customValues = action.payload.resolvedTags;
92
93
 
93
94
  const response = yield call(Api.updateEmailPreview, action.payload);
94
- if (response?.data) {
95
+ const previewPayload = extractPreviewFromLiquidResponse(response);
96
+ if (previewPayload) {
95
97
  yield put({
96
98
  type: UPDATE_PREVIEW_SUCCESS,
97
99
  payload: {
98
- previewData: response.data,
100
+ previewData: previewPayload,
99
101
  customValues, // Pass custom values to be preserved
100
102
  },
101
103
  });
@@ -236,8 +238,13 @@ export function* createMessageMetaSaga(action) {
236
238
  export function* getPrefilledValuesSaga(action) {
237
239
  try {
238
240
  const response = yield call(Api.updateEmailPreview, action.payload);
239
- if (response?.data) {
240
- yield put({ type: GET_PREFILLED_VALUES_SUCCESS, payload: { values: response?.data?.resolvedTagValues } });
241
+ const body =
242
+ response?.data !== undefined && response?.data !== null
243
+ ? response.data
244
+ : response;
245
+ const resolvedTagValues = body?.resolvedTagValues;
246
+ if (resolvedTagValues != null) {
247
+ yield put({ type: GET_PREFILLED_VALUES_SUCCESS, payload: { values: resolvedTagValues } });
241
248
  } else {
242
249
  // Pass all errors from API response to state
243
250
  yield put({
@@ -337,7 +344,9 @@ export function* getWeCrmAccountsSaga(action) {
337
344
  }
338
345
 
339
346
  export function* watchGetSenderDetails() {
340
- yield takeLatest(GET_SENDER_DETAILS_REQUESTED, getSenderDetailsSaga);
347
+ // takeEvery: RCS test & preview dispatches RCS then SMS back-to-back; takeLatest would
348
+ // cancel the RCS fetch so senderDetailsByChannel.RCS stayed empty while SMS loaded.
349
+ yield takeEvery(GET_SENDER_DETAILS_REQUESTED, getSenderDetailsSaga);
341
350
  }
342
351
 
343
352
  export function* watchGetWeCrmAccounts() {
@@ -0,0 +1,352 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react';
5
+ import { render, screen, fireEvent } from '@testing-library/react';
6
+ import '@testing-library/jest-dom';
7
+ import { IntlProvider } from 'react-intl';
8
+ import CustomValuesEditor from '../CustomValuesEditor';
9
+
10
+ jest.mock('@capillarytech/cap-ui-library/CapRow', () => {
11
+ const React = require('react');
12
+ return function CapRow(props) {
13
+ return React.createElement('div', { className: props?.className, 'data-testid': 'cap-row' }, props?.children);
14
+ };
15
+ });
16
+ jest.mock('@capillarytech/cap-ui-library/CapSpin', () => {
17
+ const React = require('react');
18
+ return function CapSpin() {
19
+ return React.createElement('div', { 'data-testid': 'cap-spin' }, 'spin');
20
+ };
21
+ });
22
+ jest.mock('@capillarytech/cap-ui-library/CapSwitch', () => {
23
+ const React = require('react');
24
+ return function CapSwitch(props) {
25
+ return React.createElement('input', {
26
+ type: 'checkbox',
27
+ 'data-testid': 'json-switch',
28
+ checked: props?.checked,
29
+ onChange: (e) => props?.onChange?.(e.target.checked),
30
+ });
31
+ };
32
+ });
33
+ jest.mock('@capillarytech/cap-ui-library/CapButton', () => {
34
+ const React = require('react');
35
+ return function CapButton(props) {
36
+ return React.createElement('button', {
37
+ type: 'button',
38
+ 'data-testid': 'cap-button',
39
+ onClick: props?.onClick,
40
+ disabled: props?.disabled,
41
+ 'data-loading': props?.loading,
42
+ }, props?.children);
43
+ };
44
+ });
45
+ jest.mock('@capillarytech/cap-ui-library/CapInput', () => {
46
+ const React = require('react');
47
+ return function CapInput(props) {
48
+ return React.createElement('input', {
49
+ 'data-testid': 'tag-input',
50
+ value: props?.value,
51
+ placeholder: props?.placeholder,
52
+ onChange: props?.onChange,
53
+ });
54
+ };
55
+ });
56
+ jest.mock('@capillarytech/cap-ui-library/CapLabel', () => {
57
+ const React = require('react');
58
+ return function CapLabel(props) {
59
+ return React.createElement('div', { className: props?.className, 'data-testid': 'cap-label' }, props?.children);
60
+ };
61
+ });
62
+
63
+ const formatMessage = (msg) => (typeof msg === 'object' && msg?.id ? msg.id : String(msg));
64
+
65
+ const baseProps = {
66
+ isExtractingTags: false,
67
+ isUpdatePreviewDisabled: false,
68
+ showJSON: false,
69
+ setShowJSON: jest.fn(),
70
+ customValues: { 'tag.a': 'v1' },
71
+ handleJSONTextChange: jest.fn(),
72
+ sections: [
73
+ {
74
+ key: 'sec1',
75
+ title: 'Section A',
76
+ requiredTags: [{ fullPath: 'tag.a', name: 'a' }],
77
+ optionalTags: [{ fullPath: 'tag.b', name: 'b' }],
78
+ },
79
+ ],
80
+ handleCustomValueChange: jest.fn(),
81
+ handleDiscardCustomValues: jest.fn(),
82
+ handleUpdatePreview: jest.fn(),
83
+ isUpdatingPreview: false,
84
+ formatMessage,
85
+ };
86
+
87
+ describe('CustomValuesEditor', () => {
88
+ beforeEach(() => {
89
+ jest.clearAllMocks();
90
+ });
91
+
92
+ it('shows loading state when isExtractingTags', () => {
93
+ render(
94
+ <IntlProvider locale="en" messages={{}}>
95
+ <CustomValuesEditor {...baseProps} isExtractingTags />
96
+ </IntlProvider>,
97
+ );
98
+ expect(screen.getByTestId('cap-spin')).toBeInTheDocument();
99
+ });
100
+
101
+ it('shows values missing label when isUpdatePreviewDisabled', () => {
102
+ render(
103
+ <IntlProvider locale="en" messages={{}}>
104
+ <CustomValuesEditor {...baseProps} isUpdatePreviewDisabled />
105
+ </IntlProvider>,
106
+ );
107
+ expect(document.querySelector('.values-missing-message')).toBeTruthy();
108
+ });
109
+
110
+ it('toggles JSON mode and calls setShowJSON', () => {
111
+ const setShowJSON = jest.fn();
112
+ render(
113
+ <IntlProvider locale="en" messages={{}}>
114
+ <CustomValuesEditor {...baseProps} setShowJSON={setShowJSON} />
115
+ </IntlProvider>,
116
+ );
117
+ fireEvent.click(screen.getByTestId('json-switch'));
118
+ expect(setShowJSON).toHaveBeenCalled();
119
+ });
120
+
121
+ it('renders JSON textarea and fires handleJSONTextChange', () => {
122
+ render(
123
+ <IntlProvider locale="en" messages={{}}>
124
+ <CustomValuesEditor {...baseProps} showJSON />
125
+ </IntlProvider>,
126
+ );
127
+ const ta = document.querySelector('.json-textarea');
128
+ expect(ta).toBeTruthy();
129
+ fireEvent.change(ta, { target: { value: '{}' } });
130
+ expect(baseProps.handleJSONTextChange).toHaveBeenCalledWith('{}');
131
+ });
132
+
133
+ it('renders required and optional tag inputs when not JSON', () => {
134
+ render(
135
+ <IntlProvider locale="en" messages={{}}>
136
+ <CustomValuesEditor {...baseProps} />
137
+ </IntlProvider>,
138
+ );
139
+ const inputs = screen.getAllByTestId('tag-input');
140
+ expect(inputs.length).toBeGreaterThan(0);
141
+ fireEvent.change(inputs[0], { target: { value: 'new' } });
142
+ expect(baseProps.handleCustomValueChange).toHaveBeenCalledWith('tag.a', 'new');
143
+ });
144
+
145
+ it('calls discard and update handlers', () => {
146
+ render(
147
+ <IntlProvider locale="en" messages={{}}>
148
+ <CustomValuesEditor {...baseProps} />
149
+ </IntlProvider>,
150
+ );
151
+ const buttons = screen.getAllByTestId('cap-button');
152
+ fireEvent.click(buttons[0]);
153
+ fireEvent.click(buttons[buttons.length - 1]);
154
+ expect(baseProps.handleDiscardCustomValues).toHaveBeenCalled();
155
+ expect(baseProps.handleUpdatePreview).toHaveBeenCalled();
156
+ });
157
+
158
+ it('skips sections with no tags', () => {
159
+ render(
160
+ <IntlProvider locale="en" messages={{}}>
161
+ <CustomValuesEditor
162
+ {...baseProps}
163
+ sections={[
164
+ { key: 'empty', requiredTags: [], optionalTags: [] },
165
+ baseProps.sections[0],
166
+ ]}
167
+ />
168
+ </IntlProvider>,
169
+ );
170
+ expect(screen.getAllByTestId('tag-input').length).toBeGreaterThan(0);
171
+ });
172
+
173
+ it('renders section title as FormattedMessage when title is an intl message object (not a string)', () => {
174
+ render(
175
+ <IntlProvider locale="en" messages={{ 'section.title.id': 'Intl Section Title' }}>
176
+ <CustomValuesEditor
177
+ {...baseProps}
178
+ sections={[
179
+ {
180
+ key: 'intl-sec',
181
+ title: { id: 'section.title.id', defaultMessage: 'Intl Section Title' },
182
+ requiredTags: [{ fullPath: 'tag.a', name: 'a' }],
183
+ optionalTags: [],
184
+ },
185
+ ]}
186
+ />
187
+ </IntlProvider>,
188
+ );
189
+ expect(screen.getByText('Intl Section Title')).toBeInTheDocument();
190
+ });
191
+
192
+ it('renders section title as plain text when title is a string', () => {
193
+ render(
194
+ <IntlProvider locale="en" messages={{}}>
195
+ <CustomValuesEditor
196
+ {...baseProps}
197
+ sections={[
198
+ {
199
+ key: 'str-sec',
200
+ title: 'Plain String Title',
201
+ requiredTags: [{ fullPath: 'tag.a', name: 'a' }],
202
+ optionalTags: [],
203
+ },
204
+ ]}
205
+ />
206
+ </IntlProvider>,
207
+ );
208
+ expect(screen.getByText('Plain String Title')).toBeInTheDocument();
209
+ });
210
+
211
+ it('renders nothing for section title when title is null (does not crash)', () => {
212
+ render(
213
+ <IntlProvider locale="en" messages={{}}>
214
+ <CustomValuesEditor
215
+ {...baseProps}
216
+ sections={[
217
+ {
218
+ key: 'no-title-sec',
219
+ title: null,
220
+ requiredTags: [{ fullPath: 'tag.a', name: 'a' }],
221
+ optionalTags: [],
222
+ },
223
+ ]}
224
+ />
225
+ </IntlProvider>,
226
+ );
227
+ const inputs = screen.getAllByTestId('tag-input');
228
+ expect(inputs.length).toBeGreaterThan(0);
229
+ });
230
+
231
+ it('uses tag.name as column label when fullPath is absent', () => {
232
+ render(
233
+ <IntlProvider locale="en" messages={{}}>
234
+ <CustomValuesEditor
235
+ {...baseProps}
236
+ sections={[
237
+ {
238
+ key: 'name-only',
239
+ title: 'Section',
240
+ requiredTags: [{ name: 'myTag' }],
241
+ optionalTags: [],
242
+ },
243
+ ]}
244
+ />
245
+ </IntlProvider>,
246
+ );
247
+ expect(screen.getByText('myTag')).toBeInTheDocument();
248
+ });
249
+
250
+ it('uses empty string as column label when both fullPath and name are absent', () => {
251
+ render(
252
+ <IntlProvider locale="en" messages={{}}>
253
+ <CustomValuesEditor
254
+ {...baseProps}
255
+ sections={[
256
+ {
257
+ key: 'no-label',
258
+ title: 'Section',
259
+ requiredTags: [{}],
260
+ optionalTags: [],
261
+ },
262
+ ]}
263
+ />
264
+ </IntlProvider>,
265
+ );
266
+ const inputs = screen.getAllByTestId('tag-input');
267
+ expect(inputs.length).toBeGreaterThan(0);
268
+ });
269
+
270
+ it('renders optional-only section (no requiredTags) correctly', () => {
271
+ render(
272
+ <IntlProvider locale="en" messages={{}}>
273
+ <CustomValuesEditor
274
+ {...baseProps}
275
+ sections={[
276
+ {
277
+ key: 'optional-only',
278
+ title: 'Optional Section',
279
+ requiredTags: [],
280
+ optionalTags: [{ fullPath: 'tag.opt', name: 'opt' }],
281
+ },
282
+ ]}
283
+ />
284
+ </IntlProvider>,
285
+ );
286
+ expect(screen.getByText('Optional Section')).toBeInTheDocument();
287
+ const inputs = screen.getAllByTestId('tag-input');
288
+ expect(inputs.length).toBeGreaterThan(0);
289
+ });
290
+
291
+ it('renders required-only section (no optionalTags) correctly', () => {
292
+ render(
293
+ <IntlProvider locale="en" messages={{}}>
294
+ <CustomValuesEditor
295
+ {...baseProps}
296
+ sections={[
297
+ {
298
+ key: 'required-only',
299
+ title: 'Required Section',
300
+ requiredTags: [{ fullPath: 'tag.req', name: 'req' }],
301
+ optionalTags: [],
302
+ },
303
+ ]}
304
+ />
305
+ </IntlProvider>,
306
+ );
307
+ expect(screen.getByText('Required Section')).toBeInTheDocument();
308
+ const inputs = screen.getAllByTestId('tag-input');
309
+ expect(inputs.length).toBeGreaterThan(0);
310
+ });
311
+
312
+ it('renders line numbers in JSON mode matching JSON line count', () => {
313
+ const multiValueCustomValues = { a: 'val1', b: 'val2', c: 'val3' };
314
+ render(
315
+ <IntlProvider locale="en" messages={{}}>
316
+ <CustomValuesEditor
317
+ {...baseProps}
318
+ showJSON
319
+ customValues={multiValueCustomValues}
320
+ />
321
+ </IntlProvider>,
322
+ );
323
+ const lineNumbers = document.querySelectorAll('.line-number');
324
+ const expectedLineCount = JSON.stringify(multiValueCustomValues, null, 2).split('\n').length;
325
+ expect(lineNumbers.length).toBe(expectedLineCount);
326
+ });
327
+
328
+ it('passes fullPath as key for required tag row when fullPath is present', () => {
329
+ const handleChange = jest.fn();
330
+ render(
331
+ <IntlProvider locale="en" messages={{}}>
332
+ <CustomValuesEditor
333
+ {...baseProps}
334
+ sections={[
335
+ {
336
+ key: 'sec',
337
+ title: 'S',
338
+ requiredTags: [{ fullPath: 'my.full.path', name: 'name' }],
339
+ optionalTags: [],
340
+ },
341
+ ]}
342
+ handleCustomValueChange={handleChange}
343
+ customValues={{ 'my.full.path': 'existing' }}
344
+ />
345
+ </IntlProvider>,
346
+ );
347
+ const input = screen.getByTestId('tag-input');
348
+ expect(input.value).toBe('existing');
349
+ fireEvent.change(input, { target: { value: 'updated' } });
350
+ expect(handleChange).toHaveBeenCalledWith('my.full.path', 'updated');
351
+ });
352
+ });