@capillarytech/creatives-library 8.0.281 → 8.0.282

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@capillarytech/creatives-library",
3
3
  "author": "meharaj",
4
- "version": "8.0.281",
4
+ "version": "8.0.282",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -49,10 +49,16 @@ const mockGetValidationState = jest.fn(() => ({
49
49
  issueCounts: { errors: 0, warnings: 0, total: 0 },
50
50
  }));
51
51
 
52
+ // Ref to capture apiValidationErrors passed to HTMLEditor (for mergedApiValidationErrors tests)
53
+ const capturedApiValidationErrorsRef = { current: null };
54
+
52
55
  // Mock HtmlEditor - it exports a lazy-loaded component by default
53
56
  jest.mock('../../../../v2Components/HtmlEditor/index.lazy', () => {
54
57
  const React = require('react');
55
58
  const MockHTMLEditor = React.forwardRef((props, ref) => {
59
+ if (global.__captureApiValidationErrorsRef && props.apiValidationErrors) {
60
+ global.__captureApiValidationErrorsRef.current = props.apiValidationErrors;
61
+ }
56
62
  React.useImperativeHandle(ref, () => ({
57
63
  getAllIssues: () => mockGetAllIssues(),
58
64
  getValidationState: () => mockGetValidationState(),
@@ -109,6 +115,9 @@ jest.mock('../../../../v2Components/HtmlEditor/index.lazy', () => {
109
115
  jest.mock('../../../../v2Components/HtmlEditor', () => {
110
116
  const React = require('react');
111
117
  const MockHTMLEditor = React.forwardRef((props, ref) => {
118
+ if (global.__captureApiValidationErrorsRef && props.apiValidationErrors) {
119
+ global.__captureApiValidationErrorsRef.current = props.apiValidationErrors;
120
+ }
112
121
  React.useImperativeHandle(ref, () => ({
113
122
  getAllIssues: () => mockGetAllIssues(),
114
123
  getValidationState: () => mockGetValidationState(),
@@ -421,6 +430,86 @@ describe('EmailHTMLEditor', () => {
421
430
  });
422
431
  // Reset hasLiquidSupportFeature mock to return true by default
423
432
  mockHasLiquidSupportFeature.mockReturnValue(true);
433
+ capturedApiValidationErrorsRef.current = null;
434
+ });
435
+
436
+ describe('mergedApiValidationErrors (lines 124-125)', () => {
437
+ beforeEach(() => {
438
+ global.__captureApiValidationErrorsRef = capturedApiValidationErrorsRef;
439
+ });
440
+
441
+ afterEach(() => {
442
+ delete global.__captureApiValidationErrorsRef;
443
+ });
444
+
445
+ it('merges tag unsupported errors into standardErrors when tagValidationError has unsupportedTags', async () => {
446
+ validateTags.mockReturnValue({
447
+ valid: false,
448
+ unsupportedTags: ['tagA', 'tagB'],
449
+ });
450
+ renderWithIntl({
451
+ metaEntities: { tags: { standard: [{ name: 'customer.name' }] } },
452
+ tags: [{ name: 'customer.name' }],
453
+ supportedTags: [],
454
+ });
455
+ const changeButton = screen.getByTestId('trigger-content-change');
456
+ await act(async () => {
457
+ fireEvent.click(changeButton);
458
+ });
459
+ await waitFor(() => {
460
+ expect(capturedApiValidationErrorsRef.current).not.toBeNull();
461
+ expect(capturedApiValidationErrorsRef.current.liquidErrors).toEqual([]);
462
+ expect(capturedApiValidationErrorsRef.current.standardErrors).toContain(
463
+ 'Unsupported tags are: tagA, tagB',
464
+ );
465
+ });
466
+ });
467
+
468
+ it('merges tag missing errors into standardErrors when tagValidationError has missingTags and unsubscribe not mandatory', async () => {
469
+ isEmailUnsubscribeTagMandatory.mockReturnValue(false);
470
+ validateTags.mockReturnValue({
471
+ valid: false,
472
+ missingTags: ['unsubscribe'],
473
+ });
474
+ renderWithIntl({
475
+ metaEntities: { tags: { standard: [{ name: 'customer.name' }] } },
476
+ tags: [{ name: 'customer.name' }],
477
+ supportedTags: [],
478
+ });
479
+ const changeButton = screen.getByTestId('trigger-content-change');
480
+ await act(async () => {
481
+ fireEvent.click(changeButton);
482
+ });
483
+ await waitFor(() => {
484
+ expect(capturedApiValidationErrorsRef.current).not.toBeNull();
485
+ expect(capturedApiValidationErrorsRef.current.standardErrors).toContain(
486
+ 'Missing tags are: unsubscribe',
487
+ );
488
+ });
489
+ });
490
+
491
+ it('uses apiValidationErrors.liquidErrors and concatenates apiValidationErrors.standardErrors with tag messages (merge shape)', async () => {
492
+ // When tag messages exist, mergedApiValidationErrors returns liquidErrors from apiValidationErrors
493
+ // and standardErrors = [...(apiValidationErrors?.standardErrors || []), ...tagMessages] (lines 124-125)
494
+ validateTags.mockReturnValue({
495
+ valid: false,
496
+ unsupportedTags: ['customTag'],
497
+ });
498
+ renderWithIntl({
499
+ metaEntities: { tags: { standard: [{ name: 'customer.name' }] } },
500
+ tags: [{ name: 'customer.name' }],
501
+ });
502
+ const changeButton = screen.getByTestId('trigger-content-change');
503
+ await act(async () => {
504
+ fireEvent.click(changeButton);
505
+ });
506
+ await waitFor(() => {
507
+ expect(capturedApiValidationErrorsRef.current).not.toBeNull();
508
+ const { liquidErrors, standardErrors } = capturedApiValidationErrorsRef.current;
509
+ expect(liquidErrors).toEqual([]);
510
+ expect(standardErrors).toContain('Unsupported tags are: customTag');
511
+ });
512
+ });
424
513
  });
425
514
 
426
515
  describe('Default Parameter Values (lines 60-63)', () => {
@@ -768,6 +768,130 @@ describe('useEmailWrapper', () => {
768
768
  });
769
769
  });
770
770
 
771
+ describe('templateId resolution (lines 209-213)', () => {
772
+ it('should call getTemplateDetails with templateId from params.id', async () => {
773
+ const templateId = 'from-params-id';
774
+ const editProps = {
775
+ ...newFlowMockProps,
776
+ params: { id: templateId },
777
+ location: { pathname: '/email/edit/other', query: {} },
778
+ Email: {
779
+ ...newFlowMockProps.Email,
780
+ templateDetails: null,
781
+ getTemplateDetailsInProgress: false,
782
+ },
783
+ };
784
+
785
+ renderHook((props) => useEmailWrapper(props), {
786
+ initialProps: editProps,
787
+ });
788
+
789
+ await waitFor(() => {
790
+ expect(mockEmailActions.getTemplateDetails).toHaveBeenCalledWith(templateId, 'email');
791
+ });
792
+ });
793
+
794
+ it('should call getTemplateDetails with templateId from location.query.id when params.id is missing', async () => {
795
+ const templateId = 'from-query-id';
796
+ const editProps = {
797
+ ...newFlowMockProps,
798
+ params: {},
799
+ location: {
800
+ pathname: '/email/edit/something',
801
+ query: { id: templateId },
802
+ },
803
+ Email: {
804
+ ...newFlowMockProps.Email,
805
+ templateDetails: null,
806
+ getTemplateDetailsInProgress: false,
807
+ },
808
+ };
809
+
810
+ renderHook((props) => useEmailWrapper(props), {
811
+ initialProps: editProps,
812
+ });
813
+
814
+ await waitFor(() => {
815
+ expect(mockEmailActions.getTemplateDetails).toHaveBeenCalledWith(templateId, 'email');
816
+ });
817
+ });
818
+
819
+ it('should call getTemplateDetails with templateId from location.params.id when params and query id are missing', async () => {
820
+ const templateId = 'from-location-params-id';
821
+ const editProps = {
822
+ ...newFlowMockProps,
823
+ params: {},
824
+ location: {
825
+ pathname: '/email/edit/fallback',
826
+ query: {},
827
+ params: { id: templateId },
828
+ },
829
+ Email: {
830
+ ...newFlowMockProps.Email,
831
+ templateDetails: null,
832
+ getTemplateDetailsInProgress: false,
833
+ },
834
+ };
835
+
836
+ renderHook((props) => useEmailWrapper(props), {
837
+ initialProps: editProps,
838
+ });
839
+
840
+ await waitFor(() => {
841
+ expect(mockEmailActions.getTemplateDetails).toHaveBeenCalledWith(templateId, 'email');
842
+ });
843
+ });
844
+
845
+ it('should call getTemplateDetails with templateId extracted from pathname /edit/ID when no params or query id', async () => {
846
+ const templateId = 'extracted-from-pathname';
847
+ const editProps = {
848
+ ...newFlowMockProps,
849
+ params: {},
850
+ location: {
851
+ pathname: `/email/edit/${templateId}`,
852
+ query: {},
853
+ },
854
+ Email: {
855
+ ...newFlowMockProps.Email,
856
+ templateDetails: null,
857
+ getTemplateDetailsInProgress: false,
858
+ },
859
+ };
860
+
861
+ renderHook((props) => useEmailWrapper(props), {
862
+ initialProps: editProps,
863
+ });
864
+
865
+ await waitFor(() => {
866
+ expect(mockEmailActions.getTemplateDetails).toHaveBeenCalledWith(templateId, 'email');
867
+ });
868
+ });
869
+
870
+ it('should not call getTemplateDetails when pathname includes /edit/ but has no id segment', async () => {
871
+ const editProps = {
872
+ ...newFlowMockProps,
873
+ params: {},
874
+ location: {
875
+ pathname: '/email/edit/',
876
+ query: {},
877
+ },
878
+ Email: {
879
+ ...newFlowMockProps.Email,
880
+ templateDetails: null,
881
+ getTemplateDetailsInProgress: false,
882
+ },
883
+ };
884
+
885
+ renderHook((props) => useEmailWrapper(props), {
886
+ initialProps: editProps,
887
+ });
888
+
889
+ await waitFor(() => {
890
+ expect(mockEmailActions.getTemplateDetails).not.toHaveBeenCalled();
891
+ }, { timeout: 500 });
892
+ });
893
+ });
894
+
771
895
  describe('Edit Flow - BEE Editor Template', () => {
772
896
  it('should call getTemplateDetails and set BEE editor for BEE template', async () => {
773
897
  const templateId = 'bee-template-123';
@@ -177,9 +177,11 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
177
177
  }
178
178
  if (nextProps.metaEntities && nextProps.metaEntities.layouts && nextProps.metaEntities.layouts.length > 0 && _.isEmpty(this.state.fullSchema)) {
179
179
  this.setState({fullSchema: nextProps.metaEntities.layouts[0].definition, schema: (nextProps.location.query.module === 'loyalty') ? nextProps.metaEntities.layouts[0].definition.textSchema : {}}, () => {
180
- this.handleEditSchemaOnPropsChange(nextProps, selectedWeChatAccount);
180
+ // Use this.props (latest) in callback to avoid race: templateDetails may have arrived by now
181
+ const latestSelectedAccount = this.getSelectedWeChatAccountFromProps(this.props);
182
+ this.handleEditSchemaOnPropsChange(this.props, latestSelectedAccount);
181
183
  const templateId = get(this, "props.params.id");
182
- if (nextProps.location.query.module !== 'loyalty' && templateId && templateId !== 'temp') {
184
+ if (this.props.location.query.module !== 'loyalty' && templateId && templateId !== 'temp') {
183
185
  this.props.actions.getTemplateDetails(templateId);
184
186
  }
185
187
  if (queryType === EMBEDDED && templateId === 'temp' && _.isEmpty(this.state.formData)) { // when his.props.params.id is temp that means mobile push template content will be passed from post message from parent with startTemplateCreation action
@@ -727,6 +729,27 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
727
729
  parent.postMessage(JSON.stringify(response), '*');
728
730
  };
729
731
 
732
+ /**
733
+ * Compute selectedWeChatAccount from props (used so we can call with latest props
734
+ * in setState callback to avoid stale closure and intermittent empty form).
735
+ */
736
+ getSelectedWeChatAccountFromProps = (props) => {
737
+ const queryType = String(get(props, 'location.query.type', ''))?.toLowerCase();
738
+ const creativesMode = String(get(props, 'creativesMode', ''))?.toLowerCase();
739
+ const { Edit: EditProps, Templates } = props || {};
740
+ const { selectedWeChatAccount: editSelectedWeChatAccount } = EditProps || {};
741
+ const { Templates: nextTemplates } = props || {};
742
+ if (isEmbeddedEditOrPreview(queryType, creativesMode)) {
743
+ return !_.isEmpty(editSelectedWeChatAccount)
744
+ ? editSelectedWeChatAccount
745
+ : nextTemplates?.selectedWeChatAccount;
746
+ }
747
+ if (!_.isEmpty(Templates?.selectedWeChatAccount)) {
748
+ return Templates?.selectedWeChatAccount;
749
+ }
750
+ return undefined;
751
+ };
752
+
730
753
  getFormData = (e) => {
731
754
  const response = {
732
755
  action: "getFormData",
@@ -749,6 +772,9 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
749
772
  formData["mobilepush-accounts"] = this.state.formData["mobilepush-accounts"];
750
773
  formData['mobilepush-template'] = this.state.formData['mobilepush-template'];
751
774
  }
775
+ if (data.definition?.accountId) {
776
+ formData['mobilepush-accounts'] = data.definition.accountId;
777
+ }
752
778
  formData['template-name'] = data.name;
753
779
  const androidData = data.versions.base.ANDROID;
754
780
  const iosData = data.versions.base.IOS;
@@ -2004,8 +2030,23 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
2004
2030
  }
2005
2031
 
2006
2032
  handleEditSchemaOnPropsChange = (nextProps, selectedWeChatAccount) => {
2007
- if (!_.isEmpty(nextProps.templateDetails) && _.isEmpty(this.state.editData) && !_.isEmpty(this.state.fullSchema) && selectedWeChatAccount && (this.props.location.query.type !== 'embedded' || this.props.isFullMode === false)) {
2033
+ const queryType = String(get(this.props, 'location.query.type', ''))?.toLowerCase();
2034
+ const isEmbeddedLibrary = queryType === EMBEDDED && !nextProps.isFullMode;
2035
+ const canSetAccountFromTemplate =
2036
+ !selectedWeChatAccount &&
2037
+ nextProps.templateDetails?.definition?.accountId &&
2038
+ nextProps.Edit?.weCrmAccounts?.length > 0;
2039
+ const canPopulateForm =
2040
+ !_.isEmpty(nextProps.templateDetails) &&
2041
+ _.isEmpty(this.state.editData) &&
2042
+ !_.isEmpty(this.state.fullSchema) &&
2043
+ (this.props.location.query.type !== 'embedded' || this.props.isFullMode === false) &&
2044
+ (selectedWeChatAccount || isEmbeddedLibrary || canSetAccountFromTemplate);
2045
+ if (canPopulateForm) {
2008
2046
  this.props = nextProps;
2047
+ if (canSetAccountFromTemplate) {
2048
+ this.setMobilePushAccountOptions(nextProps.Edit.weCrmAccounts, nextProps.templateDetails.definition.accountId);
2049
+ }
2009
2050
  const mode = nextProps.templateDetails.definition ? nextProps.templateDetails.definition.mode : nextProps.templateDetails.mode;
2010
2051
  const schema = mode === "text" ? this.state.fullSchema?.textSchema : this.state.fullSchema?.imageSchema;
2011
2052
  const isAndroidSupported = get(this, "props.Templates.selectedWeChatAccount.configs.android") === '1';
@@ -2040,7 +2081,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
2040
2081
  <CapSpin spinning={spinning}>
2041
2082
  <CapRow>
2042
2083
  <CapColumn>
2043
- <FormBuilder
2084
+ {!this.props.isLoadingMetaEntities && <FormBuilder
2044
2085
  channel={MOBILE_PUSH}
2045
2086
  schema={schema}
2046
2087
  showLiquidErrorInFooter={this.props.showLiquidErrorInFooter}
@@ -2075,7 +2116,7 @@ export class Edit extends React.Component { // eslint-disable-line react/prefer-
2075
2116
  hideTestAndPreviewBtn={this.props.hideTestAndPreviewBtn}
2076
2117
  isFullMode={this.props.isFullMode}
2077
2118
  eventContextTags={this.props?.eventContextTags}
2078
- />
2119
+ />}
2079
2120
  </CapColumn>
2080
2121
  {this.props.iosCtasData && this.state.showIosCtaTable &&
2081
2122
  <CapSlideBox