@capillarytech/creatives-library 9.0.56-betaa.0 → 9.0.56

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 (116) hide show
  1. package/constants/unified.js +9 -4
  2. package/package.json +1 -1
  3. package/services/api.js +25 -4
  4. package/utils/common.js +0 -12
  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 +46 -320
  29. package/v2Components/FormBuilder/Functional/channels/registry.js +0 -2
  30. package/v2Components/FormBuilder/Functional/constants.js +8 -43
  31. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +28 -175
  32. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +6 -75
  33. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +20 -10
  34. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +1 -9
  35. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +3 -20
  36. package/v2Components/FormBuilder/Functional/layout/Section.js +1 -6
  37. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +2 -9
  38. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +0 -17
  39. package/v2Components/FormBuilder/index.js +12 -34
  40. package/v2Components/FormBuilder/tests/entryGate.test.js +7 -94
  41. package/v2Components/TestAndPreviewSlidebox/index.js +9 -0
  42. package/v2Containers/CommunicationFlow/CommunicationFlow.js +140 -75
  43. package/v2Containers/CommunicationFlow/CommunicationFlow.scss +18 -2
  44. package/v2Containers/CommunicationFlow/CommunicationFlowCard.js +67 -28
  45. package/v2Containers/CommunicationFlow/Tests/CommunicationFlow.test.js +503 -51
  46. package/v2Containers/CommunicationFlow/Tests/CommunicationFlowCard.test.js +98 -4
  47. package/v2Containers/CommunicationFlow/Tests/constants.test.js +85 -0
  48. package/v2Containers/CommunicationFlow/constants.js +65 -4
  49. package/v2Containers/CommunicationFlow/index.js +16 -23
  50. package/v2Containers/CommunicationFlow/messages.js +11 -3
  51. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.js +27 -11
  52. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/ChannelSelectionStep.scss +4 -0
  53. package/v2Containers/CommunicationFlow/steps/ChannelSelectionStep/Tests/ChannelSelectionStep.test.js +176 -4
  54. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/DeliverySettingsSection.js +22 -4
  55. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/SenderDetails.js +14 -4
  56. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/DeliverySettingsSection.test.js +11 -0
  57. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/SenderDetails.test.js +51 -0
  58. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/Tests/deliverySettingsConfig.test.js +11 -4
  59. package/v2Containers/CommunicationFlow/steps/DeliverySettingsStep/deliverySettingsConfig.js +19 -5
  60. package/v2Containers/CreativesContainer/SlideBoxContent.js +15 -0
  61. package/v2Containers/CreativesContainer/SlideBoxFooter.js +6 -2
  62. package/v2Containers/CreativesContainer/index.js +29 -3
  63. package/v2Containers/CreativesContainer/tests/SlideBoxFooter.test.js +29 -0
  64. package/v2Containers/CreativesContainer/tests/__snapshots__/index.test.js.snap +18 -5
  65. package/v2Containers/CreativesContainer/tests/index.test.js +12 -0
  66. package/v2Containers/MobilePush/Create/index.js +18 -1
  67. package/v2Containers/MobilePush/Create/test/contentValidity.test.js +96 -0
  68. package/v2Containers/MobilePush/Edit/index.js +18 -1
  69. package/v2Containers/MobilePush/Edit/test/contentValidity.test.js +116 -0
  70. package/v2Containers/MobilePush/commonMethods.js +49 -1
  71. package/v2Containers/MobilePushNew/index.js +29 -4
  72. package/v2Containers/MobilePushNew/tests/index.test.js +119 -0
  73. package/v2Containers/MobilePushNew/tests/utils.test.js +82 -0
  74. package/v2Containers/MobilePushNew/utils.js +34 -1
  75. package/v2Containers/MobilepushWrapper/index.js +3 -1
  76. package/v2Containers/Rcs/index.js +34 -0
  77. package/v2Containers/Rcs/index.scss +15 -0
  78. package/v2Containers/Rcs/tests/__snapshots__/index.test.js.snap +115 -19
  79. package/v2Containers/Rcs/tests/index.test.js +67 -0
  80. package/v2Containers/Sms/Create/index.js +3 -1
  81. package/v2Containers/Sms/Edit/index.js +25 -0
  82. package/v2Containers/Sms/Edit/tests/index.test.js +85 -0
  83. package/v2Containers/SmsTrai/Edit/tests/__snapshots__/index.test.js.snap +27 -3
  84. package/v2Containers/Viber/index.js +24 -1
  85. package/v2Containers/Viber/tests/index.test.js +103 -0
  86. package/v2Containers/WebPush/Create/index.js +19 -0
  87. package/v2Containers/WebPush/Create/preview/WebPushPreview.js +17 -12
  88. package/v2Containers/WebPush/Create/preview/tests/WebPushPreview.test.js +5 -0
  89. package/v2Containers/WebPush/Create/tests/contentValidity.test.js +294 -0
  90. package/v2Containers/Whatsapp/index.js +25 -4
  91. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +306 -34
  92. package/v2Containers/Whatsapp/tests/index.test.js +115 -0
  93. package/v2Containers/Zalo/index.js +28 -1
  94. package/v2Containers/Zalo/tests/index.test.js +99 -0
  95. package/v2Containers/mockdata.js +25 -0
  96. package/v2Components/FormBuilder/Functional/channels/mobilepush/buildSubmitPayload.js +0 -10
  97. package/v2Components/FormBuilder/Functional/channels/mobilepush/config.js +0 -82
  98. package/v2Components/FormBuilder/Functional/channels/mobilepush/getEditorErrorDescriptor.js +0 -74
  99. package/v2Components/FormBuilder/Functional/channels/mobilepush/index.js +0 -28
  100. package/v2Components/FormBuilder/Functional/channels/mobilepush/modals.js +0 -19
  101. package/v2Components/FormBuilder/Functional/channels/mobilepush/runSubmitPipeline.js +0 -45
  102. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/getEditorErrorDescriptor.test.js +0 -162
  103. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/modals.test.js +0 -37
  104. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/runSubmitPipeline.test.js +0 -70
  105. package/v2Components/FormBuilder/Functional/channels/mobilepush/tests/validate.test.js +0 -274
  106. package/v2Components/FormBuilder/Functional/channels/mobilepush/validate.js +0 -251
  107. package/v2Components/FormBuilder/Functional/layout/TabsContainer.js +0 -89
  108. package/v2Components/FormBuilder/Functional/renderers/mpushRenderers.js +0 -440
  109. package/v2Components/FormBuilder/Functional/tests/mpush.crossFlowParity.test.js +0 -430
  110. package/v2Components/FormBuilder/Functional/tests/mpush.ctaFlows.parity.test.js +0 -282
  111. package/v2Components/FormBuilder/Functional/tests/mpush.editContainer.parity.test.js +0 -141
  112. package/v2Components/FormBuilder/Functional/tests/mpush.engine.test.js +0 -306
  113. package/v2Components/FormBuilder/Functional/tests/mpush.shellEvents.test.js +0 -151
  114. package/v2Components/FormBuilder/Functional/tests/mpushRenderers.test.js +0 -342
  115. package/v2Components/FormBuilder/Functional/tests/tabsContainer.test.js +0 -110
  116. package/v2Components/FormBuilder/tests/mpush.characterization.test.js +0 -459
@@ -1,8 +1,8 @@
1
1
  import React from 'react';
2
2
 
3
3
  jest.mock('../../../services/api', () => ({
4
- createCentralCommsMetaId: jest.fn(),
5
- getCentralCommsMetaIds: jest.fn(),
4
+ createCommDefinition: jest.fn(),
5
+ editCommDefinition: jest.fn(),
6
6
  }));
7
7
 
8
8
  jest.mock('../../CreativesContainer', () => function MockCreativesContainer({
@@ -38,7 +38,7 @@ import { IntlProvider } from 'react-intl';
38
38
  import history from '../../../utils/history';
39
39
  import { initialReducer } from '../../../initialReducer';
40
40
  import CommunicationFlow from '../CommunicationFlow';
41
- import { createCentralCommsMetaId, getCentralCommsMetaIds } from '../../../services/api';
41
+ import { createCommDefinition, editCommDefinition } from '../../../services/api';
42
42
  import { getEnabledSteps } from '../utils/getEnabledSteps';
43
43
  import {
44
44
  CHANNELS,
@@ -192,13 +192,13 @@ describe('CommunicationFlow', () => {
192
192
  it('hides channel selection and dynamic controls until a communication strategy is set', async () => {
193
193
  renderWithFlow({ features: FEATURES.strategyContentDynamic });
194
194
 
195
- expect(screen.queryByText(/content template/i)).not.toBeInTheDocument();
195
+ expect(screen.queryByText(/add creative/i)).not.toBeInTheDocument();
196
196
  expect(screen.queryByText(/other controls/i)).not.toBeInTheDocument();
197
197
 
198
198
  await selectCommunicationStrategy('Single template');
199
199
 
200
200
  await waitFor(() => {
201
- expect(screen.getAllByText(/content template/i).length).toBeGreaterThan(0);
201
+ expect(screen.getAllByText(/add creative/i).length).toBeGreaterThan(0);
202
202
  });
203
203
  expect(screen.getByText(/other controls/i)).toBeInTheDocument();
204
204
  });
@@ -359,82 +359,295 @@ describe('isSaveDisabled', () => {
359
359
  });
360
360
 
361
361
  describe('handleSave — CCS flow', () => {
362
+ const ccsConfig = { ...baseConfig, context: { name: 'Order Placed Notification' }, features: {} };
363
+
362
364
  beforeEach(() => {
363
- createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'meta-123' } } });
364
- getCentralCommsMetaIds.mockResolvedValue({ response: { data: {} } });
365
+ createCommDefinition.mockResolvedValue({
366
+ response: { data: { id: 'cd_123', referenceId: 'ORDER_PLACED_123', status: 'DRAFT', version: { version: 1 } } },
367
+ });
365
368
  });
366
369
 
367
370
  afterEach(() => {
368
371
  jest.clearAllMocks();
369
372
  });
370
373
 
371
- it('calls createCentralCommsMetaId for each content item when useCCS is not false', async () => {
374
+ it('calls createCommDefinition with a SINGLE-strategy payload when useCCS is not false', async () => {
372
375
  const onSave = jest.fn();
373
376
  renderWithFlow({
374
377
  features: {},
378
+ config: ccsConfig,
379
+ // messageBody is the legacy editor's field name for the typed SMS text
380
+ // (see CreativesContainer/index.js's getCreativesData SMS case) — the
381
+ // SMS transform maps it to CCS's `message` field.
375
382
  initialData: {
376
- contentItems: [{ channel: 'sms', templateData: { smsBody: 'Hello' } }],
383
+ contentItems: [{ channel: 'sms', templateData: { messageBody: 'Hello' } }],
377
384
  },
378
385
  onSave,
379
386
  });
380
387
 
381
388
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
382
389
 
383
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalledTimes(1));
384
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
390
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
391
+ expect(createCommDefinition).toHaveBeenCalledWith(
385
392
  expect.objectContaining({
386
- centralCommsPayload: expect.objectContaining({ channel: 'SMS', module: 'CAMPAIGNS' }),
393
+ name: 'Order Placed Notification',
394
+ strategyType: 'SINGLE',
395
+ singleChannelStrategy: expect.objectContaining({
396
+ variant: expect.objectContaining({
397
+ channel: 'SMS',
398
+ smsMessageContent: { channel: 'SMS', message: 'Hello' },
399
+ }),
400
+ }),
387
401
  }),
388
402
  );
389
403
  expect(onSave).toHaveBeenCalledTimes(1);
390
404
  });
391
405
 
392
- it('calls getCentralCommsMetaIds when metaIds are returned from createCentralCommsMetaId', async () => {
406
+ it('transforms SMS content from the legacy editor shape (messageBody) to CCS\'s message field', async () => {
407
+ const onSave = jest.fn();
393
408
  renderWithFlow({
394
409
  features: {},
395
- initialData: { contentItems: [{ channel: 'EMAIL', templateData: {} }] },
410
+ config: ccsConfig,
411
+ initialData: {
412
+ contentItems: [{
413
+ channel: 'SMS',
414
+ templateData: { messageBody: 'Hello {{name}}', channel: 'SMS' },
415
+ }],
416
+ },
417
+ onSave,
396
418
  });
397
419
 
398
420
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
399
421
 
400
- await waitFor(() => expect(getCentralCommsMetaIds).toHaveBeenCalledWith('meta-123'));
422
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
423
+ expect(createCommDefinition).toHaveBeenCalledWith(
424
+ expect.objectContaining({
425
+ singleChannelStrategy: expect.objectContaining({
426
+ variant: expect.objectContaining({
427
+ channel: 'SMS',
428
+ smsMessageContent: {
429
+ channel: 'SMS',
430
+ message: 'Hello {{name}}',
431
+ },
432
+ }),
433
+ }),
434
+ }),
435
+ );
401
436
  });
402
437
 
403
- it('skips getCentralCommsMetaIds when response contains no id', async () => {
404
- createCentralCommsMetaId.mockResolvedValue({ response: { data: {} } });
438
+ it('does not carry the legacy messageBody key into smsMessageContent (regression guard against reverting to pass-through)', async () => {
439
+ const onSave = jest.fn();
405
440
  renderWithFlow({
406
441
  features: {},
442
+ config: ccsConfig,
443
+ initialData: {
444
+ contentItems: [{
445
+ channel: 'SMS',
446
+ templateData: { messageBody: 'Hello {{name}}', channel: 'SMS' },
447
+ }],
448
+ },
449
+ onSave,
450
+ });
451
+
452
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
453
+
454
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
455
+ const payload = createCommDefinition.mock.calls[0][0];
456
+ expect(payload.singleChannelStrategy.variant.smsMessageContent).not.toHaveProperty('messageBody');
457
+ });
458
+
459
+ it('transforms EMAIL content from the legacy editor shape (emailSubject) to CCS\'s messageSubject', async () => {
460
+ const onSave = jest.fn();
461
+ renderWithFlow({
462
+ features: {},
463
+ config: ccsConfig,
464
+ initialData: {
465
+ contentItems: [{
466
+ channel: 'EMAIL',
467
+ templateData: { emailSubject: 'Order Confirmed', emailBody: '<html>Hi</html>' },
468
+ }],
469
+ },
470
+ onSave,
471
+ });
472
+
473
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
474
+
475
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
476
+ expect(createCommDefinition).toHaveBeenCalledWith(
477
+ expect.objectContaining({
478
+ singleChannelStrategy: expect.objectContaining({
479
+ variant: expect.objectContaining({
480
+ channel: 'EMAIL',
481
+ emailMessageContent: {
482
+ channel: 'EMAIL',
483
+ messageSubject: 'Order Confirmed',
484
+ messageBody: '<html>Hi</html>',
485
+ },
486
+ }),
487
+ }),
488
+ }),
489
+ );
490
+ });
491
+
492
+ it('transforms WEBPUSH content from the legacy editor\'s messageContent.content wrapping to CCS\'s flat shape', async () => {
493
+ const onSave = jest.fn();
494
+ const cta = { type: 'SITE_URL', actionLink: 'https://example.com' };
495
+ const expandableDetails = { media: [{ url: 'https://example.com/banner.png', type: 'IMAGE' }], ctas: [] };
496
+ renderWithFlow({
497
+ features: {},
498
+ config: ccsConfig,
499
+ initialData: {
500
+ contentItems: [{
501
+ channel: 'WEBPUSH',
502
+ templateData: {
503
+ messageContent: {
504
+ content: {
505
+ channel: 'WEBPUSH',
506
+ accountId: 13792,
507
+ content: { title: 'Hi', message: 'Body', iconImageUrl: 'https://example.com/icon.png', cta, expandableDetails },
508
+ messageSubject: 'Subject',
509
+ offers: [],
510
+ },
511
+ },
512
+ },
513
+ }],
514
+ },
515
+ onSave,
516
+ });
517
+
518
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
519
+
520
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
521
+ expect(createCommDefinition).toHaveBeenCalledWith(
522
+ expect.objectContaining({
523
+ singleChannelStrategy: expect.objectContaining({
524
+ variant: expect.objectContaining({
525
+ channel: 'WEBPUSH',
526
+ webPushMessageContent: {
527
+ channel: 'WEBPUSH',
528
+ messageSubject: 'Subject',
529
+ accountId: 13792,
530
+ content: { title: 'Hi', message: 'Body', iconImageUrl: 'https://example.com/icon.png', cta, expandableDetails },
531
+ },
532
+ }),
533
+ }),
534
+ }),
535
+ );
536
+ });
537
+
538
+ it('normalizes the internal MOBILEPUSH channel to CCS\'s PUSH enum (keys stay mpush*), even when templateData/channelSetting carry their own stray channel field', async () => {
539
+ const onSave = jest.fn();
540
+ renderWithFlow({
541
+ features: {},
542
+ config: ccsConfig,
543
+ initialData: {
544
+ // `channel: 'MOBILEPUSH'` inline on templateData reproduces the real
545
+ // production bug: contentItem.templateData carries the UI's own
546
+ // internal channel identifier, which must not win over the
547
+ // normalized 'PUSH' enum once spread into mpushMessageContent.
548
+ contentItems: [{ channel: 'MOBILEPUSH', templateData: { channel: 'MOBILEPUSH', messageSubject: 'Hi' } }],
549
+ // Likewise, deliverySetting.channelSetting.PUSH carrying its own
550
+ // stray `channel` key must not win over the normalized value once
551
+ // spread into mpushDeliverySettings.channelSettings.
552
+ deliverySetting: { channelSetting: { PUSH: { channel: 'MOBILEPUSH', domainId: 2002 } } },
553
+ },
554
+ onSave,
555
+ });
556
+
557
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
558
+
559
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
560
+ expect(createCommDefinition).toHaveBeenCalledWith(
561
+ expect.objectContaining({
562
+ singleChannelStrategy: expect.objectContaining({
563
+ variant: expect.objectContaining({
564
+ channel: 'PUSH',
565
+ mpushMessageContent: { channel: 'PUSH', messageSubject: 'Hi' },
566
+ mpushDeliverySettings: {
567
+ channelSettings: { channel: 'PUSH', domainId: 2002 },
568
+ },
569
+ }),
570
+ }),
571
+ }),
572
+ );
573
+ });
574
+
575
+ it('merges ccsCommDefinition into the data passed to onSave when create succeeds', async () => {
576
+ const onSave = jest.fn();
577
+ renderWithFlow({
578
+ features: {},
579
+ config: ccsConfig,
407
580
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
581
+ onSave,
408
582
  });
409
583
 
410
584
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
411
585
 
412
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
413
- expect(getCentralCommsMetaIds).not.toHaveBeenCalled();
586
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
587
+ expect(onSave).toHaveBeenCalledWith(
588
+ expect.objectContaining({
589
+ ccsCommDefinition: { id: 'cd_123', referenceId: 'ORDER_PLACED_123', version: 1, status: 'DRAFT' },
590
+ }),
591
+ );
414
592
  });
415
593
 
416
- it('uses ouId and module from config.context when provided', async () => {
594
+ it('preserves a real version of 0 from CCS (not coerced to 1 by a falsy-zero fallback)', async () => {
595
+ const onSave = jest.fn();
596
+ createCommDefinition.mockResolvedValueOnce({
597
+ response: { data: { id: 'cd_123', referenceId: 'ORDER_PLACED_123', status: 'DRAFT', version: { version: 0 } } },
598
+ });
417
599
  renderWithFlow({
418
600
  features: {},
419
- config: { ...baseConfig, context: { ouId: 42, module: 'LOYALTY' }, features: {} },
601
+ config: ccsConfig,
420
602
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
603
+ onSave,
421
604
  });
422
605
 
423
606
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
424
607
 
425
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
426
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
608
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
609
+ expect(onSave).toHaveBeenCalledWith(
427
610
  expect.objectContaining({
428
- centralCommsPayload: expect.objectContaining({ ouId: 42, module: 'LOYALTY' }),
611
+ ccsCommDefinition: { id: 'cd_123', referenceId: 'ORDER_PLACED_123', version: 0, status: 'DRAFT' },
429
612
  }),
430
613
  );
431
614
  });
432
615
 
616
+ it('sends an empty referenceId/description when config.context does not supply them', async () => {
617
+ renderWithFlow({
618
+ features: {},
619
+ config: ccsConfig,
620
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
621
+ });
622
+
623
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
624
+
625
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
626
+ const payload = createCommDefinition.mock.calls[0][0];
627
+ expect(payload.referenceId).toBe('');
628
+ expect(payload.description).toBe('');
629
+ });
630
+
631
+ it('uses config.context.referenceId and description when provided', async () => {
632
+ renderWithFlow({
633
+ features: {},
634
+ config: { ...ccsConfig, context: { ...ccsConfig.context, referenceId: 'ORDER_PLACED', description: 'desc' } },
635
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
636
+ });
637
+
638
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
639
+
640
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
641
+ expect(createCommDefinition).toHaveBeenCalledWith(
642
+ expect.objectContaining({ referenceId: 'ORDER_PLACED', description: 'desc' }),
643
+ );
644
+ });
645
+
433
646
  it('skips CCS entirely when useCCS is false', async () => {
434
647
  const onSave = jest.fn();
435
648
  renderWithFlow({
436
649
  features: {},
437
- config: { ...baseConfig, useCCS: false, features: {} },
650
+ config: { ...ccsConfig, useCCS: false },
438
651
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
439
652
  onSave,
440
653
  });
@@ -442,14 +655,15 @@ describe('handleSave — CCS flow', () => {
442
655
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
443
656
 
444
657
  await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
445
- expect(createCentralCommsMetaId).not.toHaveBeenCalled();
658
+ expect(createCommDefinition).not.toHaveBeenCalled();
659
+ expect(onSave).toHaveBeenCalledWith(expect.not.objectContaining({ ccsCommDefinition: expect.anything() }));
446
660
  });
447
661
 
448
- it('still calls onSave when createCentralCommsMetaId rejects', async () => {
449
- createCentralCommsMetaId.mockRejectedValue(new Error('Network error'));
662
+ it('skips createCommDefinition when config.context.name is absent (mandatory)', async () => {
450
663
  const onSave = jest.fn();
451
664
  renderWithFlow({
452
665
  features: {},
666
+ config: { ...baseConfig, features: {} },
453
667
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
454
668
  onSave,
455
669
  });
@@ -457,12 +671,102 @@ describe('handleSave — CCS flow', () => {
457
671
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
458
672
 
459
673
  await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
674
+ expect(createCommDefinition).not.toHaveBeenCalled();
675
+ });
676
+
677
+ it('skips createCommDefinition for multi-channel strategies (CHANNEL_PRIORITY/AB_TEST)', async () => {
678
+ const onSave = jest.fn();
679
+ renderWithFlow({
680
+ features: {},
681
+ config: ccsConfig,
682
+ initialData: {
683
+ communicationStrategy: CHANNEL_PRIORITY,
684
+ channels: ['SMS', 'EMAIL'],
685
+ contentItems: [{ channel: 'SMS', templateData: {} }, { channel: 'EMAIL', templateData: {} }],
686
+ },
687
+ onSave,
688
+ });
689
+
690
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
691
+
692
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
693
+ expect(createCommDefinition).not.toHaveBeenCalled();
694
+ });
695
+
696
+ it('blocks save and shows a generic error when createCommDefinition rejects (regression: this used to silently call onSave without ccsCommDefinition and no user-facing feedback — Send for approval would then just be disabled with zero explanation why)', async () => {
697
+ createCommDefinition.mockRejectedValue(new Error('Network error'));
698
+ const onSave = jest.fn();
699
+ renderWithFlow({
700
+ features: {},
701
+ config: ccsConfig,
702
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
703
+ onSave,
704
+ });
705
+
706
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
707
+
708
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
709
+ expect(onSave).not.toHaveBeenCalled();
460
710
  });
461
711
 
462
- it('skips createCentralCommsMetaId when contentItems is empty', async () => {
712
+ it('blocks save and shows a generic error when createCommDefinition resolves with a network-level failure (api.js resolves isError instead of rejecting on a 401/403/etc)', async () => {
713
+ createCommDefinition.mockResolvedValue({ isError: true, status: 401 });
463
714
  const onSave = jest.fn();
464
715
  renderWithFlow({
465
716
  features: {},
717
+ config: ccsConfig,
718
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
719
+ onSave,
720
+ });
721
+
722
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
723
+
724
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
725
+ expect(onSave).not.toHaveBeenCalled();
726
+ });
727
+
728
+ it('blocks save and shows a generic error when createCommDefinition resolves with no usable data (e.g. missing id)', async () => {
729
+ createCommDefinition.mockResolvedValue({ response: { data: {} } });
730
+ const onSave = jest.fn();
731
+ renderWithFlow({
732
+ features: {},
733
+ config: ccsConfig,
734
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
735
+ onSave,
736
+ });
737
+
738
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
739
+
740
+ await waitFor(() => expect(screen.getByText('Something went wrong while saving your content. Please try again.')).toBeInTheDocument());
741
+ expect(onSave).not.toHaveBeenCalled();
742
+ });
743
+
744
+ it('blocks save and shows an error when createCommDefinition resolves with a duplicate REFERENCE_ID_EXISTS conflict', async () => {
745
+ createCommDefinition.mockResolvedValue({
746
+ success: false,
747
+ status: { isError: true, code: 409, message: 'REFERENCE_ID_EXISTS' },
748
+ message: 'REFERENCE_ID_EXISTS',
749
+ });
750
+ const onSave = jest.fn();
751
+ renderWithFlow({
752
+ features: {},
753
+ config: ccsConfig,
754
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
755
+ onSave,
756
+ });
757
+
758
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
759
+
760
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
761
+ expect(await screen.findByText(/already in use in your organization/i)).toBeInTheDocument();
762
+ expect(onSave).not.toHaveBeenCalled();
763
+ });
764
+
765
+ it('skips createCommDefinition when contentItems is empty', async () => {
766
+ const onSave = jest.fn();
767
+ renderWithFlow({
768
+ features: {},
769
+ config: ccsConfig,
466
770
  initialData: { contentItems: [] },
467
771
  onSave,
468
772
  });
@@ -470,12 +774,13 @@ describe('handleSave — CCS flow', () => {
470
774
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
471
775
 
472
776
  await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
473
- expect(createCentralCommsMetaId).not.toHaveBeenCalled();
777
+ expect(createCommDefinition).not.toHaveBeenCalled();
474
778
  });
475
779
 
476
- it('includes additionalSettings derived from dynamicControls in the payload', async () => {
780
+ it('places additionalSettings derived from dynamicControls under settings.additionalSettings (not delivery settings)', async () => {
477
781
  renderWithFlow({
478
782
  features: {},
783
+ config: ccsConfig,
479
784
  initialData: {
480
785
  contentItems: [{ channel: 'SMS', templateData: {} }],
481
786
  dynamicControls: {
@@ -489,14 +794,118 @@ describe('handleSave — CCS flow', () => {
489
794
 
490
795
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
491
796
 
492
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
493
- const payload = createCentralCommsMetaId.mock.calls[0][0];
494
- expect(payload.centralCommsPayload.smsDeliverySettings.additionalSettings).toEqual({
797
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
798
+ const payload = createCommDefinition.mock.calls[0][0];
799
+ expect(payload.settings.additionalSettings).toEqual({
495
800
  useTinyUrl: true,
496
801
  encryptUrl: true,
497
802
  linkTrackingEnabled: true,
498
803
  userSubscriptionDisabled: true,
499
804
  });
805
+ expect(payload.singleChannelStrategy.variant.smsDeliverySettings.additionalSettings).toBeUndefined();
806
+ });
807
+
808
+ it('includes channelSettings from deliverySetting.channelSetting in the payload', async () => {
809
+ renderWithFlow({
810
+ features: {},
811
+ config: ccsConfig,
812
+ initialData: {
813
+ contentItems: [{ channel: 'SMS', templateData: {} }],
814
+ deliverySetting: { channelSetting: { SMS: { domainId: 1001, gsmSenderId: 'BRAND1' } } },
815
+ },
816
+ });
817
+
818
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
819
+
820
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalled());
821
+ const payload = createCommDefinition.mock.calls[0][0];
822
+ expect(payload.singleChannelStrategy.variant.smsDeliverySettings.channelSettings).toEqual({
823
+ channel: 'SMS',
824
+ domainId: 1001,
825
+ gsmSenderId: 'BRAND1',
826
+ });
827
+ });
828
+
829
+ it('does not call editCommDefinition when config.context.existingCommDefinitionId is absent', async () => {
830
+ const onSave = jest.fn();
831
+ renderWithFlow({
832
+ features: {},
833
+ config: ccsConfig,
834
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
835
+ onSave,
836
+ });
837
+
838
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
839
+
840
+ await waitFor(() => expect(createCommDefinition).toHaveBeenCalledTimes(1));
841
+ expect(editCommDefinition).not.toHaveBeenCalled();
842
+ });
843
+
844
+ it('calls editCommDefinition (not createCommDefinition) and merges the existing id into ccsCommDefinition when config.context.existingCommDefinitionId is set', async () => {
845
+ editCommDefinition.mockResolvedValue({
846
+ response: { data: { version: { version: 2 }, status: 'DRAFT' } },
847
+ });
848
+ const onSave = jest.fn();
849
+ renderWithFlow({
850
+ features: {},
851
+ config: {
852
+ ...ccsConfig,
853
+ context: { ...ccsConfig.context, referenceId: 'ORDER_PLACED', description: 'desc', existingCommDefinitionId: 'cd_existing_1' },
854
+ },
855
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
856
+ onSave,
857
+ });
858
+
859
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
860
+
861
+ await waitFor(() => expect(editCommDefinition).toHaveBeenCalledTimes(1));
862
+ expect(editCommDefinition).toHaveBeenCalledWith(
863
+ 'cd_existing_1',
864
+ expect.objectContaining({
865
+ referenceId: 'ORDER_PLACED',
866
+ description: 'desc',
867
+ strategyType: 'SINGLE',
868
+ settings: expect.anything(),
869
+ singleChannelStrategy: expect.anything(),
870
+ }),
871
+ );
872
+ expect(createCommDefinition).not.toHaveBeenCalled();
873
+
874
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
875
+ expect(onSave).toHaveBeenCalledWith(
876
+ expect.objectContaining({
877
+ ccsCommDefinition: {
878
+ id: 'cd_existing_1',
879
+ referenceId: 'ORDER_PLACED',
880
+ version: 2,
881
+ status: 'DRAFT',
882
+ },
883
+ }),
884
+ );
885
+ });
886
+
887
+ it('blocks save and shows an error when editCommDefinition resolves with a duplicate REFERENCE_ID_EXISTS conflict', async () => {
888
+ editCommDefinition.mockResolvedValue({
889
+ success: false,
890
+ status: { isError: true, code: 409, message: 'REFERENCE_ID_EXISTS' },
891
+ message: 'REFERENCE_ID_EXISTS',
892
+ });
893
+ const onSave = jest.fn();
894
+ renderWithFlow({
895
+ features: {},
896
+ config: {
897
+ ...ccsConfig,
898
+ context: { ...ccsConfig.context, referenceId: 'ORDER_PLACED', existingCommDefinitionId: 'cd_existing_1' },
899
+ },
900
+ initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
901
+ onSave,
902
+ });
903
+
904
+ await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
905
+
906
+ await waitFor(() => expect(editCommDefinition).toHaveBeenCalledTimes(1));
907
+ expect(await screen.findByText(/already in use in your organization/i)).toBeInTheDocument();
908
+ expect(onSave).not.toHaveBeenCalled();
500
909
  });
501
910
  });
502
911
 
@@ -552,39 +961,34 @@ describe('optional chaining safety', () => {
552
961
  jest.clearAllMocks();
553
962
  });
554
963
 
555
- it('defaults ouId to -1 when config.context is absent', async () => {
556
- createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'x' } } });
964
+ it('does not crash and skips CCS when config.context is entirely absent', async () => {
965
+ const onSave = jest.fn();
557
966
  renderWithFlow({
558
967
  features: {},
968
+ config: { ...baseConfig, features: {} },
559
969
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
970
+ onSave,
560
971
  });
561
972
 
562
973
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
563
974
 
564
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
565
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
566
- expect.objectContaining({
567
- centralCommsPayload: expect.objectContaining({ ouId: -1 }),
568
- }),
569
- );
975
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
976
+ expect(createCommDefinition).not.toHaveBeenCalled();
570
977
  });
571
978
 
572
- it('defaults module to consumer.toUpperCase() when config.context.module absent', async () => {
573
- createCentralCommsMetaId.mockResolvedValue({ response: { data: { id: 'x' } } });
979
+ it('does not crash when config.context is present but empty', async () => {
980
+ const onSave = jest.fn();
574
981
  renderWithFlow({
575
982
  features: {},
576
- config: { ...baseConfig, consumer: 'loyalty', features: {} },
983
+ config: { ...baseConfig, context: {}, features: {} },
577
984
  initialData: { contentItems: [{ channel: 'SMS', templateData: {} }] },
985
+ onSave,
578
986
  });
579
987
 
580
988
  await userEvent.click(screen.getByRole('button', { name: /^save$/i }));
581
989
 
582
- await waitFor(() => expect(createCentralCommsMetaId).toHaveBeenCalled());
583
- expect(createCentralCommsMetaId).toHaveBeenCalledWith(
584
- expect.objectContaining({
585
- centralCommsPayload: expect.objectContaining({ module: 'LOYALTY' }),
586
- }),
587
- );
990
+ await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
991
+ expect(createCommDefinition).not.toHaveBeenCalled();
588
992
  });
589
993
 
590
994
  it('initializes channel from config.channel when initialData.channel is absent', () => {
@@ -628,3 +1032,51 @@ describe('optional chaining safety', () => {
628
1032
  expect(screen.getByRole('button', { name: /^save$/i })).toBeDisabled();
629
1033
  });
630
1034
  });
1035
+
1036
+ describe('step dividers', () => {
1037
+ it('renders a divider after CommunicationStrategyStep and after ChannelSelectionStep when both render together', () => {
1038
+ const { container } = renderWithFlow({
1039
+ features: {
1040
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
1041
+ contentTemplateData: { required: true, channels: CHANNELS },
1042
+ },
1043
+ initialData: {
1044
+ communicationStrategy: SINGLE_TEMPLATE,
1045
+ contentItems: [{ channel: 'SMS', templateData: {} }],
1046
+ },
1047
+ });
1048
+ // Each rendered step (CommunicationStrategyStep, ChannelSelectionStep) emits its
1049
+ // own trailing divider.
1050
+ expect(container.querySelectorAll('.ant-divider')).toHaveLength(2);
1051
+ });
1052
+
1053
+ it('omits the divider after ChannelSelectionStep when no content has been added yet', () => {
1054
+ const { container } = renderWithFlow({
1055
+ features: {
1056
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
1057
+ contentTemplateData: { required: true, channels: CHANNELS },
1058
+ },
1059
+ initialData: {
1060
+ communicationStrategy: SINGLE_TEMPLATE,
1061
+ contentItems: [],
1062
+ },
1063
+ });
1064
+ // Neither divider renders — both CommunicationStrategyStep's and ChannelSelectionStep's
1065
+ // own trailing dividers are conditional on content existing, to avoid a dangling line
1066
+ // around an empty "Add creative" state.
1067
+ expect(container.querySelectorAll('.ant-divider')).toHaveLength(0);
1068
+ });
1069
+
1070
+ it('renders the divider after CommunicationStrategyStep unconditionally when no CHANNEL_SELECTION step follows', () => {
1071
+ const { container } = renderWithFlow({
1072
+ features: {
1073
+ communicationStrategyData: { required: true, options: STRATEGY_OPTIONS },
1074
+ // contentTemplateData.required omitted — no CHANNEL_SELECTION step in this flow.
1075
+ },
1076
+ initialData: {
1077
+ communicationStrategy: SINGLE_TEMPLATE,
1078
+ },
1079
+ });
1080
+ expect(container.querySelectorAll('.ant-divider')).toHaveLength(1);
1081
+ });
1082
+ });