@capillarytech/creatives-library 9.0.57-alpha.1 → 9.0.57-alpha.3

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.1",
4
+ "version": "9.0.57-alpha.3",
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,8 +273,11 @@ 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 only alongside a parent formData sync.
279
- emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
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.
278
+ if (propsRef.current.isEdit) {
279
+ emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
280
+ }
280
281
  // Classic validates on every deep-unequal parent push (Classic.js:516-537) —
281
282
  // this is what turns the container's isFormValid true before Create/Done.
282
283
  if (validateOnExternalChange) {
@@ -439,11 +440,8 @@ const FormBuilderShell = (props) => {
439
440
  // MPUSH opts out of live validation via features.liveValidation:false, SMS keeps it.
440
441
  const validateLive = liquidEnabled && !isFullMode
441
442
  && adapter.config?.features?.liveValidation !== false;
442
- // MPUSH: Classic still re-validates EVERY change via the container round-trip
443
- // (onChange -> container setState -> CWRP deep-unequal formData push -> validateForm,
444
- // Classic.js:516-537) — the container's isFormValid must track typing or the
445
- // library-mode Done gate (Create/index.js:117) stays stuck. Display remains
446
- // 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.
447
445
  if (validationActive || validateLive || validateOnExternalChange) {
448
446
  emitValidity(runValidate(legacy), legacy);
449
447
  }
@@ -626,6 +624,7 @@ FormBuilderShell.propTypes = {
626
624
  tagModule: PropTypes.string,
627
625
  tags: PropTypes.array,
628
626
  checkValidation: PropTypes.bool,
627
+ isEdit: PropTypes.bool,
629
628
  injectedTags: PropTypes.object,
630
629
  onContextChange: PropTypes.func,
631
630
  // Shapes match TagList's own contract (array of offer objects / keyed-by-blockId map).
@@ -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,6 +115,26 @@ 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
 
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.
120
+ act(() => {
121
+ containerRef.setState((prev) => ({
122
+ formData: { ...prev.formData, 0: { ...prev.formData[0], image: 'blob:uploaded' } },
123
+ }));
124
+ });
125
+ act(() => { jest.advanceTimersByTime(1000); });
126
+ const pane = document.querySelectorAll('.ant-tabs-tabpane')[0] || document;
127
+ const label = Array.from(pane.querySelectorAll('label')).find((l) => /action link/i.test(l.textContent || ''));
128
+ const cta = (label && label.querySelector('input[type="checkbox"]')) || pane.querySelector('input[type="checkbox"]');
129
+ act(() => { fireEvent.click(cta); });
130
+ act(() => { jest.advanceTimersByTime(1000); });
131
+ act(() => { fireEvent.click(cta); });
132
+ act(() => { jest.advanceTimersByTime(1000); });
133
+
134
+ expect(containerRef.state.tabCount).toBe(1); // create flow never adopts the prop
135
+ const finalCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
136
+ expect(finalCalls[finalCalls.length - 1]).toEqual({ isContentEmpty: false }); // buttons stay enabled
137
+
125
138
  utils.unmount();
126
139
  });
127
140
  });
@@ -161,4 +161,40 @@ describe('MPUSH shell event bridge', () => {
161
161
  expect(legacy[0]).toEqual(expect.objectContaining({ 'message-title': '' }));
162
162
  expect(legacy[1]).toEqual(expect.objectContaining({ 'message-title2': '' }));
163
163
  });
164
+
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.
167
+ it('create flow keeps emitting tabCount 1 after a genuine parent push (image write-back); edit adopts the prop', () => {
168
+ const writeBack = (base) => ({
169
+ ...base,
170
+ 0: { ...base[0], image: 'blob:uploaded' }, // container's in-place upload write-back
171
+ });
172
+
173
+ const typeTitle = (value) => {
174
+ const el = document.getElementById('message-title');
175
+ fireEvent.change(el, { target: { value } });
176
+ };
177
+
178
+ // CREATE (no isEdit): tabCount must stay 1
179
+ const createProps = buildProps(buildSchema(), { formData: {} });
180
+ const createMount = mount(createProps);
181
+ act(() => {
182
+ createMount.rerender(createMount.ui({ ...createProps, formData: writeBack(buildFormData()) }));
183
+ });
184
+ act(() => { typeTitle('after push'); }); // next emission carries the tabCount
185
+ const createEmit = createProps.onChange.mock.calls[createProps.onChange.mock.calls.length - 1];
186
+ expect(createEmit[1]).toBe(1);
187
+ createMount.unmount();
188
+
189
+ // EDIT: Classic adopts the prop on the data sync (Classic.js:447)
190
+ const editProps = buildProps(buildSchema(), { formData: {}, isEdit: true });
191
+ const editMount = mount(editProps);
192
+ act(() => {
193
+ editMount.rerender(editMount.ui({ ...editProps, formData: writeBack(buildFormData()) }));
194
+ });
195
+ act(() => { typeTitle('after push'); });
196
+ const editEmit = editProps.onChange.mock.calls[editProps.onChange.mock.calls.length - 1];
197
+ expect(editEmit[1]).toBe(2);
198
+ editMount.unmount();
199
+ });
164
200
  });
@@ -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: [],