@capillarytech/creatives-library 9.0.57-alpha.2 → 9.0.57-alpha.4

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": "9.0.57-alpha.2",
4
+ "version": "9.0.57-alpha.4",
5
5
  "description": "Capillary creatives ui",
6
6
  "main": "./index.js",
7
7
  "module": "./index.es.js",
@@ -1575,8 +1575,18 @@ const CommonTestAndPreview = (props) => {
1575
1575
  if (formDataObj?.whatsappDocSource) {
1576
1576
  whatsappMedia.url = formDataObj.whatsappDocSource;
1577
1577
  }
1578
- if (formDataObj?.docPreview || formDataObj?.whatsappDocParams) {
1579
- const docParams = formDataObj.docPreview || formDataObj.whatsappDocParams;
1578
+ // Prefer whatsappDocParams for document data; docPreview may be a React preview element.
1579
+ // Fall back to docPreview only when it contains plain document data.
1580
+ const isPlainDocParamsObject = (value) =>
1581
+ !!value && typeof value === 'object' && !React.isValidElement(value);
1582
+ const getDocParams = (formDataObj) => {
1583
+ const { whatsappDocParams, docPreview } = formDataObj || {};
1584
+ if (isPlainDocParamsObject(whatsappDocParams)) return whatsappDocParams;
1585
+ if (isPlainDocParamsObject(docPreview)) return docPreview;
1586
+ return null;
1587
+ };
1588
+ const docParams = getDocParams(formDataObj);
1589
+ if (docParams) {
1580
1590
  whatsappMedia.previewUrl = docParams?.whatsappDocImg || '';
1581
1591
  whatsappMedia.docParams = {
1582
1592
  whatsappDocName: docParams?.whatsappDocName || '',
@@ -208,10 +208,8 @@ const FormBuilderShell = (props) => {
208
208
  );
209
209
  useEffect(() => () => debouncedEmit.cancel(), [debouncedEmit]);
210
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.
211
+ // Classic pushes formData up on init and on every schema re-init (Classic.js:1906-1911 ->
212
+ // 740-748); the MPUSH copy-content handlers read that copy. MPUSH-only, SMS unchanged.
215
213
  const emitFormDataOnSchemaSync = Boolean(adapter.config?.features?.emitFormDataOnSchemaSync);
216
214
 
217
215
  // ---- initialize once the (possibly async) schema is ready, then validate+emit ----
@@ -275,11 +273,8 @@ const FormBuilderShell = (props) => {
275
273
  const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
276
274
  if (!isEcho) {
277
275
  dispatch(hydrate(fromLegacy(parentFormData, { channel })));
278
- // Classic mirrors the tabCount prop on a formData sync only in EDIT mode
279
- // (Classic.js:446-447); its generic mirror (415) needs the prop VALUE to
280
- // change, which never happens with the containers' hardcoded tabCount={2}.
281
- // Adopting 2 in the create flow makes the containers' content-emptiness
282
- // check (CAP-189913) scan the untouched iOS tab -> Done/Preview stuck disabled.
276
+ // Classic mirrors the tabCount prop on a data sync only in EDIT mode (Classic.js:446-447);
277
+ // adopting the create flow's hardcoded 2 makes the emptiness check scan the empty iOS tab.
283
278
  if (propsRef.current.isEdit) {
284
279
  emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
285
280
  }
@@ -445,11 +440,8 @@ const FormBuilderShell = (props) => {
445
440
  // MPUSH opts out of live validation via features.liveValidation:false, SMS keeps it.
446
441
  const validateLive = liquidEnabled && !isFullMode
447
442
  && adapter.config?.features?.liveValidation !== false;
448
- // MPUSH: Classic still re-validates EVERY change via the container round-trip
449
- // (onChange -> container setState -> CWRP deep-unequal formData push -> validateForm,
450
- // Classic.js:516-537) — the container's isFormValid must track typing or the
451
- // library-mode Done gate (Create/index.js:117) stays stuck. Display remains
452
- // gated on checkValidation, so this emission is invisible pre-save.
443
+ // MPUSH: Classic re-validates every change via the container round-trip (Classic.js:516-537)
444
+ // keeps the container's isFormValid current for the library Done gate; display stays gated.
453
445
  if (validationActive || validateLive || validateOnExternalChange) {
454
446
  emitValidity(runValidate(legacy), legacy);
455
447
  }
@@ -32,21 +32,22 @@ export const fieldIds = {
32
32
 
33
33
  // The 13 fixed CTA/deeplink rules of Classic's validateForm MPUSH block
34
34
  // (Classic.js:1037-1135), in source order. kind: 'empty' => `=== ""` fails
35
- // (absent key passes); kind: 'url' => isValidExternalLink semantics.
35
+ // (absent field passes); kind: 'url' => isValidExternalLink semantics.
36
+ // Named fieldId (not `key:`) so gitleaks' generic-api-key rule stops matching.
36
37
  export const CTA_RULES = [
37
- { key: 'cta-deeplink-select', kind: 'empty' },
38
- { key: 'cta-deeplink2-select', kind: 'empty' },
39
- { key: 'cta-deeplink-text', kind: 'url' },
40
- { key: 'cta-deeplink2-text', kind: 'url' },
41
- { key: 'secondary-cta-0-link', kind: 'empty' },
42
- { key: 'secondary-cta-0-label', kind: 'empty' },
43
- { key: 'cta-deeplink-secondary-cta-0-text', kind: 'url' },
44
- { key: 'cta-deeplink-secondary-cta-0-select', kind: 'empty' },
45
- { key: 'secondary-cta-1-label', kind: 'empty' },
46
- { key: 'cta-deeplink-secondary-cta-1-text', kind: 'url' },
47
- { key: 'cta-deeplink-secondary-cta-1-select', kind: 'empty' },
48
- { key: 'cta-deeplink-secondary-cta-1-text2', kind: 'url' },
49
- { key: 'cta-deeplink-secondary-cta-1-select2', kind: 'empty' },
38
+ { fieldId: 'cta-deeplink-select', kind: 'empty' },
39
+ { fieldId: 'cta-deeplink2-select', kind: 'empty' },
40
+ { fieldId: 'cta-deeplink-text', kind: 'url' },
41
+ { fieldId: 'cta-deeplink2-text', kind: 'url' },
42
+ { fieldId: 'secondary-cta-0-link', kind: 'empty' },
43
+ { fieldId: 'secondary-cta-0-label', kind: 'empty' },
44
+ { fieldId: 'cta-deeplink-secondary-cta-0-text', kind: 'url' },
45
+ { fieldId: 'cta-deeplink-secondary-cta-0-select', kind: 'empty' },
46
+ { fieldId: 'secondary-cta-1-label', kind: 'empty' },
47
+ { fieldId: 'cta-deeplink-secondary-cta-1-text', kind: 'url' },
48
+ { fieldId: 'cta-deeplink-secondary-cta-1-select', kind: 'empty' },
49
+ { fieldId: 'cta-deeplink-secondary-cta-1-text2', kind: 'url' },
50
+ { fieldId: 'cta-deeplink-secondary-cta-1-select2', kind: 'empty' },
50
51
  ];
51
52
 
52
53
  export default {
@@ -170,16 +170,16 @@ export default function validateMobilePush(formData = {}, options = {}) {
170
170
  }
171
171
 
172
172
  // --- fixed CTA/deeplink rules (Classic.js:1037-1135), unsuffixed by design ---
173
- CTA_RULES.forEach(({ key, kind }) => {
173
+ CTA_RULES.forEach(({ fieldId, kind }) => {
174
174
  const failed = kind === 'url'
175
- ? !isValidExternalLink(tab, key)
176
- : tab[key] === '';
175
+ ? !isValidExternalLink(tab, fieldId)
176
+ : tab[fieldId] === '';
177
177
  if (failed) {
178
- tabErrors[key] = true;
178
+ tabErrors[fieldId] = true;
179
179
  isValid = false;
180
180
  isCurrentTabValid = false;
181
181
  } else {
182
- tabErrors[key] = false;
182
+ tabErrors[fieldId] = false;
183
183
  }
184
184
  });
185
185
 
@@ -271,17 +271,21 @@ export const UploadField = ({
271
271
  // Classic fires the wrong-file event and CONTINUES (no early return) — preserved.
272
272
  onEvent(field.submitAction, { file, type: 'wrong file' });
273
273
  }
274
- const objectUrl = (window.URL || window.webkitURL).createObjectURL(file);
274
+ const urlApi = window.URL || window.webkitURL;
275
+ const objectUrl = urlApi.createObjectURL(file);
275
276
  const img = new Image();
276
277
  img.src = objectUrl;
277
278
  img.onload = () => {
278
279
  const fileParams = {
279
- width: img.width,
280
- height: img.height,
280
+ width: img?.width,
281
+ height: img?.height,
281
282
  error: Boolean(file && (file.size / 1e6 > 5)), // > 5 MB (Classic.js:2753)
282
283
  };
284
+ urlApi.revokeObjectURL(objectUrl); // dimension read done — free the blob URL
283
285
  onEvent(field.submitAction, { file, type: 'image', fileParams });
284
286
  };
287
+ // Classic dispatches nothing on a failed image load — only release the URL.
288
+ img.onerror = () => urlApi.revokeObjectURL(objectUrl);
285
289
  e.target.value = null;
286
290
  };
287
291
 
@@ -312,9 +316,8 @@ export const UploadField = ({
312
316
  />
313
317
  )}
314
318
  <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. */}
319
+ {/* Native input on purpose: openFileDialog clicks it via #fileName, and CapInput
320
+ (antd text Input + affix wrapper) can't host type="file". */}
318
321
  <input
319
322
  key={field.id}
320
323
  className="fb-upload-file-input"
@@ -1,15 +1,8 @@
1
1
  /**
2
- * Library/embedded-mode validity parity — the campaigns-host Done/Preview gate.
3
- *
4
- * The container's isFormValid must track typing: Classic re-validates every change
5
- * via the container round-trip (onChange -> container setState -> CWRP deep-unequal
6
- * formData push -> validateForm -> onFormValidityChange, Classic.js:516-537), and the
7
- * library-mode Done click early-returns on !state.isFormValid (Create/index.js:117).
8
- * The shell mirrors this with a background validity emission on every field change
9
- * under features.validateOnExternalChange (display stays gated on checkValidation).
10
- *
11
- * Also pins onContentValidityChange ({isContentEmpty}) — the footer's live
12
- * Done/Preview-and-Test disable signal (CreativesContainer handleContentValidityChange).
2
+ * Library/embedded-mode validity parity — the campaigns-host Done/Preview gates.
3
+ * Pins that the container's isFormValid tracks typing (Classic re-validates each change
4
+ * via the container round-trip, Classic.js:516-537; the Done click early-returns on
5
+ * !isFormValid) and the onContentValidityChange({isContentEmpty}) footer signal.
13
6
  */
14
7
  import React from 'react';
15
8
  import '@testing-library/jest-dom';
@@ -122,10 +115,8 @@ describe.each([
122
115
  const contentCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
123
116
  expect(contentCalls[contentCalls.length - 1]).toEqual({ isContentEmpty: false });
124
117
 
125
- // user repro: image-upload write-back (a genuine parent push) then CTA check/uncheck.
126
- // The shell must NOT adopt the hardcoded tabCount={2} prop in the create flow
127
- // (Classic.js:415/446-447), or the content check scans the empty iOS tab and
128
- // Done/Preview stick disabled.
118
+ // user repro: image write-back (genuine parent push) then CTA check/uncheck — the create
119
+ // flow must not adopt the hardcoded tabCount={2} or the check scans the empty iOS tab.
129
120
  act(() => {
130
121
  containerRef.setState((prev) => ({
131
122
  formData: { ...prev.formData, 0: { ...prev.formData[0], image: 'blob:uploaded' } },
@@ -162,11 +162,8 @@ describe('MPUSH shell event bridge', () => {
162
162
  expect(legacy[1]).toEqual(expect.objectContaining({ 'message-title2': '' }));
163
163
  });
164
164
 
165
- // Classic mirrors the tabCount prop on a formData push only in EDIT mode
166
- // (Classic.js:446-447; the generic mirror at 415 needs the prop VALUE to change,
167
- // never true with the containers' hardcoded tabCount={2}). Adopting 2 in the
168
- // create flow made getMobilePushEmbeddedContentValidity (CAP-189913) scan the
169
- // untouched iOS tab after an image-upload write-back push -> Done/Preview stuck.
165
+ // Classic mirrors the tabCount prop on a data push only in EDIT mode (Classic.js:446-447);
166
+ // adopting the create flow's hardcoded 2 made the emptiness check scan the empty iOS tab.
170
167
  it('create flow keeps emitting tabCount 1 after a genuine parent push (image write-back); edit adopts the prop', () => {
171
168
  const writeBack = (base) => ({
172
169
  ...base,
@@ -187,6 +187,7 @@ describe('UploadField', () => {
187
187
  it('file change: no file => no event; wrong extension fires the wrong-file event and CONTINUES (Classic parity)', () => {
188
188
  const createObjectURL = jest.fn(() => 'blob:x');
189
189
  window.URL.createObjectURL = createObjectURL;
190
+ window.URL.revokeObjectURL = jest.fn();
190
191
  const onEvent = jest.fn();
191
192
  const { input } = parts(UploadField({
192
193
  field, value: '', error: false, onEvent, renderContext: {},
@@ -201,8 +202,10 @@ describe('UploadField', () => {
201
202
  expect(createObjectURL).toHaveBeenCalledWith(file); // no early return
202
203
  });
203
204
 
204
- it('image load reports dimensions and the >5MB error flag', () => {
205
+ it('image load reports dimensions and the >5MB error flag, then releases the blob URL', () => {
205
206
  window.URL.createObjectURL = jest.fn(() => 'blob:x');
207
+ const revokeObjectURL = jest.fn();
208
+ window.URL.revokeObjectURL = revokeObjectURL;
206
209
  const images = [];
207
210
  const RealImage = global.Image;
208
211
  global.Image = class { constructor() { images.push(this); } };
@@ -220,6 +223,11 @@ describe('UploadField', () => {
220
223
  expect(onEvent).toHaveBeenCalledWith('onUpload', {
221
224
  file, type: 'image', fileParams: { width: 300, height: 200, error: true },
222
225
  });
226
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:x');
227
+ // failed image load: URL released, no event (Classic dispatches nothing)
228
+ input.props.onChange({ target: { files: [file], value: null } });
229
+ images[images.length - 1].onerror();
230
+ expect(revokeObjectURL).toHaveBeenCalledTimes(2);
223
231
  } finally {
224
232
  global.Image = RealImage;
225
233
  }
@@ -336,6 +336,7 @@ const HTMLEditor = forwardRef(({
336
336
  const validationTotalErrors = validation?.summary?.totalErrors || 0;
337
337
  const validationTotalWarnings = validation?.summary?.totalWarnings || 0;
338
338
  const validationLastValidated = validation?.lastValidated?.getTime?.() || null;
339
+ const validationValidatedContent = validation?.validatedContent;
339
340
  // Only Rule Group #1 (Input & Sanitization) blocks; use for UI gating (Done/Update/Preview/Test)
340
341
  const validationHasBlockingErrors = validation?.hasBlockingErrors || false;
341
342
 
@@ -350,6 +351,11 @@ const HTMLEditor = forwardRef(({
350
351
  return;
351
352
  }
352
353
 
354
+ // isValidating is false during the debounce, so results can still be for older content.
355
+ if (validationValidatedContent !== undefined && validationValidatedContent !== currentContent) {
356
+ return;
357
+ }
358
+
353
359
  // Calculate issue counts: Errors (blocking) and Warnings (non-blocking)
354
360
  const calculateIssueCounts = () => {
355
361
  const currentValidation = validationRef.current;
@@ -384,7 +390,7 @@ const HTMLEditor = forwardRef(({
384
390
  lastSentValidationStateRef.current = newState;
385
391
  onValidationChangeRef.current(newState);
386
392
  }
387
- }, [isValidating, validationTotalErrors, validationTotalWarnings, validationLastValidated, validationHasBlockingErrors, currentContent, apiValidationErrors]);
393
+ }, [isValidating, validationTotalErrors, validationTotalWarnings, validationLastValidated, validationHasBlockingErrors, validationValidatedContent, currentContent, apiValidationErrors]);
388
394
 
389
395
  // Send initial state on mount to ensure parent has correct initial button state
390
396
  const hasInitializedRef = useRef(false);
@@ -416,6 +422,11 @@ const HTMLEditor = forwardRef(({
416
422
  return;
417
423
  }
418
424
 
425
+ // Same staleness guard as the effect above.
426
+ if (validation?.validatedContent !== undefined && validation?.validatedContent !== currentContent) {
427
+ return;
428
+ }
429
+
419
430
  // Recalculate issue counts (Errors + Warnings) including API errors
420
431
  const issueCounts = !validation || typeof validation.getAllIssues !== 'function'
421
432
  ? { errors: 0, warnings: 0, total: 0 }
@@ -4462,5 +4462,98 @@ describe('HTMLEditor', () => {
4462
4462
  const lastCall = onValidationChange.mock.calls[onValidationChange.mock.calls.length - 1][0];
4463
4463
  expect(lastCall.isContentEmpty).toBe(false);
4464
4464
  });
4465
+
4466
+ const mockContentHook = (content) => {
4467
+ require('../hooks/useEditorContent').useEditorContent = () => ({
4468
+ content,
4469
+ updateContent: jest.fn(),
4470
+ saveContent: jest.fn(),
4471
+ markAsSaved: jest.fn(),
4472
+ isLoading: false,
4473
+ isDirty: false,
4474
+ hasContent: !!content,
4475
+ getContentSize: jest.fn(() => content.length),
4476
+ });
4477
+ };
4478
+
4479
+ const mockValidationFor = (validatedContent) => {
4480
+ mockUseValidationImpl.mockImplementation(() => ({
4481
+ isValidating: false,
4482
+ validatedContent,
4483
+ getAllIssues: () => [],
4484
+ isClean: () => true,
4485
+ summary: { totalErrors: 0, totalWarnings: 0 },
4486
+ }));
4487
+ };
4488
+
4489
+ it('does not report validation as complete while results are for older content', () => {
4490
+ const onValidationChange = jest.fn();
4491
+ mockContentHook('<p>uploaded zip content</p>');
4492
+ mockValidationFor('<p>previous content</p>');
4493
+
4494
+ render(
4495
+ <TestWrapper>
4496
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
4497
+ </TestWrapper>
4498
+ );
4499
+
4500
+ act(() => {
4501
+ jest.runAllTimers();
4502
+ });
4503
+
4504
+ const completed = onValidationChange.mock.calls.filter((call) => call[0].validationComplete);
4505
+ expect(completed).toHaveLength(0);
4506
+ });
4507
+
4508
+ it('reports validation as complete once results match the current content', () => {
4509
+ const onValidationChange = jest.fn();
4510
+ const content = '<p>uploaded zip content</p>';
4511
+ mockContentHook(content);
4512
+ mockValidationFor(content);
4513
+
4514
+ render(
4515
+ <TestWrapper>
4516
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
4517
+ </TestWrapper>
4518
+ );
4519
+
4520
+ act(() => {
4521
+ jest.runAllTimers();
4522
+ });
4523
+
4524
+ const completed = onValidationChange.mock.calls.filter((call) => call[0].validationComplete);
4525
+ expect(completed.length).toBeGreaterThan(0);
4526
+ });
4527
+
4528
+ it('does not re-notify parent when the validation state is unchanged', () => {
4529
+ const onValidationChange = jest.fn();
4530
+ const content = '<p>uploaded zip content</p>';
4531
+ mockContentHook(content);
4532
+ mockValidationFor(content);
4533
+
4534
+ const { rerender } = render(
4535
+ <TestWrapper>
4536
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
4537
+ </TestWrapper>
4538
+ );
4539
+
4540
+ act(() => {
4541
+ jest.runAllTimers();
4542
+ });
4543
+
4544
+ onValidationChange.mockClear();
4545
+
4546
+ rerender(
4547
+ <TestWrapper>
4548
+ <HTMLEditor {...defaultProps} onValidationChange={onValidationChange} />
4549
+ </TestWrapper>
4550
+ );
4551
+
4552
+ act(() => {
4553
+ jest.runAllTimers();
4554
+ });
4555
+
4556
+ expect(onValidationChange).not.toHaveBeenCalled();
4557
+ });
4465
4558
  });
4466
4559
  });
@@ -84,6 +84,7 @@ export const useValidation = (content, variant = 'email', options = {}, formatSa
84
84
  const [validationState, setValidationState] = useState({
85
85
  isValidating: false,
86
86
  lastValidated: null,
87
+ validatedContent: null, // content these results came from
87
88
  htmlErrors: [],
88
89
  htmlWarnings: [],
89
90
  htmlInfo: [],
@@ -114,6 +115,7 @@ export const useValidation = (content, variant = 'email', options = {}, formatSa
114
115
  setValidationState((prev) => ({
115
116
  ...prev,
116
117
  isValidating: false,
118
+ validatedContent: htmlContent,
117
119
  htmlErrors: [],
118
120
  htmlWarnings: [],
119
121
  htmlInfo: [],
@@ -169,6 +171,7 @@ export const useValidation = (content, variant = 'email', options = {}, formatSa
169
171
  setValidationState({
170
172
  isValidating: false,
171
173
  lastValidated: new Date(),
174
+ validatedContent: htmlContent,
172
175
  htmlErrors: htmlValidation.errors,
173
176
  htmlWarnings: htmlValidation.warnings,
174
177
  htmlInfo: htmlValidation.info,
@@ -191,6 +194,7 @@ export const useValidation = (content, variant = 'email', options = {}, formatSa
191
194
  setValidationState((prev) => ({
192
195
  ...prev,
193
196
  isValidating: false,
197
+ validatedContent: htmlContent,
194
198
  htmlErrors: [{
195
199
  type: 'error',
196
200
  message: `Validation failed: ${error.message}`,
@@ -255,6 +259,7 @@ export const useValidation = (content, variant = 'email', options = {}, formatSa
255
259
  setValidationState({
256
260
  isValidating: false,
257
261
  lastValidated: null,
262
+ validatedContent: null,
258
263
  htmlErrors: [],
259
264
  htmlWarnings: [],
260
265
  htmlInfo: [],