@capillarytech/creatives-library 9.0.56-betaa.0 → 9.0.57-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 (91) hide show
  1. package/constants/unified.js +7 -1
  2. package/package.json +1 -1
  3. package/services/api.js +25 -4
  4. package/utils/common.js +0 -4
  5. package/utils/templateVarUtils.js +16 -0
  6. package/utils/tests/templateVarUtils.test.js +44 -0
  7. package/v2Components/CapActionButton/index.js +73 -60
  8. package/v2Components/CapActionButton/index.scss +44 -28
  9. package/v2Components/CapActionButton/messages.js +7 -3
  10. package/v2Components/CapActionButton/tests/index.test.js +17 -1
  11. package/v2Components/CapWhatsappCTA/messages.js +4 -0
  12. package/v2Components/CapWhatsappCarouselButton/index.js +42 -33
  13. package/v2Components/CapWhatsappCarouselButton/index.scss +44 -2
  14. package/v2Components/CommonTestAndPreview/UnifiedPreview/index.js +13 -0
  15. package/v2Components/CommonTestAndPreview/actions.js +13 -0
  16. package/v2Components/CommonTestAndPreview/constants.js +8 -0
  17. package/v2Components/CommonTestAndPreview/index.js +55 -3
  18. package/v2Components/CommonTestAndPreview/reducer.js +20 -0
  19. package/v2Components/CommonTestAndPreview/sagas.js +28 -0
  20. package/v2Components/CommonTestAndPreview/selectors.js +6 -0
  21. package/v2Components/CommonTestAndPreview/tests/UnifiedPreview/index.test.js +106 -0
  22. package/v2Components/CommonTestAndPreview/tests/actions.test.js +15 -0
  23. package/v2Components/CommonTestAndPreview/tests/constants.test.js +9 -1
  24. package/v2Components/CommonTestAndPreview/tests/index.test.js +101 -0
  25. package/v2Components/CommonTestAndPreview/tests/reducer.test.js +41 -0
  26. package/v2Components/CommonTestAndPreview/tests/sagas.test.js +84 -0
  27. package/v2Components/CommonTestAndPreview/tests/selectors.test.js +12 -0
  28. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +36 -22
  29. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +5 -13
  30. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +6 -4
  31. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +13 -14
  32. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +15 -8
  33. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +31 -0
  34. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +13 -0
  35. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +26 -0
  36. package/v2Components/FormBuilder/_formBuilder.scss +8 -0
  37. package/v2Components/TestAndPreviewSlidebox/index.js +9 -0
  38. package/v2Containers/CommunicationFlow/CommunicationFlow.js +140 -75
  39. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +18 -2
  40. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +67 -28
  41. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +503 -51
  42. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +98 -4
  43. package/v2Containers/CommunicationFlow/Tests/constants.test.js +85 -0
  44. package/v2Containers/CommunicationFlow/constants.js +65 -4
  45. package/v2Containers/CommunicationFlow/index.js +16 -23
  46. package/v2Containers/CommunicationFlow/messages.js +11 -3
  47. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +27 -11
  48. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +4 -0
  49. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +176 -4
  50. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +22 -4
  51. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +14 -4
  52. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  53. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +51 -0
  54. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +11 -4
  55. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +19 -5
  56. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  57. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  58. package/v2Containers/CreativesContainer/index.js +29 -3
  59. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  60. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +18 -5
  61. package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
  62. package/v2Containers/MobilePush/Create/index.js +18 -1
  63. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  64. package/v2Containers/MobilePush/Edit/index.js +18 -1
  65. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  66. package/v2Containers/MobilePush/commonMethods.js +49 -1
  67. package/v2Containers/MobilePushNew/index.js +29 -4
  68. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  69. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  70. package/v2Containers/MobilePushNew/utils.js +34 -1
  71. package/v2Containers/MobilepushWrapper/index.js +3 -1
  72. package/v2Containers/Rcs/index.js +34 -0
  73. package/v2Containers/Rcs/index.scss +15 -0
  74. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +115 -19
  75. package/v2Containers/Rcs/tests/index.test.js +67 -0
  76. package/v2Containers/Sms/Create/index.js +3 -1
  77. package/v2Containers/Sms/Edit/index.js +25 -0
  78. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  79. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +27 -3
  80. package/v2Containers/Viber/index.js +24 -1
  81. package/v2Containers/Viber/tests/index.test.js +103 -0
  82. package/v2Containers/WebPush/Create/index.js +19 -0
  83. package/v2Containers/WebPush/Create/preview/WebPushPreview.js +17 -12
  84. package/v2Containers/WebPush/Create/preview/tests/WebPushPreview.test.js +5 -0
  85. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  86. package/v2Containers/Whatsapp/index.js +25 -4
  87. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +306 -34
  88. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  89. package/v2Containers/Zalo/index.js +28 -1
  90. package/v2Containers/Zalo/tests/index.test.js +99 -0
  91. package/v2Containers/mockdata.js +25 -0
@@ -19,6 +19,9 @@ import {
19
19
  SEND_TEST_MESSAGE_REQUESTED,
20
20
  SEND_TEST_MESSAGE_SUCCESS,
21
21
  SEND_TEST_MESSAGE_FAILURE,
22
+ SEND_CCS_TEST_MESSAGE_REQUESTED,
23
+ SEND_CCS_TEST_MESSAGE_SUCCESS,
24
+ SEND_CCS_TEST_MESSAGE_FAILURE,
22
25
  CLEAR_CUSTOMER_SEARCH_STATE,
23
26
  CLEAR_SEARCH_RESULTS,
24
27
  GET_TEST_CUSTOMERS_REQUESTED,
@@ -69,6 +72,8 @@ describe('previewAndTestReducer', () => {
69
72
  isSendingTestMessage: false,
70
73
  sendTestMessageError: null,
71
74
  testMessageResponse: null,
75
+ isSendingCcsTestMessage: false,
76
+ sendCcsTestMessageError: null,
72
77
  tags: {
73
78
  loading: false,
74
79
  error: null,
@@ -533,6 +538,42 @@ describe('previewAndTestReducer', () => {
533
538
  });
534
539
  });
535
540
 
541
+ describe('SEND_CCS_TEST_MESSAGE_REQUESTED', () => {
542
+ it('should set the sending flag and clear errors', () => {
543
+ const action = { type: SEND_CCS_TEST_MESSAGE_REQUESTED };
544
+ const result = previewAndTestReducer(initialState, action);
545
+
546
+ expect(result.get('isSendingCcsTestMessage')).toBe(true);
547
+ expect(result.get('sendCcsTestMessageError')).toBeNull();
548
+ });
549
+ });
550
+
551
+ describe('SEND_CCS_TEST_MESSAGE_SUCCESS', () => {
552
+ it('should clear the sending flag and any error', () => {
553
+ const stateWhileSending = initialState
554
+ .set('isSendingCcsTestMessage', true)
555
+ .set('sendCcsTestMessageError', 'previous error');
556
+ const action = { type: SEND_CCS_TEST_MESSAGE_SUCCESS };
557
+ const result = previewAndTestReducer(stateWhileSending, action);
558
+
559
+ expect(result.get('isSendingCcsTestMessage')).toBe(false);
560
+ expect(result.get('sendCcsTestMessageError')).toBeNull();
561
+ });
562
+ });
563
+
564
+ describe('SEND_CCS_TEST_MESSAGE_FAILURE', () => {
565
+ it('should clear the sending flag and set the error', () => {
566
+ const action = {
567
+ type: SEND_CCS_TEST_MESSAGE_FAILURE,
568
+ payload: { error: 'NO_LIVE_VERSION' },
569
+ };
570
+ const result = previewAndTestReducer(initialState, action);
571
+
572
+ expect(result.get('isSendingCcsTestMessage')).toBe(false);
573
+ expect(result.get('sendCcsTestMessageError')).toBe('NO_LIVE_VERSION');
574
+ });
575
+ });
576
+
536
577
  describe('CLEAR_SEARCH_RESULTS', () => {
537
578
  it('should clear search results and reset flags', () => {
538
579
  const stateWithResults = initialState
@@ -12,6 +12,7 @@ import {
12
12
  extractTagsSaga,
13
13
  updatePreviewSaga,
14
14
  sendTestMessageSaga,
15
+ sendCcsTestMessageSaga,
15
16
  getBulkCustomerDetails,
16
17
  fetchTestCustomersSaga,
17
18
  fetchTestGroupsSaga,
@@ -45,6 +46,8 @@ import {
45
46
  UPDATE_PREVIEW_FAILURE,
46
47
  SEND_TEST_MESSAGE_SUCCESS,
47
48
  SEND_TEST_MESSAGE_FAILURE,
49
+ SEND_CCS_TEST_MESSAGE_SUCCESS,
50
+ SEND_CCS_TEST_MESSAGE_FAILURE,
48
51
  GET_TEST_CUSTOMERS_SUCCESS,
49
52
  GET_TEST_CUSTOMERS_FAILURE,
50
53
  GET_TEST_GROUPS_SUCCESS,
@@ -747,6 +750,87 @@ describe('CommonTestAndPreview Sagas', () => {
747
750
  });
748
751
  });
749
752
 
753
+ describe('sendCcsTestMessageSaga', () => {
754
+ it('should send the CCS test message successfully', () => {
755
+ const callback = jest.fn();
756
+ const action = {
757
+ payload: { commDefinitionId: 'cd_123', recipient: { identifiers: [] } },
758
+ callback,
759
+ };
760
+ const response = { response: { success: true, data: {} } };
761
+
762
+ const generator = sendCcsTestMessageSaga(action);
763
+
764
+ expect(generator.next().value).toEqual(
765
+ call(Api.sendTransactionalComm, action.payload)
766
+ );
767
+
768
+ expect(generator.next(response).value).toEqual(
769
+ put({ type: SEND_CCS_TEST_MESSAGE_SUCCESS })
770
+ );
771
+
772
+ generator.next();
773
+ expect(callback).toHaveBeenCalledWith(true);
774
+ expect(generator.next().done).toBe(true);
775
+ });
776
+
777
+ it('should handle a CCS envelope failure (success: false)', () => {
778
+ const callback = jest.fn();
779
+ const action = {
780
+ payload: { commDefinitionId: 'cd_123', recipient: { identifiers: [] } },
781
+ callback,
782
+ };
783
+ const response = {
784
+ response: { success: false, errors: [{ message: 'NO_LIVE_VERSION' }] },
785
+ };
786
+
787
+ const generator = sendCcsTestMessageSaga(action);
788
+
789
+ generator.next();
790
+ expect(generator.next(response).value).toEqual(
791
+ put({ type: SEND_CCS_TEST_MESSAGE_FAILURE, payload: { error: 'NO_LIVE_VERSION' } })
792
+ );
793
+
794
+ generator.next();
795
+ expect(callback).toHaveBeenCalledWith(false);
796
+ });
797
+
798
+ it('falls back to a default error message when the envelope carries none', () => {
799
+ const callback = jest.fn();
800
+ const action = {
801
+ payload: { commDefinitionId: 'cd_123', recipient: { identifiers: [] } },
802
+ callback,
803
+ };
804
+ const response = { response: { success: false, errors: [] } };
805
+
806
+ const generator = sendCcsTestMessageSaga(action);
807
+
808
+ generator.next();
809
+ expect(generator.next(response).value).toEqual(
810
+ put({ type: SEND_CCS_TEST_MESSAGE_FAILURE, payload: { error: 'Failed to send test message' } })
811
+ );
812
+ });
813
+
814
+ it('should handle network error', () => {
815
+ const callback = jest.fn();
816
+ const action = {
817
+ payload: { commDefinitionId: 'cd_123', recipient: { identifiers: [] } },
818
+ callback,
819
+ };
820
+ const error = new Error('Network error');
821
+
822
+ const generator = sendCcsTestMessageSaga(action);
823
+
824
+ generator.next();
825
+ expect(generator.throw(error).value).toEqual(
826
+ put({ type: SEND_CCS_TEST_MESSAGE_FAILURE, payload: { error: 'Network error' } })
827
+ );
828
+
829
+ generator.next();
830
+ expect(callback).toHaveBeenCalledWith(false);
831
+ });
832
+ });
833
+
750
834
  describe('fetchTestCustomersSaga', () => {
751
835
  it('should fetch test customers successfully', () => {
752
836
  const response = {
@@ -28,6 +28,7 @@ import {
28
28
  makeSelectPrefilledValues,
29
29
  makeSelectTestMessageResponse,
30
30
  makeSelectIsSendingTestMessage,
31
+ makeSelectIsSendingCcsTestMessage,
31
32
  makeSelectUpdatePreviewError,
32
33
  makeSelectUpdatePreviewErrors,
33
34
  makeSelectFetchPrefilledValuesError,
@@ -78,6 +79,8 @@ describe('CommonTestAndPreview Selectors', () => {
78
79
  isSendingTestEmail: false,
79
80
  sendTestMessageError: null,
80
81
  testMessageResponse: { messageId: '123', status: 'sent' },
82
+ isSendingCcsTestMessage: false,
83
+ sendCcsTestMessageError: null,
81
84
  tags: {
82
85
  loading: false,
83
86
  error: null,
@@ -470,6 +473,15 @@ describe('CommonTestAndPreview Selectors', () => {
470
473
  });
471
474
  });
472
475
 
476
+ describe('makeSelectIsSendingCcsTestMessage', () => {
477
+ it('should select isSendingCcsTestMessage flag', () => {
478
+ const selector = makeSelectIsSendingCcsTestMessage();
479
+ const result = selector(mockState);
480
+
481
+ expect(result).toBe(false);
482
+ });
483
+ });
484
+
473
485
  describe('makeSelectUpdatePreviewError', () => {
474
486
  it('should select updatePreviewError from state', () => {
475
487
  const selector = makeSelectUpdatePreviewError();
@@ -70,8 +70,7 @@ const FormBuilderShell = (props) => {
70
70
 
71
71
  const channel = (channelProp || schema?.channel || SMS).toUpperCase();
72
72
  const adapter = getChannelAdapter(channel);
73
- // Channels ship their own registry via the adapter; SMS predates the hook and
74
- // stays on its original factory.
73
+ // Channels ship their own registry via the adapter; SMS predates the hook and stays on its original factory.
75
74
  const registry = useMemo(
76
75
  () => (adapter?.createRegistry ? adapter.createRegistry() : createSmsRegistry()),
77
76
  [channel],
@@ -98,23 +97,16 @@ const FormBuilderShell = (props) => {
98
97
  const didInitRef = useRef(false);
99
98
  const prevStartValidationRef = useRef(startValidation);
100
99
  const prevSchemaRef = useRef(null);
101
- // Deep snapshot of the last parent formData we processed. The MOBILEPUSH
102
- // containers mutate their formData IN PLACE (copy-content, tag insertion), so a
103
- // reference-keyed effect would miss genuine pushes — we deep-compare on every
104
- // render instead, exactly like Classic's componentWillReceiveProps.
100
+ // Deep snapshot of the last processed parent formData — containers mutate it in
101
+ // place (copy-content, tag insertion), so pushes are deep-compared, not ref-keyed.
105
102
  const lastSeenParentRef = useRef(null);
106
- // Platform validity flags from the last save-validation (MOBILEPUSH android/ios
107
- // modal contract): Classic keeps these in state only after a validate pass.
103
+ // android/ios validity from the last save-validation (single-platform modal contract).
108
104
  const platformRef = useRef(null);
109
- // Content snapshot of the tags prop for the tags-change re-validation
110
- // (Classic.js:569-578) — containers rebuild the array each render, so identity
111
- // is useless. Seeded by the init effect (init already validates with those tags).
105
+ // Content snapshot of the tags prop (containers rebuild the array each render).
112
106
  const lastTagsRef = useRef(null);
113
107
  const lastNextStateRef = useRef(null);
114
- // GOLDEN parity (pinned by mpush.characterization): Classic emits
115
- // this.state.tabCount in onChange, which starts at 1 and is mirrored from the
116
- // tabCount PROP only when hydrating from a parent formData push (edit sync) —
117
- // so the MPUSH create flow emits tabCount 1 even though the containers pass 2.
108
+ // GOLDEN parity: Classic emits tabCount starting at 1, mirroring the prop only
109
+ // on a parent formData sync — the create flow emits 1 though the containers pass 2.
118
110
  const emittedTabCountRef = useRef(1);
119
111
 
120
112
  // Stable handler identities for the memoized FieldSlot: these wrappers delegate to
@@ -216,6 +208,12 @@ const FormBuilderShell = (props) => {
216
208
  );
217
209
  useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
218
210
 
211
+ // Classic pushes formData up (props.onChange) on init AND on every schema change
212
+ // (initialiseForm -> resetTabKeys when isSchemaChanged, Classic.js:1906-1911,
213
+ // 740-748) — the MPUSH containers read that copy (copy-content handlers) before
214
+ // any user input. MPUSH-only; SMS keeps its shipped behavior.
215
+ const emitFormDataOnSchemaSync = Boolean(adapter.config?.features?.emitFormDataOnSchemaSync);
216
+
219
217
  // ---- initialize once the (possibly async) schema is ready, then validate+emit ----
220
218
  const schemaReady = Boolean(schema?.standalone || schema?.containers?.length);
221
219
  useEffect(() => {
@@ -232,6 +230,8 @@ const FormBuilderShell = (props) => {
232
230
  lastTagsRef.current = cloneDeep(propsRef.current.tags || null);
233
231
  const legacy = toLegacy(init);
234
232
  emitValidity(runValidate(legacy), legacy);
233
+ if (emitFormDataOnSchemaSync) emitChange(legacy); // seed the container's formData copy
234
+
235
235
  }
236
236
  }, [schemaReady]);
237
237
 
@@ -246,6 +246,7 @@ const FormBuilderShell = (props) => {
246
246
  // be stale mid container-setState-cascade): hydration-family effects only ARM this
247
247
  // flag; the [state]-keyed effect below emits once the new state has rendered.
248
248
  const pendingBackgroundValidateRef = useRef(false);
249
+ const pendingEmitChangeRef = useRef(false);
249
250
 
250
251
  // ---- reconcile on a genuine schema change (CTA splices, text<->image swap) ----
251
252
  // Runs inside the reducer (reconcileSchema) — see the action's note on stale snapshots.
@@ -258,6 +259,8 @@ const FormBuilderShell = (props) => {
258
259
  if (validationActive || validateOnExternalChange) {
259
260
  pendingBackgroundValidateRef.current = true;
260
261
  }
262
+ // Classic re-emits onChange after a schema-change re-init (Classic.js:1908-1909).
263
+ if (emitFormDataOnSchemaSync) pendingEmitChangeRef.current = true;
261
264
  }, [schema]);
262
265
 
263
266
  // ---- re-hydrate on a GENUINE external parent push (edit load / discard / copy) ----
@@ -282,12 +285,18 @@ const FormBuilderShell = (props) => {
282
285
  }
283
286
  });
284
287
 
285
- // ---- background validity emission from the SETTLED state (see flag above) ----
288
+ // ---- background validity / formData emission from the SETTLED state (see flags above) ----
286
289
  useEffect(() => {
287
- if (!didInitRef.current || !pendingBackgroundValidateRef.current) return;
288
- pendingBackgroundValidateRef.current = false;
290
+ if (!didInitRef.current || (!pendingBackgroundValidateRef.current && !pendingEmitChangeRef.current)) return;
289
291
  const legacy = toLegacy(state);
290
- emitValidity(runValidate(legacy), legacy);
292
+ if (pendingBackgroundValidateRef.current) {
293
+ pendingBackgroundValidateRef.current = false;
294
+ emitValidity(runValidate(legacy), legacy);
295
+ }
296
+ if (pendingEmitChangeRef.current) {
297
+ pendingEmitChangeRef.current = false;
298
+ emitChange(legacy);
299
+ }
291
300
  }, [state]);
292
301
 
293
302
  // ---- re-validate when the tag catalog changes (Classic.js:569-578) ----
@@ -566,14 +575,19 @@ const FormBuilderShell = (props) => {
566
575
  // Confirmation modal (Classic getModal 'confirm' variant, Classic.js:597-641).
567
576
  // Only channels that declare modal semantics render it (MOBILEPUSH); the footer's
568
577
  // primary button carries the modal id — that id IS the handleOk dispatch key.
578
+ // width 520 = the antd Modal default Classic renders (Classic.js:601);
579
+ // CapModal's own default (324) wraps the single-platform prompt to two lines.
569
580
  const confirmModal = adapter.modals && modal && (
570
581
  <CapModal
571
- visible={modalVisible}
582
+ open={modalVisible}
583
+ width={520}
572
584
  title={modal.title || ''}
573
585
  onCancel={handleModalCancel}
574
586
  footer={[
575
- <CapButton key="back" onClick={handleModalCancel}>Cancel</CapButton>,
576
- <CapButton key="submit" type="primary" id={modal.id} onClick={handleModalOk}>Yes</CapButton>,
587
+ <CapButton key="back" onClick={handleModalCancel}>{intl.formatMessage(formMessages.cancel)}</CapButton>,
588
+ <CapButton key="submit" type="primary" id={modal.id} onClick={handleModalOk}>
589
+ {intl.formatMessage(formMessages.yes)}
590
+ </CapButton>,
577
591
  ]}
578
592
  >
579
593
  {modal.body || ''}
@@ -65,18 +65,10 @@ export default {
65
65
  preview: true,
66
66
  imageUpload: true,
67
67
  cta: true,
68
- // Bridge checkbox/radio/select commit events to the container-injected handlers
69
- // with the performFormDataUpdate signature (true, formData, field). MPUSH-only.
70
- bridgeFieldEvents: true,
71
- // MPUSH keeps the inline textarea error even though liquid is supported
72
- // (Classic.js:2908-2915) — encoded in the MPUSH renderers.
73
- inlineErrorWithLiquid: true,
74
- // Classic re-validates typing only under startValidation (Classic.js:334-345):
75
- // no pre-save errorData, so no tag/footer message before a save attempt.
76
- liveValidation: false,
77
- // Validity is still maintained in the background (Classic validates + emits on
78
- // parent pushes / schema changes / tags changes, Classic.js:516-578, 1361) —
79
- // the containers gate their save flows on it (Create/index.js:101).
80
- validateOnExternalChange: true,
68
+ bridgeFieldEvents: true, // checkbox/radio/select commits use the performFormDataUpdate signature (true, formData, field)
69
+ inlineErrorWithLiquid: true, // MPUSH keeps the inline textarea error despite liquid (Classic.js:2908-2915)
70
+ liveValidation: false, // Classic re-validates typing only under startValidation (Classic.js:334-345)
71
+ validateOnExternalChange: true, // background validity on parent/schema/tags changes gates the save flows (Classic.js:516-578, 1361)
72
+ emitFormDataOnSchemaSync: true, // push formData up on init + schema re-inits; copy-content handlers read it (Classic.js:1906-1911)
81
73
  },
82
74
  };
@@ -5,13 +5,15 @@
5
5
  * closes the modal (Classic parity). Modal CONTENT stays container-owned.
6
6
  */
7
7
 
8
+ import { ANDROID, IOS } from '../../../../../constants/unified';
9
+
8
10
  const mpushModals = {
9
11
  handleOk: (id, { platform, submitSingleTab } = {}) => {
10
12
  if (!platform || typeof submitSingleTab !== 'function') return;
11
- if (id === 'android' && platform.androidValid) {
12
- submitSingleTab('android');
13
- } else if (id === 'ios' && platform.iosValid) {
14
- submitSingleTab('ios');
13
+ if (id === ANDROID && platform.androidValid) {
14
+ submitSingleTab(ANDROID);
15
+ } else if (id === IOS && platform.iosValid) {
16
+ submitSingleTab(IOS);
15
17
  }
16
18
  },
17
19
  };
@@ -8,12 +8,15 @@
8
8
  import { validateTagsCore } from '../../../../../utils/tagValidations';
9
9
  import { hasPersonalizationTags } from '../../../../../utils/commonUtils';
10
10
  import { isUrl } from '../../../../../v2Containers/Line/Container/Wrapper/utils';
11
- import { DEFAULT as DEFAULT_MODULE } from '../../../../../constants/unified';
11
+ import { DEFAULT as DEFAULT_MODULE, ANDROID, IOS } from '../../../../../constants/unified';
12
12
  import { TEMPLATE_NAME_FIELD } from '../../constants';
13
13
  import { ERROR_VALUE, TABS, CTA_RULES } from './config';
14
14
 
15
15
  const isTabKey = (key) => /^\d+$/.test(`${key}`);
16
16
 
17
+ // Tab metadata keys skipped when seeding a tab's error map (Classic.js:1627-1656).
18
+ const ERROR_SEED_SKIP_KEYS = ['tabKey', 'base', 'selectedLanguages'];
19
+
17
20
  /** Truthy-value projection of a tab, excluding the given metadata keys. */
18
21
  const pickTruthy = (tab, excluded) => {
19
22
  const out = {};
@@ -51,12 +54,11 @@ const runTagValidation = (content, { tags, currentModule, isFullMode }) => {
51
54
  : { valid: false, isBraceError: false };
52
55
  };
53
56
 
54
- // Classic seeds EVERY tab's error map from tab 0's keys, skipping
55
- // tabKey/base/selectedLanguages (initializeEditErrorData, Classic.js:1627-1656).
57
+ // Classic seeds EVERY tab's error map from tab 0's keys (initializeEditErrorData).
56
58
  const seedTabErrors = (tab0) => {
57
59
  const seeded = {};
58
60
  Object.keys(tab0 || {}).forEach((key) => {
59
- if (key !== 'tabKey' && key !== 'base' && key !== 'selectedLanguages') seeded[key] = false;
61
+ if (!ERROR_SEED_SKIP_KEYS.includes(key)) seeded[key] = false;
60
62
  });
61
63
  return seeded;
62
64
  };
@@ -204,10 +206,9 @@ export default function validateMobilePush(formData = {}, options = {}) {
204
206
  });
205
207
  }
206
208
 
207
- // eslint-disable-next-line eqeqeq
208
- if (index == 0) androidValid = isCurrentTabValid;
209
- // eslint-disable-next-line eqeqeq
210
- else if (index == 1) iosValid = isCurrentTabValid;
209
+ // index is a string key ('0'/'1'/metadata) — Number() keeps Classic's `==` semantics.
210
+ if (Number(index) === TABS.ANDROID) androidValid = isCurrentTabValid;
211
+ else if (Number(index) === TABS.IOS) iosValid = isCurrentTabValid;
211
212
  });
212
213
 
213
214
  // --- template-name: root, skipped in embedded mode; failure also suppresses
@@ -224,15 +225,13 @@ export default function validateMobilePush(formData = {}, options = {}) {
224
225
  // --- single-platform save prompt (Classic.js:1171-1204) ---
225
226
  if (isSave && (androidValid || iosValid)) {
226
227
  const androidData = pickTruthy(formData[TABS.ANDROID], ['tabKey', 'base']);
227
- // eslint-disable-next-line eqeqeq
228
- if (isEmptyObject(androidData) && currentTab == 2) {
229
- singlePlatformPrompt = 'ios';
228
+ if (isEmptyObject(androidData) && Number(currentTab) === 2) {
229
+ singlePlatformPrompt = IOS;
230
230
  } else {
231
231
  // Classic's inner rebuild excludes only tabKey — NOT base (line 1191). Preserved.
232
232
  const iosData = pickTruthy(formData[TABS.IOS], ['tabKey']);
233
- // eslint-disable-next-line eqeqeq
234
- if (isEmptyObject(iosData) && currentTab == 1) {
235
- singlePlatformPrompt = 'android';
233
+ if (isEmptyObject(iosData) && Number(currentTab) === 1) {
234
+ singlePlatformPrompt = ANDROID;
236
235
  }
237
236
  }
238
237
  }
@@ -34,6 +34,9 @@ import { ERROR_VALUE } from '../channels/mobilepush/config';
34
34
 
35
35
  const { TextArea } = CapInput;
36
36
 
37
+ // Image types the upload accepts (Classic.js:2746 wrong-file check).
38
+ const ALLOWED_IMAGE_EXTENSIONS = /(\.bmp|\.jpeg|\.png|\.gif|\.avif|\.jpg)$/i;
39
+
37
40
  const dataFieldPropTypes = {
38
41
  field: PropTypes.object.isRequired,
39
42
  value: PropTypes.any,
@@ -264,12 +267,9 @@ export const UploadField = ({
264
267
  const files = e?.target?.files;
265
268
  const file = files && files[0];
266
269
  if (!file) return;
267
- if (field.supportedExtensions) {
268
- const allowedExtensions = /(\.bmp|\.jpeg|\.png|\.gif|\.avif|\.jpg)$/i;
269
- if (!allowedExtensions.exec(file.name)) {
270
- // Classic fires the wrong-file event and CONTINUES (no early return) — preserved.
271
- onEvent(field.submitAction, { file, type: 'wrong file' });
272
- }
270
+ if (field.supportedExtensions && !ALLOWED_IMAGE_EXTENSIONS.exec(file.name)) {
271
+ // Classic fires the wrong-file event and CONTINUES (no early return) — preserved.
272
+ onEvent(field.submitAction, { file, type: 'wrong file' });
273
273
  }
274
274
  const objectUrl = (window.URL || window.webkitURL).createObjectURL(file);
275
275
  const img = new Image();
@@ -291,7 +291,7 @@ export const UploadField = ({
291
291
  {isImage
292
292
  ? <CapImage src={value} alt={imageProps.alt} style={imageProps.style} />
293
293
  : (
294
- <div style={{ width: '100%', textAlign: 'center' }}>
294
+ <div className="fb-upload-placeholder">
295
295
  <span className="image-placeholder">{imageProps.placeholder}</span>
296
296
  </div>
297
297
  )}
@@ -312,9 +312,12 @@ export const UploadField = ({
312
312
  />
313
313
  )}
314
314
  <form encType="multipart/form-data" id={field.id}>
315
+ {/* Native file input on purpose — openFileDialog clicks it via #fileName;
316
+ CapInput wraps antd's text Input (affix wrapper + suffix), which cannot
317
+ host type="file". Hidden: the visible control is the CapButton below. */}
315
318
  <input
316
319
  key={field.id}
317
- style={{ display: 'none' }}
320
+ className="fb-upload-file-input"
318
321
  id="fileName"
319
322
  type="file"
320
323
  onChange={onFileChange}
@@ -325,6 +328,8 @@ export const UploadField = ({
325
328
  type="link"
326
329
  prefix={<CapIcon size="s" type="add-photo" />}
327
330
  onClick={openFileDialog}
331
+ // color is state-dependent and must beat CapButton's own runtime styling —
332
+ // kept inline (Classic.js:3399 verbatim).
328
333
  style={{ float: 'right', color: `${field.disabled ? FONT_COLOR_04 : FONT_COLOR_05}` }}
329
334
  >
330
335
  {field.label}
@@ -428,6 +433,8 @@ export const createMpushRegistry = () => createFieldRegistry({
428
433
  [FIELD_TYPE.TEXTAREA]: MpushTextAreaField,
429
434
  [FIELD_TYPE.CHECKBOX]: MpushCheckboxField,
430
435
  [FIELD_TYPE.BUTTON]: ButtonField,
436
+ // TagListField mirrors Classic's SEMANTIC tag-list path (Classic.js:3730-3769):
437
+ // span = field.width || '', field.style applied — the CTA splices rely on it.
431
438
  [FIELD_TYPE.TAG_LIST]: TagListField,
432
439
  [FIELD_TYPE.RADIO_GROUP]: RadioGroupField,
433
440
  [FIELD_TYPE.SELECT]: SelectField,
@@ -22,6 +22,7 @@
22
22
  import React from 'react';
23
23
  import '@testing-library/jest-dom';
24
24
  import _ from 'lodash';
25
+ import { notification } from 'antd';
25
26
  import { Router } from 'react-router-dom';
26
27
  import { render, act, fireEvent } from '../../../../utils/test-utils';
27
28
  import history from '../../../../utils/history';
@@ -279,4 +280,34 @@ describe.each([
279
280
  expectSaveProceeds(r);
280
281
  });
281
282
  }
283
+
284
+ // Copy with EMPTY source fields must warn ("Cannot copy empty field values."):
285
+ // the container handler reads its own formData copy (Create/index.js:1456-1465),
286
+ // seeded by the builder's init onChange push (Classic.js:1906-1911 -> 740-748).
287
+ it('copy from Android with empty fields shows the cannot-copy warning', () => {
288
+ const warnSpy = jest.spyOn(notification, 'warning').mockImplementation(() => {});
289
+ try {
290
+ const props = buildProps();
291
+ const ui = (p) => (
292
+ <Router history={history}>
293
+ <Create {...p} />
294
+ </Router>
295
+ );
296
+ const utils = render(ui(props));
297
+ const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
298
+ act(() => {
299
+ utils.rerender(ui({ ...props, metaEntities: meta }));
300
+ });
301
+ act(() => { fireEvent.click(document.querySelectorAll('.ant-tabs-tab')[1]); });
302
+ const copyLink = document.getElementById('copy-android-content2')
303
+ || document.getElementById('copy-android-content');
304
+ if (!copyLink) throw new Error('no copy-android-content link found');
305
+ act(() => { fireEvent.click(copyLink); });
306
+ expect(warnSpy).toHaveBeenCalledTimes(1);
307
+ expect(warnSpy.mock.calls[0][0].description).toBe('Cannot copy empty field values.');
308
+ utils.unmount();
309
+ } finally {
310
+ warnSpy.mockRestore();
311
+ }
312
+ });
282
313
  });
@@ -145,7 +145,20 @@ describe('MPUSH shell event bridge', () => {
145
145
  const footer = document.querySelector('.ant-modal-footer');
146
146
  expect(footer).toBeTruthy();
147
147
  const [cancel] = footer.querySelectorAll('button');
148
+ expect(cancel.textContent).toBe('Cancel'); // Classic's footer labels (messages.cancel/yes)
148
149
  act(() => { fireEvent.click(cancel); });
149
150
  expect(props.handleCancelModal).toHaveBeenCalled();
150
151
  });
152
+
153
+ // Classic seeds the container's formData copy on init (initialiseForm ->
154
+ // resetTabKeys -> onChange, Classic.js:1906-1911, 740-748); the copy-content
155
+ // handlers read that copy before any user input.
156
+ it('emits the seeded legacy formData (onChange) on init', () => {
157
+ const props = buildProps(buildSchema(), { formData: {} });
158
+ mount(props);
159
+ expect(props.onChange).toHaveBeenCalled();
160
+ const [legacy] = props.onChange.mock.calls[0];
161
+ expect(legacy[0]).toEqual(expect.objectContaining({ 'message-title': '' }));
162
+ expect(legacy[1]).toEqual(expect.objectContaining({ 'message-title2': '' }));
163
+ });
151
164
  });
@@ -9,6 +9,7 @@ import {
9
9
  SelectField, UploadField, MpushDivField, IconField, MobilePushPreviewField,
10
10
  createMpushRegistry,
11
11
  } from '../renderers/mpushRenderers';
12
+ import { TagListField } from '../renderers/smsRenderers';
12
13
  import { ERROR_VALUE } from '../channels/mobilepush/config';
13
14
  import { isAiContentBotDisabled } from '../../../../utils/common';
14
15
 
@@ -300,6 +301,31 @@ describe('IconField', () => {
300
301
  });
301
302
  });
302
303
 
304
+ describe('tag-list rendering (shared TagListField)', () => {
305
+ it('CLASSIC PARITY PIN: column takes field.width and field.style (semantic path, Classic.js:3730-3769)', () => {
306
+ // the container-spliced CTA tagLists position "Add label" via style
307
+ // (marginRight 24%/10%) — Classic's semantic tag-list case honors both.
308
+ const field = {
309
+ id: 'title-tagList',
310
+ label: 'Add label',
311
+ offset: 3,
312
+ style: { marginRight: '10%', display: 'flex', justifyContent: 'end', width: '100%' },
313
+ };
314
+ const el = TagListField({ field, onEvent: jest.fn(), renderContext: {} });
315
+ expect(el.props.span).toBe(''); // no width -> '' exactly like Classic
316
+ expect(el.props.offset).toBe(3);
317
+ expect(el.props.style).toEqual(field.style);
318
+ expect(el.props.children.props.label).toBe('Add label');
319
+ });
320
+
321
+ it('bridges onTagSelect through onEvent', () => {
322
+ const onEvent = jest.fn();
323
+ const el = TagListField({ field: { id: 'title-tagList' }, onEvent, renderContext: {} });
324
+ el.props.children.props.onTagSelect({ definition: { value: 'x' } });
325
+ expect(onEvent).toHaveBeenCalledWith('onTagSelect', { definition: { value: 'x' } });
326
+ });
327
+ });
328
+
303
329
  describe('MobilePushPreviewField', () => {
304
330
  const field = {
305
331
  id: 'mobile-push-preview',
@@ -67,6 +67,14 @@
67
67
  margin: auto;
68
68
  }
69
69
  }
70
+ // Functional MPUSH upload field (values identical to Classic's inline styles).
71
+ .fb-upload-file-input {
72
+ display: none;
73
+ }
74
+ .fb-upload-placeholder {
75
+ width: 100%;
76
+ text-align: center;
77
+ }
70
78
  .disabled {
71
79
  pointer-events: none;
72
80
  border-bottom: 1px solid #C2C2C2 !important;
@@ -36,6 +36,7 @@ import {
36
36
  makeSelectMessageMetaConfigId,
37
37
  makeSelectPrefilledValues,
38
38
  makeSelectIsSendingTestMessage,
39
+ makeSelectIsSendingCcsTestMessage,
39
40
  makeSelectUpdatePreviewError,
40
41
  makeSelectUpdatePreviewErrors,
41
42
  makeSelectFetchPrefilledValuesError,
@@ -103,11 +104,17 @@ TestAndPreviewSlidebox.propTypes = {
103
104
  messageMetaConfigId: PropTypes.string,
104
105
  prefilledValues: PropTypes.object,
105
106
  isSendingTestMessage: PropTypes.bool.isRequired,
107
+ isSendingCcsTestMessage: PropTypes.bool.isRequired,
106
108
  intl: PropTypes.object.isRequired,
107
109
  senderDetailsByChannel: PropTypes.object,
108
110
  wecrmAccounts: PropTypes.array,
109
111
  isLoadingSenderDetails: PropTypes.bool,
110
112
  orgUnitId: PropTypes.number,
113
+ /** notify/ui only — see CommonTestAndPreview's handleSendTestMessage. */
114
+ ccsSendTransaction: PropTypes.shape({
115
+ commDefinitionId: PropTypes.string,
116
+ referenceId: PropTypes.string,
117
+ }),
111
118
  };
112
119
 
113
120
  TestAndPreviewSlidebox.defaultProps = {
@@ -125,6 +132,7 @@ TestAndPreviewSlidebox.defaultProps = {
125
132
  isLoadingSenderDetails: false,
126
133
  orgUnitId: -1,
127
134
  smsFallbackContent: null,
135
+ ccsSendTransaction: null,
128
136
  };
129
137
 
130
138
  const mapStateToProps = createStructuredSelector({
@@ -145,6 +153,7 @@ const mapStateToProps = createStructuredSelector({
145
153
  fetchPrefilledValuesError: makeSelectFetchPrefilledValuesError(),
146
154
  fetchPrefilledValuesErrors: makeSelectFetchPrefilledValuesErrors(),
147
155
  isSendingTestMessage: makeSelectIsSendingTestMessage(),
156
+ isSendingCcsTestMessage: makeSelectIsSendingCcsTestMessage(),
148
157
  senderDetailsByChannel: makeSelectSenderDetailsByChannel(),
149
158
  wecrmAccounts: makeSelectWeCrmAccounts(),
150
159
  isLoadingSenderDetails: makeSelectIsLoadingSenderDetails(),