@capillarytech/creatives-library 8.0.320 → 8.0.321

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 (45) hide show
  1. package/package.json +1 -1
  2. package/utils/tests/tagValidations.test.js +34 -0
  3. package/v2Components/CapTagList/index.js +15 -22
  4. package/v2Components/CapTagList/style.scss +48 -0
  5. package/v2Components/CapTagListWithInput/__tests__/CapTagListWithInput.test.js +63 -0
  6. package/v2Components/CapTagListWithInput/index.js +4 -0
  7. package/v2Components/CapWhatsappCTA/index.js +2 -0
  8. package/v2Components/FormBuilder/index.js +7 -0
  9. package/v2Components/HtmlEditor/HTMLEditor.js +6 -1
  10. package/v2Components/HtmlEditor/__tests__/HTMLEditor.apiErrors.test.js +1 -0
  11. package/v2Components/HtmlEditor/__tests__/HTMLEditor.test.js +927 -2
  12. package/v2Components/HtmlEditor/components/CodeEditorPane/index.js +3 -0
  13. package/v2Containers/BeeEditor/index.js +3 -0
  14. package/v2Containers/CreativesContainer/SlideBoxContent.js +28 -1
  15. package/v2Containers/CreativesContainer/index.js +3 -0
  16. package/v2Containers/Email/index.js +1 -0
  17. package/v2Containers/EmailWrapper/components/EmailHTMLEditor.js +7 -1
  18. package/v2Containers/EmailWrapper/components/EmailWrapperView.js +3 -0
  19. package/v2Containers/EmailWrapper/components/__tests__/EmailHTMLEditor.test.js +20 -2
  20. package/v2Containers/EmailWrapper/components/__tests__/EmailWrapperView.test.js +16 -1
  21. package/v2Containers/EmailWrapper/hooks/useEmailWrapper.js +3 -0
  22. package/v2Containers/EmailWrapper/index.js +4 -0
  23. package/v2Containers/EmailWrapper/tests/useEmailWrapper.edgeCases.test.js +1 -0
  24. package/v2Containers/EmailWrapper/tests/useEmailWrapper.test.js +9 -0
  25. package/v2Containers/InAppWrapper/hooks/__tests__/useInAppWrapper.test.js +19 -0
  26. package/v2Containers/InAppWrapper/hooks/useInAppWrapper.js +3 -0
  27. package/v2Containers/InAppWrapper/index.js +3 -0
  28. package/v2Containers/MobilePush/Create/index.js +2 -0
  29. package/v2Containers/MobilePush/Edit/index.js +2 -0
  30. package/v2Containers/MobilepushWrapper/index.js +3 -1
  31. package/v2Containers/Rcs/index.js +1 -0
  32. package/v2Containers/Sms/Create/index.js +2 -0
  33. package/v2Containers/Sms/Edit/index.js +2 -0
  34. package/v2Containers/SmsTrai/Edit/index.js +2 -0
  35. package/v2Containers/SmsWrapper/index.js +2 -0
  36. package/v2Containers/TagList/index.js +41 -2
  37. package/v2Containers/TagList/messages.js +4 -0
  38. package/v2Containers/TagList/tests/TagList.test.js +122 -20
  39. package/v2Containers/TagList/tests/mockdata.js +17 -0
  40. package/v2Containers/Viber/index.js +5 -0
  41. package/v2Containers/WebPush/Create/hooks/useTagManagement.js +0 -2
  42. package/v2Containers/WebPush/Create/index.js +9 -1
  43. package/v2Containers/Whatsapp/index.js +5 -0
  44. package/v2Containers/Whatsapp/tests/__snapshots__/index.test.js.snap +20 -0
  45. package/v2Containers/Zalo/index.js +2 -0
@@ -11,6 +11,8 @@ import {
11
11
  import '@testing-library/jest-dom';
12
12
  import { IntlProvider } from 'react-intl';
13
13
  import HTMLEditor from '../HTMLEditor';
14
+ import { VALIDATION_SEVERITY } from '../constants';
15
+ import { ISSUE_SOURCES } from '../utils/validationConstants';
14
16
 
15
17
  // Options to control CodeEditorPane mock behavior
16
18
  const mockCodeEditorOptions = {
@@ -19,6 +21,7 @@ const mockCodeEditorOptions = {
19
21
  setRef: true,
20
22
  navigateToLineThrows: false,
21
23
  includeNavigateToLine: true,
24
+ omitGetCursor: false,
22
25
  };
23
26
 
24
27
 
@@ -166,11 +169,14 @@ jest.mock('../components/CodeEditorPane', () => {
166
169
 
167
170
  const methods = {
168
171
  focus: jest.fn(),
169
- getCursor: jest.fn(() => 0),
170
172
  getValue: jest.fn(() => value),
171
173
  setValue: jest.fn((newValue) => setValue(newValue)),
172
174
  };
173
175
 
176
+ if (!mockCodeEditorOptions.omitGetCursor) {
177
+ methods.getCursor = jest.fn(() => 0);
178
+ }
179
+
174
180
  if (mockCodeEditorOptions.includeNavigateToLine) {
175
181
  methods.navigateToLine = jest.fn(() => {
176
182
  if (mockCodeEditorOptions.navigateToLineThrows) {
@@ -199,7 +205,20 @@ jest.mock('../components/CodeEditorPane', () => {
199
205
  }
200
206
 
201
207
  return (
202
- <div data-testid="code-editor-pane">
208
+ <div
209
+ data-testid="code-editor-pane"
210
+ data-tags={JSON.stringify(props.tags ?? null)}
211
+ data-injected-tags={JSON.stringify(props.injectedTags ?? null)}
212
+ data-location={JSON.stringify(props.location ?? null)}
213
+ data-event-context-tags={JSON.stringify(props.eventContextTags ?? null)}
214
+ data-wait-event-context-tags={JSON.stringify(props.waitEventContextTags ?? null)}
215
+ data-selected-offer-details={JSON.stringify(props.selectedOfferDetails ?? null)}
216
+ data-channel={JSON.stringify(props.channel ?? null)}
217
+ data-user-locale={props.userLocale ?? ''}
218
+ data-module-filter-enabled={String(!!props.moduleFilterEnabled)}
219
+ data-read-only={String(!!props.readOnly)}
220
+ data-is-fullscreen-mode={String(!!props.isFullscreenMode)}
221
+ >
203
222
  <textarea
204
223
  value={value}
205
224
  onChange={(e) => {
@@ -216,6 +235,18 @@ jest.mock('../components/CodeEditorPane', () => {
216
235
  >
217
236
  Trigger Context Change
218
237
  </button>
238
+ <button
239
+ onClick={() => props.onContextChange && props.onContextChange('ALL')}
240
+ data-testid="trigger-context-all"
241
+ >
242
+ Trigger Context ALL
243
+ </button>
244
+ <button
245
+ onClick={() => props.onContextChange && props.onContextChange(null)}
246
+ data-testid="trigger-context-null"
247
+ >
248
+ Trigger Context Null
249
+ </button>
219
250
  {validation && (
220
251
  <ValidationErrorDisplay
221
252
  validation={validation}
@@ -249,6 +280,9 @@ jest.mock('../components/ValidationErrorDisplay', () => function MockValidationE
249
280
  <button onClick={() => onErrorClick && onErrorClick({ line: null })}>
250
281
  Error without Line
251
282
  </button>
283
+ <button onClick={() => onErrorClick && onErrorClick(undefined)}>
284
+ Error undefined
285
+ </button>
252
286
  </div>
253
287
  );
254
288
  });
@@ -384,6 +418,195 @@ describe('HTMLEditor', () => {
384
418
  });
385
419
  });
386
420
 
421
+ describe('waitEventContextTags', () => {
422
+ const waitTags = {
423
+ blockA: {
424
+ eventName: 'Order Placed',
425
+ blockName: 'Wait',
426
+ tags: [{ tagName: 'wait.foo', label: 'Foo', profileId: 'p1', profileName: 'P1' }],
427
+ },
428
+ };
429
+
430
+ it('passes default empty object to CodeEditorPane when waitEventContextTags is omitted', () => {
431
+ render(
432
+ <TestWrapper>
433
+ <HTMLEditor {...defaultProps} />
434
+ </TestWrapper>
435
+ );
436
+
437
+ act(() => {
438
+ jest.runAllTimers();
439
+ });
440
+
441
+ const pane = screen.getByTestId('code-editor-pane');
442
+ expect(pane).toHaveAttribute('data-wait-event-context-tags', JSON.stringify({}));
443
+ expect(pane).toHaveAttribute('data-is-fullscreen-mode', 'false');
444
+ });
445
+
446
+ it('forwards custom waitEventContextTags to the main CodeEditorPane', () => {
447
+ render(
448
+ <TestWrapper>
449
+ <HTMLEditor {...defaultProps} waitEventContextTags={waitTags} />
450
+ </TestWrapper>
451
+ );
452
+
453
+ act(() => {
454
+ jest.runAllTimers();
455
+ });
456
+
457
+ const pane = screen.getByTestId('code-editor-pane');
458
+ expect(pane).toHaveAttribute('data-wait-event-context-tags', JSON.stringify(waitTags));
459
+ });
460
+
461
+ it('forwards the same waitEventContextTags to fullscreen CodeEditorPane when modal opens', () => {
462
+ render(
463
+ <TestWrapper>
464
+ <HTMLEditor {...defaultProps} waitEventContextTags={waitTags} />
465
+ </TestWrapper>
466
+ );
467
+
468
+ act(() => {
469
+ jest.runAllTimers();
470
+ });
471
+
472
+ fireEvent.click(screen.getByText('Toggle Fullscreen'));
473
+
474
+ act(() => {
475
+ jest.runAllTimers();
476
+ });
477
+
478
+ const panes = screen.getAllByTestId('code-editor-pane');
479
+ expect(panes.length).toBeGreaterThanOrEqual(2);
480
+ const expected = JSON.stringify(waitTags);
481
+ panes.forEach((pane) => {
482
+ expect(pane).toHaveAttribute('data-wait-event-context-tags', expected);
483
+ });
484
+ const mainPane = panes.find((el) => el.getAttribute('data-is-fullscreen-mode') === 'false');
485
+ const fsPane = panes.find((el) => el.getAttribute('data-is-fullscreen-mode') === 'true');
486
+ expect(mainPane).toBeTruthy();
487
+ expect(fsPane).toBeTruthy();
488
+ });
489
+
490
+ it('forwards waitEventContextTags for inapp variant to main and fullscreen CodeEditorPane', () => {
491
+ render(
492
+ <TestWrapper>
493
+ <HTMLEditor {...defaultProps} variant="inapp" waitEventContextTags={waitTags} />
494
+ </TestWrapper>
495
+ );
496
+
497
+ act(() => {
498
+ jest.runAllTimers();
499
+ });
500
+
501
+ expect(screen.getByTestId('code-editor-pane')).toHaveAttribute(
502
+ 'data-wait-event-context-tags',
503
+ JSON.stringify(waitTags),
504
+ );
505
+
506
+ fireEvent.click(screen.getByText('Toggle Fullscreen'));
507
+
508
+ act(() => {
509
+ jest.runAllTimers();
510
+ });
511
+
512
+ const panes = screen.getAllByTestId('code-editor-pane');
513
+ const expected = JSON.stringify(waitTags);
514
+ panes.forEach((pane) => {
515
+ expect(pane).toHaveAttribute('data-wait-event-context-tags', expected);
516
+ });
517
+ });
518
+
519
+ it('passes explicit null waitEventContextTags through to CodeEditorPane', () => {
520
+ render(
521
+ <TestWrapper>
522
+ <HTMLEditor {...defaultProps} waitEventContextTags={null} />
523
+ </TestWrapper>
524
+ );
525
+
526
+ act(() => {
527
+ jest.runAllTimers();
528
+ });
529
+
530
+ expect(screen.getByTestId('code-editor-pane')).toHaveAttribute(
531
+ 'data-wait-event-context-tags',
532
+ JSON.stringify(null),
533
+ );
534
+ });
535
+ });
536
+
537
+ /**
538
+ * Covers forwardRef destructuring / defaults for tag-related props (HTMLEditor.js ~93–101)
539
+ * merged with HTMLEditor.defaultProps and passed through to CodeEditorPane (~663–671).
540
+ */
541
+ describe('forwardRef tag-related props (HTMLEditor.js lines 93–101 → CodeEditorPane)', () => {
542
+ it('passes merged default tag-related props to CodeEditorPane when omitted on the instance', () => {
543
+ render(
544
+ <TestWrapper>
545
+ <HTMLEditor {...defaultProps} />
546
+ </TestWrapper>
547
+ );
548
+
549
+ act(() => {
550
+ jest.runAllTimers();
551
+ });
552
+
553
+ const pane = screen.getByTestId('code-editor-pane');
554
+ expect(pane).toHaveAttribute('data-tags', JSON.stringify([]));
555
+ expect(pane).toHaveAttribute('data-injected-tags', JSON.stringify({}));
556
+ expect(pane).toHaveAttribute('data-location', JSON.stringify(null));
557
+ expect(pane).toHaveAttribute('data-event-context-tags', JSON.stringify([]));
558
+ expect(pane).toHaveAttribute('data-wait-event-context-tags', JSON.stringify({}));
559
+ expect(pane).toHaveAttribute('data-selected-offer-details', JSON.stringify([]));
560
+ expect(pane).toHaveAttribute('data-channel', JSON.stringify(null));
561
+ expect(pane).toHaveAttribute('data-user-locale', 'en');
562
+ expect(pane).toHaveAttribute('data-module-filter-enabled', 'true');
563
+ expect(pane).toHaveAttribute('data-read-only', 'false');
564
+ });
565
+
566
+ it('forwards explicit tag-related props from the forwardRef parameter list to CodeEditorPane', () => {
567
+ const tags = [{ id: 't1' }];
568
+ const injectedTags = { custom: { name: 'Custom' } };
569
+ const location = { query: { module: 'outbound' } };
570
+ const eventContextTags = [{ tagName: 'entryTrigger.x' }];
571
+ const waitEventContextTags = { block1: { eventName: 'E', blockName: 'B', tags: [] } };
572
+ const selectedOfferDetails = [{ type: 'coupon' }];
573
+
574
+ render(
575
+ <TestWrapper>
576
+ <HTMLEditor
577
+ {...defaultProps}
578
+ tags={tags}
579
+ injectedTags={injectedTags}
580
+ location={location}
581
+ eventContextTags={eventContextTags}
582
+ waitEventContextTags={waitEventContextTags}
583
+ selectedOfferDetails={selectedOfferDetails}
584
+ channel="SMS"
585
+ userLocale="ja"
586
+ moduleFilterEnabled={false}
587
+ readOnly
588
+ />
589
+ </TestWrapper>
590
+ );
591
+
592
+ act(() => {
593
+ jest.runAllTimers();
594
+ });
595
+
596
+ const pane = screen.getByTestId('code-editor-pane');
597
+ expect(pane).toHaveAttribute('data-tags', JSON.stringify(tags));
598
+ expect(pane).toHaveAttribute('data-injected-tags', JSON.stringify(injectedTags));
599
+ expect(pane).toHaveAttribute('data-location', JSON.stringify(location));
600
+ expect(pane).toHaveAttribute('data-event-context-tags', JSON.stringify(eventContextTags));
601
+ expect(pane).toHaveAttribute('data-wait-event-context-tags', JSON.stringify(waitEventContextTags));
602
+ expect(pane).toHaveAttribute('data-selected-offer-details', JSON.stringify(selectedOfferDetails));
603
+ expect(pane).toHaveAttribute('data-channel', JSON.stringify('SMS'));
604
+ expect(pane).toHaveAttribute('data-user-locale', 'ja');
605
+ expect(pane).toHaveAttribute('data-module-filter-enabled', 'false');
606
+ expect(pane).toHaveAttribute('data-read-only', 'true');
607
+ });
608
+ });
609
+
387
610
  describe('Context Provider', () => {
388
611
  it('provides correct context value for email variant', () => {
389
612
  render(
@@ -2479,6 +2702,7 @@ describe('HTMLEditor', () => {
2479
2702
  mockCodeEditorOptions.setRef = true;
2480
2703
  mockCodeEditorOptions.navigateToLineThrows = false;
2481
2704
  mockCodeEditorOptions.includeNavigateToLine = true;
2705
+ mockCodeEditorOptions.omitGetCursor = false;
2482
2706
 
2483
2707
  // Clear specific mocks instead of all to avoid breaking other mocks
2484
2708
  CapNotification.warning.mockClear();
@@ -3467,6 +3691,7 @@ describe('HTMLEditor', () => {
3467
3691
  tags={null}
3468
3692
  injectedTags={null}
3469
3693
  eventContextTags={null}
3694
+ waitEventContextTags={null}
3470
3695
  selectedOfferDetails={null}
3471
3696
  onTagContextChange={null}
3472
3697
  onTagSelect={null}
@@ -3492,6 +3717,7 @@ describe('HTMLEditor', () => {
3492
3717
  <HTMLEditor
3493
3718
  tags={[]}
3494
3719
  eventContextTags={[]}
3720
+ waitEventContextTags={{}}
3495
3721
  selectedOfferDetails={[]}
3496
3722
  />
3497
3723
  </TestWrapper>
@@ -3541,4 +3767,703 @@ describe('HTMLEditor', () => {
3541
3767
  expect(screen.getByTestId('editor-toolbar')).toBeInTheDocument();
3542
3768
  });
3543
3769
  });
3770
+
3771
+ /**
3772
+ * Covers remaining HTMLEditor.js branches for 100% file coverage:
3773
+ * useImperativeHandle getters (302–304), validation effects (350, 416, 440–441), onErrorAcknowledged (543).
3774
+ */
3775
+ describe('HTMLEditor full file coverage', () => {
3776
+ beforeEach(() => {
3777
+ mockCodeEditorOptions.omitGetCursor = false;
3778
+ mockCodeEditorOptions.setRef = true;
3779
+ mockCodeEditorOptions.includeInsertText = true;
3780
+ mockCodeEditorOptions.insertTextThrows = false;
3781
+
3782
+ mockUseValidationImpl.mockReset();
3783
+ mockUseValidationImpl.mockImplementation(() => ({
3784
+ isValidating: false,
3785
+ getAllIssues: () => [],
3786
+ isClean: () => true,
3787
+ summary: { totalErrors: 0, totalWarnings: 0 },
3788
+ }));
3789
+
3790
+ require('../hooks/useEditorContent').useEditorContent = () => ({
3791
+ content: '<p>Test content</p>',
3792
+ updateContent: jest.fn(),
3793
+ saveContent: jest.fn(),
3794
+ markAsSaved: jest.fn(),
3795
+ isLoading: false,
3796
+ isDirty: false,
3797
+ hasContent: true,
3798
+ getContentSize: jest.fn(() => 20),
3799
+ });
3800
+
3801
+ require('../hooks/useInAppContent').useInAppContent = () => ({
3802
+ content: '<p>Android content</p>',
3803
+ deviceContent: {
3804
+ android: '<p>Android content</p>',
3805
+ ios: '<p>iOS content</p>',
3806
+ },
3807
+ activeDevice: 'android',
3808
+ keepContentSame: false,
3809
+ updateContent: jest.fn(),
3810
+ saveContent: jest.fn(),
3811
+ markAsSaved: jest.fn(),
3812
+ switchDevice: jest.fn(),
3813
+ toggleContentSync: jest.fn(),
3814
+ getDeviceContent: (device) => `<p>${device} content</p>`,
3815
+ setDeviceContent: jest.fn(),
3816
+ getContentSize: () => 20,
3817
+ isLoading: false,
3818
+ isDirty: false,
3819
+ hasContent: true,
3820
+ });
3821
+ });
3822
+
3823
+ it('ref exposes getValidation, getContent, and isContentEmpty (lines 302–304)', async () => {
3824
+ const ref = React.createRef();
3825
+ const WrappedHTMLEditor = HTMLEditor.WrappedComponent || HTMLEditor;
3826
+ const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
3827
+ const { intl } = intlProvider.getChildContext();
3828
+
3829
+ mockUseValidationImpl.mockReturnValue({
3830
+ isValidating: false,
3831
+ getAllIssues: () => [],
3832
+ isClean: () => true,
3833
+ summary: { totalErrors: 0, totalWarnings: 0 },
3834
+ });
3835
+
3836
+ render(
3837
+ <TestWrapper>
3838
+ <WrappedHTMLEditor
3839
+ {...defaultProps}
3840
+ intl={intl}
3841
+ ref={ref}
3842
+ initialContent="<p>hi</p>"
3843
+ />
3844
+ </TestWrapper>
3845
+ );
3846
+
3847
+ act(() => {
3848
+ jest.runAllTimers();
3849
+ });
3850
+
3851
+ await waitFor(() => {
3852
+ expect(ref.current).toBeTruthy();
3853
+ });
3854
+
3855
+ expect(ref.current.getValidation()).toBeDefined();
3856
+ expect(ref.current.getContent()).toBe('<p>Test content</p>');
3857
+ expect(ref.current.isContentEmpty()).toBe(false);
3858
+ });
3859
+
3860
+ it('skips main validation notify effect while isValidating is true (line 350)', () => {
3861
+ const onValidationChange = jest.fn();
3862
+ mockUseValidationImpl.mockReturnValue({
3863
+ isValidating: true,
3864
+ hasBlockingErrors: false,
3865
+ getAllIssues: () => [],
3866
+ isClean: () => true,
3867
+ summary: { totalErrors: 0, totalWarnings: 0 },
3868
+ });
3869
+
3870
+ render(
3871
+ <TestWrapper>
3872
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
3873
+ </TestWrapper>
3874
+ );
3875
+
3876
+ act(() => {
3877
+ jest.runAllTimers();
3878
+ });
3879
+
3880
+ expect(onValidationChange).toHaveBeenCalled();
3881
+ const withComplete = onValidationChange.mock.calls.filter((c) => c[0]?.validationComplete === true);
3882
+ expect(withComplete.length).toBe(0);
3883
+ });
3884
+
3885
+ it('skips api validation errors effect while validation is still running (line 416)', () => {
3886
+ const onValidationChange = jest.fn();
3887
+ mockUseValidationImpl.mockReturnValue({
3888
+ isValidating: true,
3889
+ hasBlockingErrors: false,
3890
+ getAllIssues: () => [],
3891
+ isClean: () => true,
3892
+ summary: { totalErrors: 0, totalWarnings: 0 },
3893
+ });
3894
+
3895
+ render(
3896
+ <TestWrapper>
3897
+ <HTMLEditor
3898
+ {...defaultProps}
3899
+ onValidationChange={onValidationChange}
3900
+ apiValidationErrors={{ liquidErrors: ['x'], standardErrors: [] }}
3901
+ />
3902
+ </TestWrapper>
3903
+ );
3904
+
3905
+ act(() => {
3906
+ jest.runAllTimers();
3907
+ });
3908
+
3909
+ onValidationChange.mockClear();
3910
+
3911
+ act(() => {
3912
+ jest.runAllTimers();
3913
+ });
3914
+
3915
+ const afterClear = onValidationChange.mock.calls.filter((c) => c[0]?.validationComplete === true);
3916
+ expect(afterClear.length).toBe(0);
3917
+ });
3918
+
3919
+ it('api validation errors effect notifies parent when issue counts change (lines 440–441)', () => {
3920
+ const onValidationChange = jest.fn();
3921
+ let issues = [];
3922
+
3923
+ // Keep summary primitives stable so the main validation effect (line 343) does not re-run
3924
+ // when only getAllIssues() starts returning API-style issues; the api-only effect (409)
3925
+ // still runs because `validation` reference changes each render.
3926
+ mockUseValidationImpl.mockImplementation(() => ({
3927
+ isValidating: false,
3928
+ hasBlockingErrors: false,
3929
+ getAllIssues: () => issues,
3930
+ isClean: () => issues.length === 0,
3931
+ summary: { totalErrors: 0, totalWarnings: 0 },
3932
+ lastValidated: { getTime: () => 0 },
3933
+ }));
3934
+
3935
+ const { rerender } = render(
3936
+ <TestWrapper>
3937
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
3938
+ </TestWrapper>
3939
+ );
3940
+
3941
+ act(() => {
3942
+ jest.runAllTimers();
3943
+ });
3944
+
3945
+ onValidationChange.mockClear();
3946
+
3947
+ issues = [{ rule: 'liquid-api-validation', message: 'API error', source: 'liquid' }];
3948
+
3949
+ rerender(
3950
+ <TestWrapper>
3951
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
3952
+ </TestWrapper>
3953
+ );
3954
+
3955
+ act(() => {
3956
+ jest.runAllTimers();
3957
+ });
3958
+
3959
+ expect(onValidationChange).toHaveBeenCalled();
3960
+ const lastCall = onValidationChange.mock.calls[onValidationChange.mock.calls.length - 1][0];
3961
+ expect(lastCall.validationComplete).toBe(true);
3962
+ expect(lastCall.issueCounts.errors).toBeGreaterThanOrEqual(1);
3963
+ });
3964
+
3965
+ it('calls onErrorAcknowledged when user clicks a validation error (lines 542–543)', () => {
3966
+ const onErrorAcknowledged = jest.fn();
3967
+ mockUseValidationImpl.mockReturnValue({
3968
+ isValidating: false,
3969
+ getAllIssues: () => [{ source: 'htmlhint', rule: 'rule1', message: 'E' }],
3970
+ isClean: () => false,
3971
+ summary: { totalErrors: 1, totalWarnings: 0 },
3972
+ });
3973
+
3974
+ render(
3975
+ <TestWrapper>
3976
+ <HTMLEditor {...defaultProps} onErrorAcknowledged={onErrorAcknowledged} />
3977
+ </TestWrapper>
3978
+ );
3979
+
3980
+ act(() => {
3981
+ jest.runAllTimers();
3982
+ });
3983
+
3984
+ fireEvent.click(screen.getByText('Error without Line'));
3985
+ expect(onErrorAcknowledged).toHaveBeenCalled();
3986
+ });
3987
+
3988
+ it('getIssueCounts covers isBlockingError branches (standard API, liquid error, sanitizer, warning)', async () => {
3989
+ const ref = React.createRef();
3990
+ const WrappedHTMLEditor = HTMLEditor.WrappedComponent || HTMLEditor;
3991
+ const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
3992
+ const { intl } = intlProvider.getChildContext();
3993
+
3994
+ mockUseValidationImpl.mockReturnValue({
3995
+ isValidating: false,
3996
+ hasBlockingErrors: true,
3997
+ getAllIssues: () => [
3998
+ { rule: 'standard-api-validation', message: 'a' },
3999
+ { source: ISSUE_SOURCES.LIQUID, severity: VALIDATION_SEVERITY.ERROR, message: 'b' },
4000
+ { rule: 'sanitizer.invalidInput', message: 'c' },
4001
+ { rule: 'html-warn', message: 'd' },
4002
+ ],
4003
+ isClean: () => false,
4004
+ summary: { totalErrors: 4, totalWarnings: 0 },
4005
+ });
4006
+
4007
+ render(
4008
+ <TestWrapper>
4009
+ <WrappedHTMLEditor {...defaultProps} intl={intl} ref={ref} />
4010
+ </TestWrapper>
4011
+ );
4012
+
4013
+ act(() => {
4014
+ jest.runAllTimers();
4015
+ });
4016
+
4017
+ await waitFor(() => {
4018
+ expect(ref.current).toBeTruthy();
4019
+ });
4020
+
4021
+ expect(ref.current.getIssueCounts()).toEqual({
4022
+ errors: 3,
4023
+ warnings: 1,
4024
+ total: 4,
4025
+ });
4026
+ });
4027
+
4028
+ it('maps ALL context to DEFAULT in handleContextChange', () => {
4029
+ const globalActions = { fetchSchemaForEntity: jest.fn() };
4030
+ const location = { query: { type: 'full' } };
4031
+
4032
+ render(
4033
+ <TestWrapper>
4034
+ <HTMLEditor
4035
+ {...defaultProps}
4036
+ globalActions={globalActions}
4037
+ location={location}
4038
+ variant="email"
4039
+ />
4040
+ </TestWrapper>
4041
+ );
4042
+
4043
+ act(() => {
4044
+ jest.runAllTimers();
4045
+ });
4046
+
4047
+ fireEvent.click(screen.getByTestId('trigger-context-all'));
4048
+
4049
+ expect(globalActions.fetchSchemaForEntity).toHaveBeenCalledWith({
4050
+ layout: 'EMAIL',
4051
+ type: 'TAG',
4052
+ context: 'default',
4053
+ embedded: 'full',
4054
+ });
4055
+ });
4056
+
4057
+ it('email variant runs updateContent when initialContent differs from stored content', () => {
4058
+ const updateContent = jest.fn();
4059
+ require('../hooks/useEditorContent').useEditorContent = () => ({
4060
+ content: '<p>Stale</p>',
4061
+ updateContent,
4062
+ saveContent: jest.fn(),
4063
+ markAsSaved: jest.fn(),
4064
+ isLoading: false,
4065
+ isDirty: false,
4066
+ hasContent: true,
4067
+ getContentSize: jest.fn(() => 20),
4068
+ });
4069
+
4070
+ render(
4071
+ <TestWrapper>
4072
+ <HTMLEditor {...defaultProps} initialContent="<p>Initial content</p>" />
4073
+ </TestWrapper>
4074
+ );
4075
+
4076
+ act(() => {
4077
+ jest.runAllTimers();
4078
+ });
4079
+
4080
+ expect(updateContent).toHaveBeenCalledWith('<p>Initial content</p>', true);
4081
+ });
4082
+
4083
+ it('inapp variant runs updateContent when device content differs from initialContent string', () => {
4084
+ const updateContent = jest.fn();
4085
+ require('../hooks/useInAppContent').useInAppContent = () => ({
4086
+ activeDevice: 'android',
4087
+ keepContentSame: false,
4088
+ updateContent,
4089
+ saveContent: jest.fn(),
4090
+ markAsSaved: jest.fn(),
4091
+ switchDevice: jest.fn(),
4092
+ toggleContentSync: jest.fn(),
4093
+ getDeviceContent: () => '<p>android content</p>',
4094
+ setDeviceContent: jest.fn(),
4095
+ getContentSize: () => 20,
4096
+ isLoading: false,
4097
+ isDirty: false,
4098
+ hasContent: true,
4099
+ });
4100
+
4101
+ render(
4102
+ <TestWrapper>
4103
+ <HTMLEditor
4104
+ {...defaultProps}
4105
+ variant="inapp"
4106
+ initialContent="<p>Initial content</p>"
4107
+ />
4108
+ </TestWrapper>
4109
+ );
4110
+
4111
+ act(() => {
4112
+ jest.runAllTimers();
4113
+ });
4114
+
4115
+ expect(updateContent).toHaveBeenCalledWith('<p>Initial content</p>', true);
4116
+ });
4117
+
4118
+ it('handleLabelInsert uses numeric cursor when getCursor is not a function', () => {
4119
+ const CapNotification = require('@capillarytech/cap-ui-library/CapNotification');
4120
+ mockCodeEditorOptions.omitGetCursor = true;
4121
+
4122
+ render(
4123
+ <TestWrapper>
4124
+ <HTMLEditor {...defaultProps} />
4125
+ </TestWrapper>
4126
+ );
4127
+
4128
+ act(() => {
4129
+ jest.runAllTimers();
4130
+ });
4131
+
4132
+ fireEvent.click(screen.getByText('Insert Label (Null Position)'));
4133
+
4134
+ expect(CapNotification.success).toHaveBeenCalled();
4135
+ });
4136
+
4137
+ it('handleValidationErrorClick with undefined error focuses editor when ref is set', () => {
4138
+ mockUseValidationImpl.mockReturnValue({
4139
+ isValidating: false,
4140
+ getAllIssues: () => [{ source: 'htmlhint', rule: 'rule1', message: 'E' }],
4141
+ isClean: () => false,
4142
+ summary: { totalErrors: 1, totalWarnings: 0 },
4143
+ });
4144
+
4145
+ render(
4146
+ <TestWrapper>
4147
+ <HTMLEditor {...defaultProps} />
4148
+ </TestWrapper>
4149
+ );
4150
+
4151
+ act(() => {
4152
+ jest.runAllTimers();
4153
+ });
4154
+
4155
+ fireEvent.click(screen.getByText('Error undefined'));
4156
+ });
4157
+
4158
+ it('handleValidationErrorClick skips editor when ref is not available', () => {
4159
+ mockCodeEditorOptions.setRef = false;
4160
+ mockUseValidationImpl.mockReturnValue({
4161
+ isValidating: false,
4162
+ getAllIssues: () => [{ source: 'htmlhint', rule: 'rule1', message: 'E' }],
4163
+ isClean: () => false,
4164
+ summary: { totalErrors: 1, totalWarnings: 0 },
4165
+ });
4166
+
4167
+ render(
4168
+ <TestWrapper>
4169
+ <HTMLEditor {...defaultProps} onErrorAcknowledged={jest.fn()} />
4170
+ </TestWrapper>
4171
+ );
4172
+
4173
+ act(() => {
4174
+ jest.runAllTimers();
4175
+ });
4176
+
4177
+ fireEvent.click(screen.getByText('Error at Line 5'));
4178
+ });
4179
+
4180
+ it('getIssueCounts treats nullish issues as non-blocking (isBlockingError issue || {})', async () => {
4181
+ const ref = React.createRef();
4182
+ const WrappedHTMLEditor = HTMLEditor.WrappedComponent || HTMLEditor;
4183
+ const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
4184
+ const { intl } = intlProvider.getChildContext();
4185
+
4186
+ mockUseValidationImpl.mockReturnValue({
4187
+ isValidating: false,
4188
+ hasBlockingErrors: false,
4189
+ getAllIssues: () => [undefined, null, {}],
4190
+ isClean: () => false,
4191
+ summary: { totalErrors: 0, totalWarnings: 3 },
4192
+ });
4193
+
4194
+ render(
4195
+ <TestWrapper>
4196
+ <WrappedHTMLEditor {...defaultProps} intl={intl} ref={ref} />
4197
+ </TestWrapper>
4198
+ );
4199
+
4200
+ act(() => {
4201
+ jest.runAllTimers();
4202
+ });
4203
+
4204
+ await waitFor(() => {
4205
+ expect(ref.current).toBeTruthy();
4206
+ });
4207
+
4208
+ expect(ref.current.getIssueCounts()).toEqual({
4209
+ errors: 0,
4210
+ warnings: 3,
4211
+ total: 3,
4212
+ });
4213
+ });
4214
+
4215
+ it('handleContextChange uses embedded full when location.query is missing', () => {
4216
+ const globalActions = { fetchSchemaForEntity: jest.fn() };
4217
+ const location = { pathname: '/creatives' };
4218
+
4219
+ render(
4220
+ <TestWrapper>
4221
+ <HTMLEditor
4222
+ {...defaultProps}
4223
+ globalActions={globalActions}
4224
+ location={location}
4225
+ variant="email"
4226
+ />
4227
+ </TestWrapper>
4228
+ );
4229
+
4230
+ act(() => {
4231
+ jest.runAllTimers();
4232
+ });
4233
+
4234
+ fireEvent.click(screen.getByTestId('trigger-context-change'));
4235
+
4236
+ expect(globalActions.fetchSchemaForEntity).toHaveBeenCalledWith({
4237
+ layout: 'EMAIL',
4238
+ type: 'TAG',
4239
+ context: 'test-context',
4240
+ embedded: 'full',
4241
+ });
4242
+ });
4243
+
4244
+ it('handleSave succeeds when content.content is undefined', () => {
4245
+ const CapNotification = require('@capillarytech/cap-ui-library/CapNotification');
4246
+ const onSave = jest.fn();
4247
+
4248
+ require('../hooks/useEditorContent').useEditorContent = () => ({
4249
+ content: undefined,
4250
+ updateContent: jest.fn(),
4251
+ saveContent: jest.fn(),
4252
+ markAsSaved: jest.fn(),
4253
+ isLoading: false,
4254
+ isDirty: false,
4255
+ hasContent: false,
4256
+ getContentSize: jest.fn(() => 0),
4257
+ });
4258
+
4259
+ render(
4260
+ <TestWrapper>
4261
+ <HTMLEditor {...defaultProps} onSave={onSave} />
4262
+ </TestWrapper>
4263
+ );
4264
+
4265
+ act(() => {
4266
+ jest.runAllTimers();
4267
+ });
4268
+
4269
+ fireEvent.click(screen.getByText('Save'));
4270
+
4271
+ expect(onSave).toHaveBeenCalledWith({ html: undefined, css: undefined, javascript: undefined });
4272
+ expect(CapNotification.success).toHaveBeenCalled();
4273
+ });
4274
+
4275
+ it('countIssuesBySeverity handles null allIssues from getAllIssues', async () => {
4276
+ const ref = React.createRef();
4277
+ const WrappedHTMLEditor = HTMLEditor.WrappedComponent || HTMLEditor;
4278
+ const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
4279
+ const { intl } = intlProvider.getChildContext();
4280
+
4281
+ mockUseValidationImpl.mockReturnValue({
4282
+ isValidating: false,
4283
+ getAllIssues: () => null,
4284
+ isClean: () => true,
4285
+ summary: { totalErrors: 0, totalWarnings: 0 },
4286
+ });
4287
+
4288
+ render(
4289
+ <TestWrapper>
4290
+ <WrappedHTMLEditor {...defaultProps} intl={intl} ref={ref} />
4291
+ </TestWrapper>
4292
+ );
4293
+
4294
+ act(() => {
4295
+ jest.runAllTimers();
4296
+ });
4297
+
4298
+ await waitFor(() => {
4299
+ expect(ref.current).toBeTruthy();
4300
+ });
4301
+
4302
+ expect(ref.current.getIssueCounts()).toEqual({
4303
+ errors: 0,
4304
+ warnings: 0,
4305
+ total: 0,
4306
+ });
4307
+ });
4308
+
4309
+ it('handleContextChange lowercases null contextData to empty string', () => {
4310
+ const globalActions = { fetchSchemaForEntity: jest.fn() };
4311
+ const location = { query: { type: 'full' } };
4312
+
4313
+ render(
4314
+ <TestWrapper>
4315
+ <HTMLEditor
4316
+ {...defaultProps}
4317
+ globalActions={globalActions}
4318
+ location={location}
4319
+ variant="email"
4320
+ />
4321
+ </TestWrapper>
4322
+ );
4323
+
4324
+ act(() => {
4325
+ jest.runAllTimers();
4326
+ });
4327
+
4328
+ fireEvent.click(screen.getByTestId('trigger-context-null'));
4329
+
4330
+ expect(globalActions.fetchSchemaForEntity).toHaveBeenCalledWith({
4331
+ layout: 'EMAIL',
4332
+ type: 'TAG',
4333
+ context: '',
4334
+ embedded: 'full',
4335
+ });
4336
+ });
4337
+
4338
+ it('inapp initialContent object falls back to ANDROID when active device slot is missing', () => {
4339
+ const updateContent = jest.fn();
4340
+ require('../hooks/useInAppContent').useInAppContent = () => ({
4341
+ activeDevice: 'android',
4342
+ keepContentSame: false,
4343
+ updateContent,
4344
+ saveContent: jest.fn(),
4345
+ markAsSaved: jest.fn(),
4346
+ switchDevice: jest.fn(),
4347
+ toggleContentSync: jest.fn(),
4348
+ getDeviceContent: () => '<p>old</p>',
4349
+ setDeviceContent: jest.fn(),
4350
+ getContentSize: () => 20,
4351
+ isLoading: false,
4352
+ isDirty: false,
4353
+ hasContent: true,
4354
+ });
4355
+
4356
+ render(
4357
+ <TestWrapper>
4358
+ <HTMLEditor
4359
+ {...defaultProps}
4360
+ variant="inapp"
4361
+ initialContent={{ ios: '<p>ios only</p>' }}
4362
+ />
4363
+ </TestWrapper>
4364
+ );
4365
+
4366
+ act(() => {
4367
+ jest.runAllTimers();
4368
+ });
4369
+
4370
+ expect(updateContent).toHaveBeenCalledWith('', true);
4371
+ });
4372
+
4373
+ it('inapp initialContent effect skips when updateContent is not available', () => {
4374
+ require('../hooks/useInAppContent').useInAppContent = () => ({
4375
+ activeDevice: 'android',
4376
+ keepContentSame: false,
4377
+ updateContent: undefined,
4378
+ saveContent: jest.fn(),
4379
+ markAsSaved: jest.fn(),
4380
+ switchDevice: jest.fn(),
4381
+ toggleContentSync: jest.fn(),
4382
+ getDeviceContent: () => '<p>x</p>',
4383
+ setDeviceContent: jest.fn(),
4384
+ getContentSize: () => 20,
4385
+ isLoading: false,
4386
+ isDirty: false,
4387
+ hasContent: true,
4388
+ });
4389
+
4390
+ render(
4391
+ <TestWrapper>
4392
+ <HTMLEditor
4393
+ {...defaultProps}
4394
+ variant="inapp"
4395
+ initialContent="<p>y</p>"
4396
+ />
4397
+ </TestWrapper>
4398
+ );
4399
+
4400
+ act(() => {
4401
+ jest.runAllTimers();
4402
+ });
4403
+
4404
+ expect(screen.getByTestId('device-toggle')).toBeInTheDocument();
4405
+ });
4406
+
4407
+ it('uses default forwardRef props when only intl is supplied', () => {
4408
+ const WrappedHTMLEditor = HTMLEditor.WrappedComponent || HTMLEditor;
4409
+ const intlProvider = new IntlProvider({ locale: 'en', messages: {} }, {});
4410
+ const { intl } = intlProvider.getChildContext();
4411
+
4412
+ render(
4413
+ <TestWrapper>
4414
+ <WrappedHTMLEditor intl={intl} />
4415
+ </TestWrapper>
4416
+ );
4417
+
4418
+ act(() => {
4419
+ jest.runAllTimers();
4420
+ });
4421
+
4422
+ expect(screen.getByTestId('editor-toolbar')).toBeInTheDocument();
4423
+ });
4424
+
4425
+ it('main validation effect notifies when isContentEmpty changes (hasChanged branch)', () => {
4426
+ const onValidationChange = jest.fn();
4427
+ let hookContent = '';
4428
+
4429
+ require('../hooks/useEditorContent').useEditorContent = () => ({
4430
+ content: hookContent,
4431
+ updateContent: jest.fn(),
4432
+ saveContent: jest.fn(),
4433
+ markAsSaved: jest.fn(),
4434
+ isLoading: false,
4435
+ isDirty: false,
4436
+ hasContent: !!hookContent,
4437
+ getContentSize: jest.fn(() => hookContent.length),
4438
+ });
4439
+
4440
+ const { rerender } = render(
4441
+ <TestWrapper>
4442
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
4443
+ </TestWrapper>
4444
+ );
4445
+
4446
+ act(() => {
4447
+ jest.runAllTimers();
4448
+ });
4449
+
4450
+ onValidationChange.mockClear();
4451
+
4452
+ hookContent = '<p>not empty</p>';
4453
+
4454
+ rerender(
4455
+ <TestWrapper>
4456
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
4457
+ </TestWrapper>
4458
+ );
4459
+
4460
+ act(() => {
4461
+ jest.runAllTimers();
4462
+ });
4463
+
4464
+ expect(onValidationChange).toHaveBeenCalled();
4465
+ const lastCall = onValidationChange.mock.calls[onValidationChange.mock.calls.length - 1][0];
4466
+ expect(lastCall.isContentEmpty).toBe(false);
4467
+ });
4468
+ });
3544
4469
  });