@capillarytech/creatives-library 8.0.353-alpha.1 → 8.0.353-alpha.2

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.
@@ -200,370 +200,6 @@ describe('ViberPreviewContent', () => {
200
200
 
201
201
  expect(screen.getByText('Click Here')).toBeTruthy();
202
202
  });
203
-
204
- it('should not render button when buttonText has only whitespace', () => {
205
- const props = {
206
- ...defaultProps,
207
- content: {
208
- viberPreviewContent: {
209
- messageContent: 'Message',
210
- buttonText: ' ',
211
- },
212
- },
213
- };
214
-
215
- const { container } = render(
216
- <TestWrapper>
217
- <ComponentToRender {...props} />
218
- </TestWrapper>
219
- );
220
-
221
- expect(container.querySelector('.viber-button-base')).toBeFalsy();
222
- });
223
- });
224
-
225
- describe('Carousel Content', () => {
226
- it('should render no content when carousel is selected but cards are empty', () => {
227
- const props = {
228
- ...defaultProps,
229
- content: {
230
- viberPreviewContent: {
231
- type: 'CAROUSEL',
232
- cards: [
233
- {
234
- text: '',
235
- mediaUrl: '',
236
- buttons: [
237
- { title: '', action: '' },
238
- ],
239
- },
240
- {
241
- text: ' ',
242
- mediaUrl: ' ',
243
- buttons: [
244
- { title: ' ', action: 'https://example.com/2' },
245
- ],
246
- },
247
- ],
248
- },
249
- },
250
- };
251
-
252
- render(
253
- <TestWrapper>
254
- <ComponentToRender {...props} />
255
- </TestWrapper>
256
- );
257
-
258
- expect(screen.getByText('No content available')).toBeTruthy();
259
- });
260
-
261
- it('should render carousel cards when type is CAROUSEL', () => {
262
- const props = {
263
- ...defaultProps,
264
- content: {
265
- viberPreviewContent: {
266
- type: 'CAROUSEL',
267
- cards: [
268
- {
269
- text: 'Card 1 text',
270
- mediaUrl: 'https://image.url/card1.jpg',
271
- buttons: [
272
- { title: 'Button 1', action: 'https://example.com/1' },
273
- ],
274
- },
275
- {
276
- text: 'Card 2 text',
277
- mediaUrl: 'https://image.url/card2.jpg',
278
- buttons: [
279
- { title: 'Button 2', action: 'https://example.com/2' },
280
- ],
281
- },
282
- ],
283
- },
284
- },
285
- };
286
-
287
- render(
288
- <TestWrapper>
289
- <ComponentToRender {...props} />
290
- </TestWrapper>
291
- );
292
-
293
- expect(screen.getByText('Card 1 text')).toBeTruthy();
294
- expect(screen.getByText('Card 2 text')).toBeTruthy();
295
- expect(screen.getByText('Button 1')).toBeTruthy();
296
- expect(screen.getByText('Button 2')).toBeTruthy();
297
- });
298
-
299
- it('should not render empty carousel button placeholder', () => {
300
- const props = {
301
- ...defaultProps,
302
- content: {
303
- viberPreviewContent: {
304
- type: 'CAROUSEL',
305
- cards: [
306
- {
307
- text: 'Card 1 text',
308
- mediaUrl: 'https://image.url/card1.jpg',
309
- buttons: [
310
- { title: '', action: 'https://example.com/1' },
311
- { title: ' ', action: 'https://example.com/2' },
312
- ],
313
- },
314
- ],
315
- },
316
- },
317
- };
318
-
319
- const { container } = render(
320
- <TestWrapper>
321
- <ComponentToRender {...props} />
322
- </TestWrapper>
323
- );
324
-
325
- expect(container.querySelector('.viber-carousel-preview-button')).toBeNull();
326
- });
327
-
328
- it('should show carousel shell when showCarouselEditorPreview is true even if cards are empty', () => {
329
- const props = {
330
- ...defaultProps,
331
- content: {
332
- viberPreviewContent: {
333
- type: 'CAROUSEL',
334
- showCarouselEditorPreview: true,
335
- cards: [
336
- { text: '', mediaUrl: '', buttons: [{ title: '', action: '' }] },
337
- { text: '', mediaUrl: '', buttons: [{ title: '', action: '' }] },
338
- ],
339
- },
340
- },
341
- };
342
-
343
- const { container } = render(
344
- <TestWrapper>
345
- <ComponentToRender {...props} />
346
- </TestWrapper>
347
- );
348
-
349
- expect(screen.queryByText('No content available')).toBeNull();
350
- expect(container.querySelector('.viber-carousel-preview-scroll')).toBeTruthy();
351
- expect(container.querySelector('.viber-carousel-message-box-placeholder')).toBeTruthy();
352
- });
353
-
354
- it('should render carousel message text in message box when type is CAROUSEL', () => {
355
- const props = {
356
- ...defaultProps,
357
- content: {
358
- viberPreviewContent: {
359
- type: 'CAROUSEL',
360
- messageContent: 'Carousel intro copy',
361
- cards: [
362
- {
363
- text: 'Card text',
364
- mediaUrl: 'https://image.url/c.jpg',
365
- buttons: [{ title: 'Go', action: 'https://example.com' }],
366
- },
367
- ],
368
- },
369
- },
370
- };
371
-
372
- const { container } = render(
373
- <TestWrapper>
374
- <ComponentToRender {...props} />
375
- </TestWrapper>
376
- );
377
-
378
- expect(container.querySelector('.viber-carousel-message-box-text')).toHaveTextContent('Carousel intro copy');
379
- expect(screen.queryByText('Carousel intro copy')).toBeTruthy();
380
- });
381
-
382
- it('should hide account icon when carousel is shown', () => {
383
- const props = {
384
- ...defaultProps,
385
- content: {
386
- viberPreviewContent: {
387
- type: 'CAROUSEL',
388
- cards: [
389
- {
390
- text: 'Carousel card line',
391
- mediaUrl: '',
392
- buttons: [{ title: 'Open link', action: 'https://x.com' }],
393
- },
394
- ],
395
- },
396
- },
397
- };
398
-
399
- const { container } = render(
400
- <TestWrapper>
401
- <ComponentToRender {...props} />
402
- </TestWrapper>
403
- );
404
-
405
- expect(container.querySelector('.viber-account-icon')).toBeNull();
406
- });
407
-
408
- it('should use image placeholder when carousel card mediaUrl is whitespace only', () => {
409
- const props = {
410
- ...defaultProps,
411
- content: {
412
- viberPreviewContent: {
413
- type: 'CAROUSEL',
414
- cards: [
415
- {
416
- text: 'Only text',
417
- mediaUrl: ' ',
418
- buttons: [{ title: 'Btn', action: 'https://example.com' }],
419
- },
420
- ],
421
- },
422
- },
423
- };
424
-
425
- const { container } = render(
426
- <TestWrapper>
427
- <ComponentToRender {...props} />
428
- </TestWrapper>
429
- );
430
-
431
- expect(container.querySelector('.viber-carousel-preview-image-placeholder')).toBeTruthy();
432
- expect(container.querySelector('.viber-carousel-preview-image')).toBeNull();
433
- });
434
-
435
- it('should render at most two carousel buttons per card', () => {
436
- const props = {
437
- ...defaultProps,
438
- content: {
439
- viberPreviewContent: {
440
- type: 'CAROUSEL',
441
- cards: [
442
- {
443
- text: 'Card',
444
- mediaUrl: 'https://image.url/c.jpg',
445
- buttons: [
446
- { title: 'One', action: 'https://a.com' },
447
- { title: 'Two', action: 'https://b.com' },
448
- { title: 'Three', action: 'https://c.com' },
449
- ],
450
- },
451
- ],
452
- },
453
- },
454
- };
455
-
456
- const { container } = render(
457
- <TestWrapper>
458
- <ComponentToRender {...props} />
459
- </TestWrapper>
460
- );
461
-
462
- expect(container.querySelectorAll('.viber-carousel-preview-button')).toHaveLength(2);
463
- expect(screen.getByText('One')).toBeTruthy();
464
- expect(screen.getByText('Two')).toBeTruthy();
465
- expect(screen.queryByText('Three')).toBeNull();
466
- });
467
-
468
- it('should apply secondary class to second carousel button', () => {
469
- const props = {
470
- ...defaultProps,
471
- content: {
472
- viberPreviewContent: {
473
- type: 'CAROUSEL',
474
- cards: [
475
- {
476
- text: 'Card',
477
- mediaUrl: 'https://image.url/c.jpg',
478
- buttons: [
479
- { title: 'Primary', action: 'https://a.com' },
480
- { title: 'Secondary', action: 'https://b.com' },
481
- ],
482
- },
483
- ],
484
- },
485
- },
486
- };
487
-
488
- const { container } = render(
489
- <TestWrapper>
490
- <ComponentToRender {...props} />
491
- </TestWrapper>
492
- );
493
-
494
- const buttons = container.querySelectorAll('.viber-carousel-preview-button');
495
- expect(buttons[0].className).toContain('viber-carousel-preview-button');
496
- expect(buttons[0].className).not.toContain('viber-carousel-preview-button-secondary');
497
- expect(buttons[1].className).toContain('viber-carousel-preview-button-secondary');
498
- });
499
-
500
- it('should show carousel when only a button title is present on a card', () => {
501
- const props = {
502
- ...defaultProps,
503
- content: {
504
- viberPreviewContent: {
505
- type: 'CAROUSEL',
506
- cards: [
507
- {
508
- text: '',
509
- mediaUrl: '',
510
- buttons: [{ title: 'Tap me', action: 'https://example.com' }],
511
- },
512
- ],
513
- },
514
- },
515
- };
516
-
517
- render(
518
- <TestWrapper>
519
- <ComponentToRender {...props} />
520
- </TestWrapper>
521
- );
522
-
523
- expect(screen.getByText('Tap me')).toBeTruthy();
524
- expect(screen.queryByText('No content available')).toBeNull();
525
- });
526
-
527
- it('should show one placeholder card when editor preview and cards array is empty', () => {
528
- const props = {
529
- ...defaultProps,
530
- content: {
531
- viberPreviewContent: {
532
- type: 'CAROUSEL',
533
- showCarouselEditorPreview: true,
534
- cards: [],
535
- },
536
- },
537
- };
538
-
539
- const { container } = render(
540
- <TestWrapper>
541
- <ComponentToRender {...props} />
542
- </TestWrapper>
543
- );
544
-
545
- expect(container.querySelectorAll('.viber-carousel-preview-card')).toHaveLength(1);
546
- });
547
-
548
- it('should show no content when CAROUSEL has empty cards and no editor preview flag', () => {
549
- const props = {
550
- ...defaultProps,
551
- content: {
552
- viberPreviewContent: {
553
- type: 'CAROUSEL',
554
- cards: [],
555
- },
556
- },
557
- };
558
-
559
- render(
560
- <TestWrapper>
561
- <ComponentToRender {...props} />
562
- </TestWrapper>
563
- );
564
-
565
- expect(screen.getByText('No content available')).toBeTruthy();
566
- });
567
203
  });
568
204
 
569
205
  describe('Account and Brand Name', () => {
@@ -382,7 +382,12 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
382
382
  this.setState({formData: nextProps.formData, tabCount: nextProps.tabCount});
383
383
  // this.resetTabKeys(nextProps.formData, nextProps.tabCount);
384
384
  } else if (this.props.schema && this.props.schema.channel && this.props.schema.channel.toUpperCase() === 'EMAIL') {
385
- this.setState({formData: nextProps.formData});
385
+ // Skip state overwrite when only high-frequency fields changed — FormBuilder
386
+ // already updated them via updateFieldValueImmediately, so overwriting here
387
+ // would cause a redundant full re-render ~300ms after every keystroke.
388
+ if (!this._isOnlyHighFreqUpdate(nextProps.formData, this.state.formData)) {
389
+ this.setState({formData: nextProps.formData});
390
+ }
386
391
  }
387
392
 
388
393
  if (this.state.usingTabContainer && this.state.tabKey === '') {
@@ -423,14 +428,24 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
423
428
  ( !this.state.usingTabContainer || (this.state.usingTabContainer && nextProps.tabKey !== ''))
424
429
  && !_.isEqual(nextProps.formData, this.state.formData) &&
425
430
  !_.isEqual(nextProps.formData, this.props.formData)) {
426
- // Don't run validation if we're in Test & Preview mode
427
- if (!nextProps.isTestAndPreviewMode) {
428
- this.setState({formData: nextProps.formData, tabKey: nextProps.tabKey}, () => {
429
- this.validateForm();
430
- });
431
- } else {
432
- // Just update formData without validation
433
- this.setState({formData: nextProps.formData, tabKey: nextProps.tabKey});
431
+ // For EMAIL: skip state overwrite when only high-frequency fields (template-name /
432
+ // template-subject) changed — they are already correct via updateFieldValueImmediately.
433
+ const isEmailHighFreqOnly = (
434
+ this.props.schema &&
435
+ this.props.schema.channel &&
436
+ this.props.schema.channel.toUpperCase() === 'EMAIL' &&
437
+ this._isOnlyHighFreqUpdate(nextProps.formData, this.state.formData)
438
+ );
439
+ if (!isEmailHighFreqOnly) {
440
+ // Don't run validation if we're in Test & Preview mode
441
+ if (!nextProps.isTestAndPreviewMode) {
442
+ this.setState({formData: nextProps.formData, tabKey: nextProps.tabKey}, () => {
443
+ this.validateForm();
444
+ });
445
+ } else {
446
+ // Just update formData without validation
447
+ this.setState({formData: nextProps.formData, tabKey: nextProps.tabKey});
448
+ }
434
449
  }
435
450
  //this.resetTabKeys(nextProps.formData, nextProps.tabCount);
436
451
  } else if ((_.isEmpty(this.props.formData) || !this.props.formData) && _.isEmpty(this.state.formData)) {
@@ -448,7 +463,16 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
448
463
  this.setState({currentTab: nextProps.currentTab});
449
464
  }
450
465
 
451
- if (!_.isEmpty(nextProps.formData) && !_.isEqual(this.state.formData, nextProps.formData)) {
466
+ // For EMAIL: check high-freq first (cheap) to avoid the expensive _.isEqual
467
+ // and the setState + validateForm cascade triggered by every debounced keystroke.
468
+ const isEmailHighFreqOnly = (
469
+ !_.isEmpty(nextProps.formData) &&
470
+ this.props.schema &&
471
+ this.props.schema.channel &&
472
+ this.props.schema.channel.toUpperCase() === 'EMAIL' &&
473
+ this._isOnlyHighFreqUpdate(nextProps.formData, this.state.formData)
474
+ );
475
+ if (!isEmailHighFreqOnly && !_.isEmpty(nextProps.formData) && !_.isEqual(this.state.formData, nextProps.formData)) {
452
476
  if (nextProps.isNewVersionFlow) {
453
477
  const tabKey = (this.state.tabKey !== nextProps.formData[nextProps.currentTab - 1].tabKey) ? nextProps.formData[nextProps.currentTab - 1].tabKey : this.state.tabKey;
454
478
 
@@ -2181,6 +2205,20 @@ class FormBuilder extends React.Component { // eslint-disable-line react/prefer-
2181
2205
  this.debouncedUpdateFormData(data, val, event, true);
2182
2206
  }
2183
2207
 
2208
+ // Returns true when the only differences between newData and currentData are
2209
+ // the high-frequency standalone fields (template-name / template-subject).
2210
+ // Uses reference equality for all other keys — safe because shallow spreads in
2211
+ // optimizedFormDataUpdate and updateFieldValueImmediately preserve nested refs.
2212
+ _isOnlyHighFreqUpdate(newData, currentData) {
2213
+ if (!newData || !currentData) return false;
2214
+ // isTemplateNameEdited is set alongside template-name by performTemplateNameUpdate
2215
+ // and treated as a high-freq field so it doesn't break the reference equality check.
2216
+ const HIGH_FREQ_FIELDS = ['template-name', 'template-subject', 'isTemplateNameEdited'];
2217
+ return Object.keys(newData).every(
2218
+ key => HIGH_FREQ_FIELDS.includes(key) || newData[key] === currentData[key]
2219
+ );
2220
+ }
2221
+
2184
2222
  // Update field value immediately for UI feedback
2185
2223
  updateFieldValueImmediately(data, val) {
2186
2224
  const currentFormData = this.state.formData;
@@ -1,4 +1,43 @@
1
1
  import React from 'react';
2
+
3
+ // Isolated input for the email template name field.
4
+ // Manages its own value in local state so keystrokes only re-render this
5
+ // small component, not the entire CreativesContainer → Email → FormBuilder tree.
6
+ class TemplateNameInputField extends React.Component {
7
+ constructor(props) {
8
+ super(props);
9
+ this.state = { localValue: props.initialValue || '' };
10
+ }
11
+
12
+ componentDidUpdate(prevProps) {
13
+ // Sync from props only when the external value changed AND the user hasn't
14
+ // diverged from the previous prop value. This handles async data-load in edit
15
+ // mode without overwriting what the user is actively typing.
16
+ if (
17
+ prevProps.initialValue !== this.props.initialValue &&
18
+ this.state.localValue === (prevProps.initialValue || '')
19
+ ) {
20
+ this.setState({ localValue: this.props.initialValue || '' });
21
+ }
22
+ }
23
+
24
+ handleChange = (ev) => {
25
+ const { value } = ev.currentTarget;
26
+ this.setState({ localValue: value });
27
+ this.props.onChange(value);
28
+ };
29
+
30
+ render() {
31
+ const { onChange: _onChange, initialValue: _initialValue, ...rest } = this.props;
32
+ return (
33
+ <CapInput
34
+ {...rest}
35
+ value={this.state.localValue}
36
+ onChange={this.handleChange}
37
+ />
38
+ );
39
+ }
40
+ }
2
41
  import PropTypes from 'prop-types';
3
42
  import {
4
43
  CAP_SPACE_16, CAP_SPACE_32, CAP_SPACE_56, CAP_SPACE_64,
@@ -191,7 +230,10 @@ export class Creatives extends React.Component {
191
230
  // Performance optimized template name update
192
231
  performTemplateNameUpdate = (value, formData, onFormDataChange) => {
193
232
  const isEmptyTemplateName = !value.trim();
194
- const newFormData = { ...formData, 'template-name': value, 'isTemplateNameEdited': true };
233
+ // _highFreqField signals Email's onFormDataChange that only a high-frequency
234
+ // standalone field changed, enabling the fast-path cache in getFormDataForBuilder
235
+ // and skipping the expensive FormBuilder re-render + validateForm cascade.
236
+ const newFormData = { ...formData, 'template-name': value, 'isTemplateNameEdited': true, _highFreqField: 'template-name' };
195
237
 
196
238
  this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
197
239
  onFormDataChange(newFormData);
@@ -1753,30 +1795,24 @@ export class Creatives extends React.Component {
1753
1795
  } />
1754
1796
  )
1755
1797
 
1756
- templateNameComponentInput = ({ formData, onFormDataChange, name }) => {
1757
- // Use local state for immediate UI feedback, fallback to prop value
1758
- const displayValue = this.state.localTemplateName !== '' ? this.state.localTemplateName : name;
1759
-
1760
- return (
1761
- <CapInput
1762
- value={displayValue}
1763
- suffix={<span />}
1764
- onBlur={() => {
1765
- this.setState({
1766
- isEditName: false,
1767
- localTemplateName: '', // Clear local state on blur
1768
- }, () => {
1769
- this.showTemplateName({ formData, onFormDataChange });
1770
- });
1771
- }}
1772
- onChange={(ev) => {
1773
- const { value } = ev.currentTarget;
1774
- // Use optimized update for better performance
1775
- this.updateTemplateNameImmediately(value, formData, onFormDataChange);
1776
- }}
1777
- />
1778
- );
1779
- }
1798
+ templateNameComponentInput = ({ formData, onFormDataChange, name }) => (
1799
+ <TemplateNameInputField
1800
+ initialValue={name}
1801
+ suffix={<span />}
1802
+ onBlur={() => {
1803
+ this.setState({ isEditName: false }, () => {
1804
+ this.showTemplateName({ formData, onFormDataChange });
1805
+ });
1806
+ }}
1807
+ onChange={(value) => {
1808
+ const isEmptyTemplateName = !value.trim();
1809
+ if (this.state.isTemplateNameEmpty !== isEmptyTemplateName) {
1810
+ this.setState({ isTemplateNameEmpty: isEmptyTemplateName });
1811
+ }
1812
+ this.debouncedTemplateNameUpdate(value, formData, onFormDataChange);
1813
+ }}
1814
+ />
1815
+ )
1780
1816
 
1781
1817
  showTemplateName = ({ formData, onFormDataChange }) => { //gets called from email/index after template data is fetched
1782
1818
  const {
@@ -795,9 +795,16 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
795
795
  delete window?.[CREATIVES_S3_ASSET_FILESIZES];
796
796
  }
797
797
 
798
- onFormDataChange = (updatedFormData, tabCount, currentTab) => {
798
+ // performFormDataUpdate in FormBuilder passes `val` as the 4th arg to props.onChange.
799
+ // CreativesContainer.performTemplateNameUpdate passes _highFreqField on the formData object.
800
+ // Both paths set _highFreqUpdate so getFormDataForBuilder can use the fast-path cache.
801
+ onFormDataChange = (updatedFormData, tabCount, currentTab, val) => {
799
802
  // this.transformFormData(formData);
800
803
  const formData = {...updatedFormData};
804
+ // Consume and clean up the CC-path signal before storing in state
805
+ const highFreqField = (val && val.id) || updatedFormData._highFreqField;
806
+ delete formData._highFreqField;
807
+
801
808
  const {defaultData = {}, isFullMode, showTemplateName} = this.props;
802
809
  const templateName = formData['template-name'];
803
810
  const defaultTemplateName = _.get(defaultData, 'template-name', "");
@@ -809,6 +816,9 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
809
816
  formData['template-name'] = templateName;
810
817
  }
811
818
 
819
+ // Must be set before setState so getFormDataForBuilder reads it during the triggered re-render.
820
+ const HIGH_FREQ_FIELDS = ['template-name', 'template-subject'];
821
+ this._highFreqUpdate = !!(highFreqField && HIGH_FREQ_FIELDS.includes(highFreqField));
812
822
 
813
823
  this.setState({formData, tabCount, isSchemaChanged: false}, () => {
814
824
  if (this.props.isFullMode && showTemplateName) {
@@ -821,6 +831,27 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
821
831
  //this.resetCkEditorInstance(currentTab, formData);
822
832
  }
823
833
 
834
+ // Returns a formData object safe to pass to FormBuilder.
835
+ // For high-frequency field updates (template-name / template-subject) patches only
836
+ // those fields into the existing cache, avoiding an expensive _.cloneDeep of the
837
+ // entire email formData (HTML content, tabs, language variants) on every keystroke.
838
+ // All other operations (tab changes, language add/delete, etc.) still get a full clone.
839
+ getFormDataForBuilder = () => {
840
+ const formData = this.state.formData;
841
+ if (this._highFreqUpdate && this._formDataBuilderCache) {
842
+ this._formDataBuilderCache = {
843
+ ...this._formDataBuilderCache,
844
+ 'template-name': formData['template-name'],
845
+ 'template-subject': formData['template-subject'],
846
+ 'isTemplateNameEdited': formData['isTemplateNameEdited'],
847
+ };
848
+ } else {
849
+ this._formDataBuilderCache = _.cloneDeep(formData);
850
+ }
851
+ this._highFreqUpdate = false;
852
+ return this._formDataBuilderCache;
853
+ }
854
+
824
855
  onChange = (evt) => {
825
856
  const {isFullMode, showTemplateName} = this.props;
826
857
  const formData = _.cloneDeep(this.state.formData);
@@ -3129,7 +3160,7 @@ export class Email extends React.Component { // eslint-disable-line react/prefer
3129
3160
  onChange={this.onFormDataChange}
3130
3161
  currentTab={this.state.currentTab}
3131
3162
  parent={this}
3132
- formData={_.cloneDeep(this.state.formData)}
3163
+ formData={this.getFormDataForBuilder()}
3133
3164
  location={this.props.location}
3134
3165
  tabKey={this.state.tabKey}
3135
3166
  tags={tags}